Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 31 additions & 1 deletion src/main/ipc/t3Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,12 @@ import {
AGENT_PROVIDER_AUTH_CANCEL,
AGENT_PROVIDER_STATUS_GET,
AGENT_PROVIDER_SETTINGS,
AGENT_REMOTE_START,
AGENT_REMOTE_GET,
AGENT_REMOTE_WRITE,
AGENT_REMOTE_CANCEL,
} from '../../shared/ipc-channels'
import type { AgentHarnessPanelRequest, AgentProviderAuthRequest, AgentProviderId, AgentProviderStatusRequest } from '../../shared/t3Agent'
import type { AgentHarnessPanelRequest, AgentProviderAuthRequest, AgentProviderId, AgentProviderStatusRequest, T3RemoteOperation } from '../../shared/t3Agent'
import { t3HarnessManager } from '../t3Agent/T3HarnessManager'
import { broadcastToAll, windowFromEvent } from '../windowRegistry'

Expand Down Expand Up @@ -77,6 +81,32 @@ function requireWindowId(event: IpcMainInvokeEvent): number {
}

export function registerT3AgentHandlers(): void {
ipcMain.handle(AGENT_REMOTE_START, async (event, input: unknown) => {
try {
const request = validateProviderStatusRequest(input)
const operation = (input as { operation?: unknown }).operation
if (operation !== 'status' && operation !== 'link' && operation !== 'unlink') throw new Error('Invalid T3 Connect operation')
return await t3HarnessManager.startRemote({ ...request, operation: operation as T3RemoteOperation }, requireWindowId(event))
} catch (error) { return { error: error instanceof Error ? error.message : String(error) } }
})
ipcMain.handle(AGENT_REMOTE_GET, (event, input: unknown) => {
try {
return t3HarnessManager.getRemote(requireText((input as { id?: unknown } | null)?.id, 'id'), requireWindowId(event))
} catch (error) { return { error: error instanceof Error ? error.message : String(error) } }
})
ipcMain.handle(AGENT_REMOTE_WRITE, (event, input: unknown) => {
try {
const request = input as { id?: unknown; data?: unknown } | null
t3HarnessManager.writeRemote(requireText(request?.id, 'id'), requireWindowId(event), typeof request?.data === 'string' ? request.data : '')
return { ok: true }
} catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } }
})
ipcMain.handle(AGENT_REMOTE_CANCEL, (event, input: unknown) => {
try {
t3HarnessManager.cancelRemote(requireText((input as { id?: unknown } | null)?.id, 'id'), requireWindowId(event))
return { ok: true }
} catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } }
})
ipcMain.handle(AGENT_HARNESS_RENAME_CONVERSATION, async (event, input: unknown) => {
try {
const request = validateProviderStatusRequest(input)
Expand Down
27 changes: 27 additions & 0 deletions src/main/t3Agent/T3HarnessManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ function runtime() {
function instance(key: string, rt: ReturnType<typeof runtime>) {
return {
key, runtimeId: key.startsWith('local:') ? 'local' : 'remote', runtime: rt, environmentId: 'env', proxyPort: 4321,
entryPath: '/bundled/t3/bin.mjs', baseDir: `/app/harness/instances/${key}`,
serverId: key, panels: new Set<string>(),
proxy: { close: vi.fn((done: () => void) => done()) },
}
Expand Down Expand Up @@ -183,6 +184,32 @@ describe('T3 provider sign-in ownership', () => {
})
})

describe('T3 Connect', () => {
it('runs the bundled CLI against the selected checkout and restarts after linking', async () => {
const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/alias', operation: 'link' }, 1)
expect(local.process.create).toHaveBeenCalledWith(expect.objectContaining({
cwd: '/repo', scopeId: 'ws',
command: expect.objectContaining({ args: ['/bundled/t3/bin.mjs', 'connect', 'link', '--base-dir', '/app/harness/instances/local:/repo', '--headless'] }),
}), expect.any(Function), expect.any(Function))
expect(() => manager.getRemote(session.id, 2)).toThrow('not found')
expect(() => manager.writeRemote(session.id, 2, 'yes')).toThrow('not found')
manager.writeRemote(session.id, 1, 'yes\n')
expect(local.process.write).toHaveBeenCalledWith(session.id, 'yes\n')
local.process.create.mock.calls[0][2](session.id, 0)
await vi.waitFor(() => expect(manager.getRemote(session.id, 1).phase).toBe('succeeded'))
expect(local.server.stop).toHaveBeenCalledWith('local:/repo')
expect(start).toHaveBeenCalledTimes(2)
})

it('rejects remote workspaces and concurrent operations', async () => {
await expect(manager.startRemote({ workspaceId: 'ws', cwd: 'ssh:/repo', operation: 'link' }, 1)).rejects.toThrow('local workspaces')
const session = await manager.startRemote({ workspaceId: 'ws', cwd: '/repo', operation: 'status' }, 1)
await expect(manager.startRemote({ workspaceId: 'ws', cwd: '/alias', operation: 'unlink' }, 1)).rejects.toThrow('already running')
manager.cancelRemote(session.id, 1)
expect(local.process.kill).toHaveBeenCalledWith(session.id)
})
})

it('copies provider secrets through canonical directory aliases and still rejects collision-renamed files', async () => {
const secret = 'provider-env-Y29kZXg-VE9LRU4.bin'
const rt = {
Expand Down
99 changes: 99 additions & 0 deletions src/main/t3Agent/T3HarnessManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import type {
AgentProviderId,
AgentProviderStatus,
AgentProviderStatusRequest,
T3RemoteOperation,
T3RemoteSession,
} from '../../shared/t3Agent'

const READY_PATH = '/.well-known/t3/environment'
Expand Down Expand Up @@ -77,6 +79,13 @@ interface ProviderAuthState extends AgentProviderAuthSession {
rawOutput: string
}

interface RemoteSessionState extends T3RemoteSession {
ownerWindowId: number
key: string
processId?: string
runtime: Runtime
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
Expand Down Expand Up @@ -146,6 +155,7 @@ export class T3HarnessManager {
private readonly panelRoute = new Map<string, AgentHarnessPanelRequest['route']>()
private readonly locatorHarness = new Map<string, string>()
private readonly providerAuth = new Map<string, ProviderAuthState>()
private readonly remoteSessions = new Map<string, RemoteSessionState>()

constructor() {
const onDisconnected = runtimes.onDisconnected?.bind(runtimes)
Expand All @@ -166,9 +176,94 @@ export class T3HarnessManager {
this.cancelProviderAuth(auth.id, windowId)
}
}
for (const remote of this.remoteSessions.values()) {
if (remote.ownerWindowId === windowId && remote.phase === 'running') this.cancelRemote(remote.id, windowId)
}
})
}

async startRemote(request: AgentProviderStatusRequest & { operation: T3RemoteOperation }, ownerWindowId: number): Promise<T3RemoteSession> {
const resolved = resolveLocator(request.cwd)
if (resolved.runtimeId !== 'local') throw new Error('T3 Connect is currently available for local workspaces only')
const cwd = await resolved.runtime.validatePathStrict(resolved.path, ownerWindowId, request.workspaceId)
const key = harnessKey(resolved.runtimeId, cwd)
if ([...this.remoteSessions.values()].some((session) => session.key === key && session.phase === 'running')) {
throw new Error('A T3 Connect operation is already running for this checkout')
}
const instance = await this.ensureInstance(key, resolved.runtimeId, resolved.runtime, cwd, request.workspaceId)
const id = `t3-remote-${randomUUID()}`
const state: RemoteSessionState = {
id, operation: request.operation, phase: 'running', output: '', ownerWindowId,
key, runtime: resolved.runtime,
}
this.remoteSessions.set(id, state)
try {
const handle = await resolved.runtime.process.create({
id, cols: 96, rows: 30, cwd, scopeId: request.workspaceId,
command: {
executable: harnessNodeExecutable(resolved.runtimeId, app.isPackaged),
args: [instance.entryPath, 'connect', request.operation, '--base-dir', instance.baseDir,
...(request.operation === 'link' ? ['--headless'] : [])],
},
env: { TERM: 'xterm-256color' },
}, (_id, chunk) => {
const current = this.remoteSessions.get(id)
if (current) current.output = cleanProviderAuthOutput(`${current.output}${chunk}`).slice(-24_000)
}, (_id, exitCode) => {
const current = this.remoteSessions.get(id)
if (!current || current.phase !== 'running') return
if (exitCode !== 0) {
current.phase = 'failed'
current.message = `T3 Connect exited with code ${exitCode}.`
} else if (request.operation === 'link') {
void this.restart(cwd).then(() => this.ensureInstance(key, resolved.runtimeId, resolved.runtime, cwd, request.workspaceId)).then(() => {
current.phase = 'succeeded'
current.message = 'T3 Connect is enabled for this checkout.'
}).catch((error) => {
current.phase = 'failed'
current.message = `T3 Connect was authorized, but the server could not restart: ${errorMessage(error)}`
})
} else {
current.phase = 'succeeded'
current.message = request.operation === 'unlink' ? 'T3 Connect is disabled for this checkout.' : undefined
}
})
state.processId = handle.id
if (state.phase === 'cancelled') resolved.runtime.process.kill(handle.id)
return this.remoteSnapshot(state)
} catch (error) {
this.remoteSessions.delete(id)
throw error
}
}

getRemote(id: string, ownerWindowId: number): T3RemoteSession {
return this.remoteSnapshot(this.ownedRemote(id, ownerWindowId))
}

writeRemote(id: string, ownerWindowId: number, data: string): void {
const state = this.ownedRemote(id, ownerWindowId)
if (state.phase === 'running' && state.processId) state.runtime.process.write(state.processId, data)
}

cancelRemote(id: string, ownerWindowId: number): void {
const state = this.ownedRemote(id, ownerWindowId)
if (state.phase !== 'running') return
state.phase = 'cancelled'
if (state.processId) state.runtime.process.kill(state.processId)
}

private ownedRemote(id: string, ownerWindowId: number): RemoteSessionState {
const state = this.remoteSessions.get(id)
if (!state || state.ownerWindowId !== ownerWindowId) throw new Error('T3 Connect session was not found')
return state
}

private remoteSnapshot(state: RemoteSessionState): T3RemoteSession {
return { id: state.id, operation: state.operation, phase: state.phase, output: state.output,
...(state.message ? { message: state.message } : {}) }
}

async startProviderAuth(
request: AgentProviderAuthRequest,
ownerWindowId: number,
Expand Down Expand Up @@ -480,6 +575,10 @@ export class T3HarnessManager {

async disposeAll(): Promise<void> {
this.panelAcquisitions.clear()
for (const remote of this.remoteSessions.values()) {
if (remote.phase === 'running' && remote.processId) remote.runtime.process.kill(remote.processId)
}
this.remoteSessions.clear()
for (const auth of this.providerAuth.values()) {
if (auth.phase === 'running' && auth.processId) auth.runtime.process.kill(auth.processId)
}
Expand Down
8 changes: 8 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,10 @@ import {
AGENT_HARNESS_PANEL_CLOSED,
AGENT_HARNESS_RESTART,
AGENT_HARNESS_GET_STATUS,
AGENT_REMOTE_START,
AGENT_REMOTE_GET,
AGENT_REMOTE_WRITE,
AGENT_REMOTE_CANCEL,
AGENT_PROVIDER_AUTH_START,
AGENT_PROVIDER_AUTH_GET,
AGENT_PROVIDER_AUTH_WRITE,
Expand Down Expand Up @@ -509,6 +513,10 @@ const invokeForwarders = {
agentHarnessGetPanelUrl: makeInvoker<'agentHarnessGetPanelUrl'>(AGENT_HARNESS_GET_PANEL_URL),
agentHarnessRestart: makeInvoker<'agentHarnessRestart'>(AGENT_HARNESS_RESTART),
agentHarnessGetStatus: makeInvoker<'agentHarnessGetStatus'>(AGENT_HARNESS_GET_STATUS),
agentRemoteStart: makeInvoker<'agentRemoteStart'>(AGENT_REMOTE_START),
agentRemoteGet: makeInvoker<'agentRemoteGet'>(AGENT_REMOTE_GET),
agentRemoteWrite: makeInvoker<'agentRemoteWrite'>(AGENT_REMOTE_WRITE),
agentRemoteCancel: makeInvoker<'agentRemoteCancel'>(AGENT_REMOTE_CANCEL),
agentProviderAuthStart: makeInvoker<'agentProviderAuthStart'>(AGENT_PROVIDER_AUTH_START),
agentProviderAuthGet: makeInvoker<'agentProviderAuthGet'>(AGENT_PROVIDER_AUTH_GET),
agentProviderAuthWrite: makeInvoker<'agentProviderAuthWrite'>(AGENT_PROVIDER_AUTH_WRITE),
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/settings/AgentSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { AgentHooksSettings } from './AgentHooksSettings'
import { AGENT_PROVIDER_LOGINS, type AgentProviderLogin } from './providerAuthentication'
import type { AgentProviderAuthSession, AgentProviderStatus } from '../../shared/t3Agent'
import { errorMessage } from '../lib/errorMessage'
import { T3RemoteAccess } from './T3RemoteAccess'

export function AgentSettings() {
const [authProvider, setAuthProvider] = useState<AgentProviderLogin | null>(null)
Expand Down Expand Up @@ -132,7 +133,7 @@ export function AgentSettings() {
}, [authSession?.phase, authSession?.providerId, refreshProviderStatuses])

return (
<SearchableBlock keywords="t3 code agent providers models sign in authentication codex claude cursor grok hermes opencode kiro hooks activity status advanced display name accent color binary path home launch arguments custom models environment variables server password endpoint auto-compact updates archive chats merge generated titles">
<SearchableBlock keywords="t3 code agent providers models sign in authentication codex claude cursor grok hermes opencode kiro hooks activity status advanced display name accent color binary path home launch arguments custom models environment variables server password endpoint auto-compact updates archive chats merge generated titles remote phone connect tunnel">
<div className="flex flex-col gap-4">
<AgentProviderConfiguration workspaceId={workspaceId} cwd={cwd} onChanged={refreshProviderStatuses} authentication={(driver) => (
<div>
Expand Down Expand Up @@ -182,6 +183,7 @@ export function AgentSettings() {
})}
</div>
)} />
<T3RemoteAccess workspaceId={workspaceId} cwd={cwd} />
<div>
<h3 className="mb-2 text-sm font-medium text-primary">Agent hooks</h3>
<AgentHooksSettings />
Expand Down
76 changes: 76 additions & 0 deletions src/renderer/settings/T3RemoteAccess.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { T3RemoteOperation, T3RemoteSession } from '../../shared/t3Agent'
import { errorMessage } from '../lib/errorMessage'
import { SecondaryButton } from './SettingsComponents'

export function T3RemoteAccess({ workspaceId, cwd }: { workspaceId: string; cwd: string }) {
const [session, setSession] = useState<T3RemoteSession | null>(null)
const [error, setError] = useState('')
const [input, setInput] = useState('')
const runningId = useRef<string | null>(null)
const start = useCallback(async (operation: T3RemoteOperation) => {
setError('')
setSession(null)
try {
const result = await window.electronAPI.agentRemoteStart({ workspaceId, cwd, operation })
if ('error' in result) setError(result.error)
else setSession(result)
} catch (cause) { setError(errorMessage(cause, 'T3 Connect could not start.')) }
}, [workspaceId, cwd])

useEffect(() => {
if (!workspaceId || !cwd) return
void start('status')
}, [workspaceId, cwd, start])

const sessionId = session?.id
const sessionPhase = session?.phase
useEffect(() => {
if (!sessionId || sessionPhase !== 'running') return
const id = sessionId
const timer = window.setInterval(() => {
void window.electronAPI.agentRemoteGet({ id }).then((result) => {
if ('error' in result) setError(result.error)
else setSession((current) => current?.id === id ? result : current)
}).catch((cause) => setError(errorMessage(cause, 'Could not read T3 Connect status.')))
}, 500)
return () => window.clearInterval(timer)
}, [sessionId, sessionPhase])

useEffect(() => {
runningId.current = session?.phase === 'running' ? session.id : null
}, [session])
useEffect(() => () => {
if (runningId.current) void window.electronAPI.agentRemoteCancel({ id: runningId.current })
}, [])

const send = (data: string) => {
if (!session || session.phase !== 'running') return
void window.electronAPI.agentRemoteWrite({ id: session.id, data })
setInput('')
}

const busy = session?.phase === 'running'
const authorizationUrl = session?.operation === 'link'
? session.output.match(/https:\/\/[^\s<>"']+/)?.[0].replace(/[),.;]+$/, '')
: undefined
return <section className="border-t border-subtle pt-4">
<h3 className="text-sm font-medium text-primary">T3 Connect</h3>
<p className="mt-1 text-xs text-muted">Reach this checkout’s T3 conversations from your phone. Keep this Mac awake, online, and running Cate. T3 Connect uses a managed remote tunnel; no VPS or port forwarding is needed.</p>
<div className="mt-3 flex flex-wrap gap-2">
<SecondaryButton disabled={!cwd || busy} onClick={() => void start('link')}>Enable T3 Connect</SecondaryButton>
<SecondaryButton disabled={!cwd || busy} onClick={() => void start('unlink')}>Disable</SecondaryButton>
<SecondaryButton disabled={!cwd || busy} onClick={() => void start('status')}>Refresh status</SecondaryButton>
{busy && <SecondaryButton onClick={() => void window.electronAPI.agentRemoteCancel({ id: session.id })}>Cancel</SecondaryButton>}
</div>
{!cwd && <p className="mt-2 text-xs text-muted">Open a local workspace first.</p>}
{error && <p role="alert" className="mt-2 text-xs text-red-400">{error}</p>}
{session?.message && <p role="status" className="mt-2 text-xs text-muted">{session.message}</p>}
{authorizationUrl && <SecondaryButton onClick={() => window.electronAPI.openExternalUrl(authorizationUrl)}>Open authorization page</SecondaryButton>}
{session?.output && <pre className="mt-3 max-h-52 overflow-auto whitespace-pre-wrap break-all rounded-md bg-surface-raised p-3 text-xs text-primary">{session.output}</pre>}
{busy && session.operation === 'link' && <div className="mt-2 flex gap-2">
<input aria-label="T3 Connect response" className="min-w-0 flex-1 rounded border border-subtle bg-surface px-2 text-xs text-primary" value={input} onChange={(event) => setInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') send(`${input}\n`) }} placeholder="Reply to a T3 prompt, if needed" />
<SecondaryButton onClick={() => send(`${input}\n`)}>Send</SecondaryButton>
</div>}
</section>
}
5 changes: 5 additions & 0 deletions src/shared/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,11 @@ export interface ElectronAPI {

agentHarnessGetStatus(request: { cwd: string }): Promise<AgentHarnessStatus>

agentRemoteStart(request: AgentProviderStatusRequest & { operation: import('./t3Agent').T3RemoteOperation }): Promise<import('./t3Agent').T3RemoteSession | AgentHarnessError>
agentRemoteGet(request: { id: string }): Promise<import('./t3Agent').T3RemoteSession | AgentHarnessError>
agentRemoteWrite(request: { id: string; data: string }): Promise<{ ok: boolean; error?: string }>
agentRemoteCancel(request: { id: string }): Promise<{ ok: boolean; error?: string }>

agentProviderAuthStart(
request: AgentProviderAuthRequest,
): Promise<AgentProviderAuthSession | AgentHarnessError>
Expand Down
4 changes: 4 additions & 0 deletions src/shared/ipc-channels.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,10 @@ export const AGENT_HARNESS_RENAME_CONVERSATION = 'agentHarness:renameConversatio
export const AGENT_HARNESS_PANEL_CLOSED = 'agentHarness:panelClosed'
export const AGENT_HARNESS_RESTART = 'agentHarness:restart'
export const AGENT_HARNESS_GET_STATUS = 'agentHarness:getStatus'
export const AGENT_REMOTE_START = 'agentRemote:start'
export const AGENT_REMOTE_GET = 'agentRemote:get'
export const AGENT_REMOTE_WRITE = 'agentRemote:write'
export const AGENT_REMOTE_CANCEL = 'agentRemote:cancel'
export const AGENT_PROVIDER_AUTH_START = 'agentProviderAuth:start'
export const AGENT_PROVIDER_AUTH_GET = 'agentProviderAuth:get'
export const AGENT_PROVIDER_AUTH_WRITE = 'agentProviderAuth:write'
Expand Down
10 changes: 10 additions & 0 deletions src/shared/t3Agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ export interface AgentHarnessStatus {
message?: string
}

export type T3RemoteOperation = 'status' | 'link' | 'unlink'

export interface T3RemoteSession {
id: string
operation: T3RemoteOperation
phase: 'running' | 'succeeded' | 'failed' | 'cancelled'
output: string
message?: string
}

export type AgentProviderId = import('./agents').T3ProviderId

export type AgentProviderAuthPhase = 'running' | 'succeeded' | 'failed' | 'cancelled'
Expand Down
Loading