@pablopunk/webr 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +17 -0
  3. package/bin/webr.mjs +11 -0
  4. package/dist/client/_astro/App.BxlQhLhN.css +1 -0
  5. package/dist/client/_astro/App.CzxEZ_Dw.js +2 -0
  6. package/dist/client/_astro/ClientRouter.astro_astro_type_script_index_0_lang.CTgtIFyY.js +1 -0
  7. package/dist/client/_astro/addon-fit.jgKqzwky.js +1 -0
  8. package/dist/client/_astro/addon-web-links.C9lyQRxI.js +1 -0
  9. package/dist/client/_astro/client.BDkzSRUN.js +1 -0
  10. package/dist/client/_astro/client.Bbg9OBwT.js +1 -0
  11. package/dist/client/_astro/client.CLSsMr2g.js +9 -0
  12. package/dist/client/_astro/jetbrains-mono.CFJaiWaY.woff2 +0 -0
  13. package/dist/client/_astro/symbols-nerd.CEdkKAK8.woff2 +0 -0
  14. package/dist/client/_astro/xterm.B-l5AIf6.js +7 -0
  15. package/dist/client/apple-touch-icon.png +0 -0
  16. package/dist/client/brand-mark.png +0 -0
  17. package/dist/client/favicon-16.png +0 -0
  18. package/dist/client/favicon-32.png +0 -0
  19. package/dist/client/favicon.ico +0 -0
  20. package/dist/client/icon-192.png +0 -0
  21. package/dist/client/icon-512.png +0 -0
  22. package/dist/client/icon-maskable-512.png +0 -0
  23. package/dist/client/manifest.webmanifest +30 -0
  24. package/dist/server/chunks/App_BmsGAwGf.mjs +4314 -0
  25. package/dist/server/chunks/_id__u0ueX6GO.mjs +34 -0
  26. package/dist/server/chunks/_tab__DL6wyr-j.mjs +35 -0
  27. package/dist/server/chunks/_virtual_astro_server-island-manifest_C1Q2srgE.mjs +5 -0
  28. package/dist/server/chunks/_virtual_astro_session-driver_DS5V7T-N.mjs +6 -0
  29. package/dist/server/chunks/compiler_CpXox_xS.mjs +35 -0
  30. package/dist/server/chunks/console_CucE1SwS.mjs +170 -0
  31. package/dist/server/chunks/errors_BgIp4W2B.mjs +383 -0
  32. package/dist/server/chunks/index_DyyPce_k.mjs +22 -0
  33. package/dist/server/chunks/new_G7XChx5p.mjs +33 -0
  34. package/dist/server/chunks/node_BlixNyYV.mjs +1816 -0
  35. package/dist/server/chunks/noop-entrypoint_Z3zFhrGC.mjs +4 -0
  36. package/dist/server/chunks/remote_BxuVLM0r.mjs +169 -0
  37. package/dist/server/chunks/server_DS45LC4q.mjs +2496 -0
  38. package/dist/server/chunks/settings_DLV2Js3e.mjs +31 -0
  39. package/dist/server/chunks/sharp_CgNvPCpY.mjs +152 -0
  40. package/dist/server/chunks/vnode-children_CRmaSs5G.mjs +24 -0
  41. package/dist/server/entry.mjs +5837 -0
  42. package/dist/server/virtual_astro_middleware.mjs +4 -0
  43. package/drizzle/0000_gateway.sql +13 -0
  44. package/drizzle/0001_device_sessions.sql +9 -0
  45. package/drizzle/0002_audit_events.sql +7 -0
  46. package/drizzle/meta/_journal.json +1 -0
  47. package/package.json +88 -0
  48. package/server/cli.ts +93 -0
  49. package/server/command.ts +9 -0
  50. package/server/entry.ts +3 -0
  51. package/server/herdr-check.ts +37 -0
  52. package/server/home.ts +4 -0
  53. package/server/log-file.ts +52 -0
  54. package/server/package-info.ts +6 -0
  55. package/server/plugin/config.ts +14 -0
  56. package/server/plugin/dev.ts +27 -0
  57. package/server/plugin/files.ts +10 -0
  58. package/server/plugin/herdr.ts +33 -0
  59. package/server/plugin/index.ts +68 -0
  60. package/server/plugin/probe.ts +17 -0
  61. package/server/plugin/stop.ts +39 -0
  62. package/server/port.ts +8 -0
  63. package/server/run.ts +35 -0
  64. package/server/ssr.ts +23 -0
  65. package/server/start.ts +5 -0
  66. package/server/update/hint.ts +26 -0
  67. package/server/update/index.ts +33 -0
  68. package/server/update/latest.ts +33 -0
  69. package/server/update/package-manager.ts +24 -0
  70. package/server/update/versions.ts +15 -0
  71. package/src/lib/avatars.ts +15 -0
  72. package/src/lib/launch.ts +16 -0
  73. package/src/lib/machines.ts +41 -0
  74. package/src/lib/mock-store.ts +82 -0
  75. package/src/lib/models.ts +139 -0
  76. package/src/lib/project-icons.ts +120 -0
  77. package/src/lib/terminal-split.ts +23 -0
  78. package/src/lib/uuid.ts +10 -0
  79. package/src/server/auth/access.ts +22 -0
  80. package/src/server/auth/audit.ts +15 -0
  81. package/src/server/auth/auth.ts +134 -0
  82. package/src/server/auth/pair-page.ts +85 -0
  83. package/src/server/auth/pairing.ts +91 -0
  84. package/src/server/auth/presence.ts +8 -0
  85. package/src/server/auth/sessions.ts +48 -0
  86. package/src/server/directories.ts +21 -0
  87. package/src/server/dist.ts +3 -0
  88. package/src/server/host.ts +166 -0
  89. package/src/server/perf-log.ts +23 -0
  90. package/src/server/protocol/deadlines.ts +24 -0
  91. package/src/server/protocol/native.ts +10 -0
  92. package/src/server/protocol/ndjson.ts +22 -0
  93. package/src/server/protocol/socket.ts +81 -0
  94. package/src/server/protocol/validate.ts +17 -0
  95. package/src/server/public-urls.ts +17 -0
  96. package/src/server/runtime/herdr-actions.ts +122 -0
  97. package/src/server/runtime/launch.ts +55 -0
  98. package/src/server/runtime/manager.ts +187 -0
  99. package/src/server/runtime/reconcile.ts +51 -0
  100. package/src/server/runtime/supervisor.ts +92 -0
  101. package/src/server/runtime/target.ts +37 -0
  102. package/src/server/runtime/workspace.ts +24 -0
  103. package/src/server/storage/database.ts +88 -0
  104. package/src/server/storage/drizzle-node-sqlite.ts +18 -0
  105. package/src/server/storage/node-sqlite-client.ts +44 -0
  106. package/src/server/storage/schema.ts +25 -0
  107. package/src/server/tailscale-host.ts +17 -0
  108. package/src/server/terminal/cli.ts +44 -0
  109. package/src/server/terminal/hub.ts +140 -0
  110. package/src/server/terminal/writer.ts +38 -0
  111. package/src/server/transport/herdr-target.ts +148 -0
  112. package/src/server/transport/icons.ts +65 -0
  113. package/src/server/transport/identity.ts +5 -0
  114. package/src/server/transport/local-session.ts +65 -0
  115. package/src/server/transport/model-discovery.ts +18 -0
  116. package/src/server/transport/native-locations.ts +17 -0
  117. package/src/server/transport/process.ts +21 -0
  118. package/src/server/transport/registry.ts +27 -0
  119. package/src/server/transport/ssh.ts +47 -0
  120. package/src/server/uploads.ts +40 -0
  121. package/src/server/websockets.ts +62 -0
  122. package/src/shared/agent-kinds.ts +4 -0
  123. package/src/shared/frame.ts +38 -0
  124. package/src/shared/projects.ts +1 -0
  125. package/src/shared/retry.ts +1 -0
  126. package/src/shared/runtime.ts +33 -0
@@ -0,0 +1,22 @@
1
+ export class NdjsonParser {
2
+ private pending = Buffer.alloc(0);
3
+ constructor(private onRecord: (record: unknown) => void, private limit = 1024 * 1024) {}
4
+ push(chunk: Buffer) {
5
+ let start = 0;
6
+ for (let index = 0; index < chunk.length; index++) {
7
+ if (chunk[index] !== 10) continue;
8
+ this.append(chunk.subarray(start, index));
9
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(this.pending);
10
+ this.pending = Buffer.alloc(0);
11
+ if (!text.trim()) throw new Error('empty_record');
12
+ this.onRecord(JSON.parse(text));
13
+ start = index + 1;
14
+ }
15
+ this.append(chunk.subarray(start));
16
+ }
17
+ end() { if (this.pending.length) throw new Error('partial_record'); }
18
+ private append(chunk: Buffer) {
19
+ if (this.pending.length + chunk.length > this.limit) throw new Error('record_limit');
20
+ if (chunk.length) this.pending = Buffer.concat([this.pending, chunk]);
21
+ }
22
+ }
@@ -0,0 +1,81 @@
1
+ import { connect, type Socket } from 'node:net';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { z } from 'zod';
4
+ import { NdjsonParser } from './ndjson';
5
+ import { lifecycleSubscriptions } from './native';
6
+ import { requestDeadline, type RpcOptions } from './deadlines';
7
+
8
+ const envelope = z.object({ id: z.string(), result: z.record(z.string(), z.unknown()).optional(), error: z.object({ code: z.string(), message: z.string() }).optional() });
9
+ const allowed = new Set(['ping', 'session.snapshot', 'events.subscribe', 'server.agent_manifests', 'workspace.create', 'worktree.create', 'worktree.list', 'worktree.open', 'tab.create', 'agent.start', 'agent.get', 'agent.prompt', 'pane.process_info', 'pane.read', 'pane.focus', 'tab.close', 'workspace.close', 'worktree.remove', 'tab.rename', 'workspace.rename', 'pane.split', 'pane.close']);
10
+ export class SocketApi {
11
+ private sockets = new Set<Socket>();
12
+ constructor(readonly path: string, readonly deadline = 5000, private methods: ReadonlySet<string> = allowed) {}
13
+ request(method: string, params: Record<string, unknown> = {}, options?: RpcOptions): Promise<Record<string, unknown>> {
14
+ if (!this.methods.has(method) || method === 'events.subscribe') return Promise.reject(new Error('method_not_allowed'));
15
+ let deadline: number;
16
+ try { deadline = requestDeadline(method, params, this.deadline, options); } catch (error) { return Promise.reject(error); }
17
+ return new Promise((resolve, reject) => {
18
+ const id = options?.requestId ?? randomUUID();
19
+ const socket = this.open();
20
+ let done = false;
21
+ const finish = (error?: Error, result?: Record<string, unknown>) => {
22
+ if (done) return;
23
+ done = true; clearTimeout(timer); socket.destroy();
24
+ if (error) reject(error); else resolve(result!);
25
+ };
26
+ const timer = setTimeout(() => finish(new Error('rpc_timeout')), deadline);
27
+ const parser = new NdjsonParser((value) => {
28
+ const record = envelope.parse(value);
29
+ if (record.id !== id) throw new Error('request_id_mismatch');
30
+ if (record.error) throw new Error(record.error.code);
31
+ if (!record.result) throw new Error('missing_result');
32
+ finish(undefined, record.result);
33
+ });
34
+ socket.on('connect', () => socket.write(JSON.stringify({ id, method, params }) + '\n'));
35
+ socket.on('data', (chunk) => { try { parser.push(chunk); } catch (error) { finish(error as Error); } });
36
+ socket.on('error', (error: NodeJS.ErrnoException) => finish(Object.assign(new Error('socket_unavailable', { cause: error }), { code: error.code })));
37
+ socket.on('close', () => finish(new Error('rpc_closed')));
38
+ socket.on('end', () => { try { parser.end(); finish(new Error('rpc_closed')); } catch (error) { finish(error as Error); } });
39
+ });
40
+ }
41
+ subscribe(onEvent: () => void, onClose: (reason: string) => void, paneIds: string[] = []): Promise<() => void> {
42
+ return new Promise((resolve, reject) => {
43
+ const socket = this.open();
44
+ const id = randomUUID();
45
+ let acknowledged = false;
46
+ let closed = false;
47
+ const stop = (reason: string, notify = true) => {
48
+ if (closed) return;
49
+ closed = true; clearTimeout(timer); socket.destroy();
50
+ if (!acknowledged) reject(new Error(reason));
51
+ else if (notify) onClose(reason);
52
+ };
53
+ const timer = setTimeout(() => stop('subscription_timeout'), this.deadline);
54
+ const parser = new NdjsonParser((value) => {
55
+ const response = envelope.safeParse(value);
56
+ if (response.success) {
57
+ if (response.data.id !== id) throw new Error('request_id_mismatch');
58
+ if (response.data.error) throw new Error(response.data.error.code);
59
+ if (acknowledged || response.data.result?.type !== 'subscription_started') throw new Error('invalid_ack');
60
+ acknowledged = true; clearTimeout(timer); resolve(() => stop('released', false));
61
+ } else {
62
+ z.object({ event: z.string(), data: z.unknown() }).parse(value);
63
+ if (!acknowledged) throw new Error('event_before_ack');
64
+ onEvent();
65
+ }
66
+ });
67
+ socket.on('connect', () => socket.write(JSON.stringify({ id, method: 'events.subscribe', params: { subscriptions: [...lifecycleSubscriptions, ...paneIds.map((pane_id) => ({ type: 'pane.agent_status_changed', pane_id }))] } }) + '\n'));
68
+ socket.on('data', (chunk) => { try { parser.push(chunk); } catch (error) { stop((error as Error).message); } });
69
+ socket.on('error', () => stop('socket_unavailable'));
70
+ socket.on('end', () => stop('subscription_closed'));
71
+ socket.on('close', () => stop('subscription_closed'));
72
+ });
73
+ }
74
+ close() { for (const socket of this.sockets) socket.destroy(); }
75
+ private open() {
76
+ const socket = connect(this.path);
77
+ this.sockets.add(socket);
78
+ socket.once('close', () => this.sockets.delete(socket));
79
+ return socket;
80
+ }
81
+ }
@@ -0,0 +1,17 @@
1
+ type SchemaNode = { const?: unknown; required?: string[]; properties?: Record<string, SchemaNode>; oneOf?: SchemaNode[]; $defs?: Record<string, SchemaNode> };
2
+ type Schema = { protocol?: number; schemas?: Record<string, SchemaNode> };
3
+ export function validateInstalledSchema(schema: Schema) {
4
+ const errors: string[] = [];
5
+ if (schema.protocol !== 22) errors.push('Unsupported protocol: expected 22');
6
+ const request = schema.schemas?.request;
7
+ const methods = new Set(request?.oneOf?.map((variant) => variant.properties?.method?.const));
8
+ for (const method of ['ping', 'session.snapshot', 'events.subscribe', 'tab.create', 'worktree.create', 'agent.start', 'agent.get', 'agent.prompt']) if (!methods.has(method)) errors.push('Missing method: ' + method);
9
+ const requiredFields = { SessionSnapshot: ['version', 'protocol', 'workspaces', 'tabs', 'panes', 'layouts', 'agents'], PaneInfo: ['pane_id', 'terminal_id', 'workspace_id', 'tab_id'], PaneLayoutSnapshot: ['area', 'panes', 'tab_id', 'workspace_id'] };
10
+ for (const [record, fields] of Object.entries(requiredFields)) for (const field of fields) if (!schema.schemas?.success_response?.$defs?.[record]?.required?.includes(field)) errors.push('Missing required response field: ' + record + '.' + field);
11
+ if (!request?.$defs?.EventsSubscribeParams?.required?.includes('subscriptions')) errors.push('Missing EventsSubscribeParams.subscriptions');
12
+ for (const [record, fields] of Object.entries({ AgentStartParams: ['name', 'kind', 'pane_id', 'args', 'timeout_ms'], AgentPromptParams: ['target', 'text'], TabCreateParams: ['workspace_id', 'cwd', 'focus'], WorktreeCreateParams: ['workspace_id', 'branch', 'focus'] })) for (const field of fields) if (!request?.$defs?.[record]?.properties?.[field]) errors.push('Missing request field: ' + record + '.' + field);
13
+ const statusSubscription = request?.$defs?.Subscription?.oneOf?.find((variant) => variant.properties?.type?.const === 'pane.agent_status_changed');
14
+ if (!statusSubscription?.required?.includes('pane_id')) errors.push('Missing pane-scoped status subscription');
15
+ for (const field of ['name', 'pane_id', 'terminal_id', 'agent', 'agent_status']) if (!schema.schemas?.success_response?.$defs?.AgentInfo?.properties?.[field]) errors.push('Missing agent identity field: ' + field);
16
+ return errors;
17
+ }
@@ -0,0 +1,17 @@
1
+ import { networkInterfaces } from 'node:os';
2
+ import { isLoopbackHost } from './auth/access';
3
+
4
+ const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']);
5
+ type Interfaces = ReturnType<typeof networkInterfaces>;
6
+
7
+ const VIRTUAL_INTERFACE = /^(bridge|vmnet|docker|br-|veth|vboxnet|virbr|vethernet)/i;
8
+
9
+ const lanAddresses = (interfaces: Interfaces) => Object.entries(interfaces).filter(([name]) => !VIRTUAL_INTERFACE.test(name)).flatMap(([, list]) => list ?? []).filter((entry) => entry.family === 'IPv4' && !entry.internal).map((entry) => entry.address);
10
+
11
+ export function publicUrls(origin: string, host: string, port: number, interfaces: Interfaces = networkInterfaces(), tailscaleHost?: string) {
12
+ const { protocol, host: originHost } = new URL(origin);
13
+ const reachable = WILDCARD_HOSTS.has(host) ? lanAddresses(interfaces) : isLoopbackHost(host) ? [] : [host];
14
+ const named = tailscaleHost && WILDCARD_HOSTS.has(host) ? [tailscaleHost] : [];
15
+ const urls = [...named, ...reachable].map((address) => `${protocol}//${address}:${port}`);
16
+ return [...new Set([...(isLoopbackHost(originHost) ? [] : [origin]), ...urls])];
17
+ }
@@ -0,0 +1,122 @@
1
+ import { z } from 'zod';
2
+ import { acceptsModelFlag, isHerdrAgentKind } from '../../shared/agent-kinds';
3
+ import type { LaunchInput } from '../../shared/runtime';
4
+ import type { LaunchLocation, TerminalDirection } from './target';
5
+ import { nativePane, nativeTab, type NativeSnapshot } from '../protocol/native';
6
+ import { AGENT_READY_TIMEOUT_MS, SCREEN_SETTLE_TIMEOUT_MS, SHELL_READY_TIMEOUT_MS, requestDeadline, type RpcOptions } from '../protocol/deadlines';
7
+
8
+ type Api = { request(method: string, params?: Record<string, unknown>, options?: RpcOptions): Promise<Record<string, unknown>> };
9
+ const agentShape = { name: z.string(), terminal_id: z.string(), pane_id: z.string(), agent_status: z.enum(['idle', 'done', 'working', 'blocked', 'unknown']) };
10
+ const startedAgent = z.object({ ...agentShape, agent: z.string() });
11
+ const detectingAgent = z.object({ ...agentShape, agent: z.string().nullable().optional() });
12
+ const processInfo = z.object({ shell_pid: z.number().int().positive(), foreground_processes: z.array(z.object({ pid: z.number().int().positive() })) });
13
+ const worktreeEntry = z.object({ path: z.string(), branch: z.string().nullable().optional(), label: z.string(), is_bare: z.boolean(), is_prunable: z.boolean(), open_workspace_id: z.string().nullable().optional() });
14
+ export type WorktreeEntry = z.infer<typeof worktreeEntry>;
15
+ const SETTLE_INTERVAL_MS = 350;
16
+ const AGENT_SHARE_OF_TERMINAL_SPLIT = 0.5;
17
+ const SETTLED_READS = 3;
18
+ const pause = (ms: number) => new Promise<void>((done) => setTimeout(done, ms));
19
+ export function modelArguments(kind: string, model: string): string[] {
20
+ if (!isHerdrAgentKind(kind) || !/^[A-Za-z0-9_/.:+-]{1,120}$/.test(model)) throw new Error('unsupported_launch_adapter');
21
+ if (model === 'Default') return [];
22
+ if (!acceptsModelFlag(kind)) throw new Error('unsupported_launch_adapter');
23
+ return ['--model', model];
24
+ }
25
+ export const agentName = (threadId: string) => 'web-' + threadId.replaceAll('-', '').slice(0, 28);
26
+
27
+ export class HerdrActions {
28
+ constructor(private api: Api, private locations: LaunchLocation[], private requireCapability: (input?: LaunchInput) => void) {}
29
+ async create(input: LaunchInput, threadId: string) {
30
+ this.requireCapability(input);
31
+ const location = this.locations.find((location) => location.projectId === input.projectId);
32
+ if (!location) throw new Error('unknown_project_location');
33
+ const result = await this.api.request(input.worktree ? 'worktree.create' : 'tab.create', input.worktree
34
+ ? { workspace_id: location.workspaceId, branch: 'web/' + threadId, label: input.prompt.split('\n')[0].slice(0, 90), focus: false }
35
+ : { workspace_id: location.workspaceId, cwd: location.path, label: input.prompt.split('\n')[0].slice(0, 90), focus: false }, { timeoutMs: requestDeadline(input.worktree ? 'worktree.create' : 'tab.create', {}) });
36
+ if (result.type !== (input.worktree ? 'worktree_created' : 'tab_created')) throw new Error('unexpected_create_result');
37
+ const pane = nativePane.parse(result.root_pane); const tab = nativeTab.parse(result.tab);
38
+ if (pane.tab_id !== tab.tab_id || pane.workspace_id !== tab.workspace_id) throw new Error('inconsistent_create_result');
39
+ return { paneId: pane.pane_id, terminalId: pane.terminal_id, tabId: tab.tab_id, workspaceId: pane.workspace_id };
40
+ }
41
+ async start(input: LaunchInput, paneId: string, threadId: string) {
42
+ this.requireCapability(input);
43
+ await this.untilShellReady(paneId);
44
+ const params = { name: agentName(threadId), kind: input.agent, pane_id: paneId, args: modelArguments(input.agent, input.model), timeout_ms: AGENT_READY_TIMEOUT_MS };
45
+ const result = await this.startWhenPromptReady(params);
46
+ if (result.type !== 'agent_started') throw new Error('agent_not_ready');
47
+ const isReady = (agent: z.infer<typeof detectingAgent>) => agent.name === params.name && agent.pane_id === paneId && agent.agent === input.agent && ['idle', 'done'].includes(agent.agent_status);
48
+ const started = detectingAgent.parse(result.agent);
49
+ if (started.name !== params.name || started.pane_id !== paneId) throw new Error('agent_not_ready');
50
+ if (!isReady(started)) await this.untilReady(params.name, isReady);
51
+ }
52
+ private async startWhenPromptReady(params: Record<string, unknown>) {
53
+ const end = Date.now() + SHELL_READY_TIMEOUT_MS;
54
+ for (;;) {
55
+ try { return await this.api.request('agent.start', params, { timeoutMs: requestDeadline('agent.start', params) }); }
56
+ catch (error) { if (!(error instanceof Error) || error.message !== 'agent_pane_busy' || Date.now() >= end) throw error; await pause(500); }
57
+ }
58
+ }
59
+ private async untilShellReady(paneId: string) {
60
+ const end = Date.now() + SHELL_READY_TIMEOUT_MS;
61
+ do {
62
+ const info = processInfo.safeParse((await this.api.request('pane.process_info', { pane_id: paneId })).process_info);
63
+ if (info.success && info.data.foreground_processes.length && info.data.foreground_processes.every((process) => process.pid === info.data.shell_pid)) return;
64
+ await pause(250);
65
+ } while (Date.now() < end);
66
+ throw new Error('shell_not_ready');
67
+ }
68
+ private async untilReady(name: string, isReady: (agent: z.infer<typeof detectingAgent>) => boolean) {
69
+ const end = Date.now() + AGENT_READY_TIMEOUT_MS;
70
+ do {
71
+ const current = detectingAgent.safeParse((await this.api.request('agent.get', { target: name })).agent);
72
+ if (current.success && isReady(current.data)) return;
73
+ await pause(250);
74
+ } while (Date.now() < end);
75
+ throw new Error('agent_not_ready');
76
+ }
77
+ async worktrees(workspaceId: string) { return z.array(worktreeEntry).parse((await this.api.request('worktree.list', { workspace_id: workspaceId })).worktrees); }
78
+ async openWorktree(workspaceId: string, path: string) { await this.api.request('worktree.open', { workspace_id: workspaceId, path, focus: false }); }
79
+ async focus(paneId: string) { await this.api.request('pane.focus', { pane_id: paneId }); }
80
+ async splitTerminal(snapshot: NativeSnapshot, paneId: string, direction: TerminalDirection) {
81
+ const pane = snapshot.panes.find((pane) => pane.pane_id === paneId);
82
+ if (!pane) throw new Error('thread_not_found');
83
+ const result = await this.api.request('pane.split', { target_pane_id: paneId, direction, ratio: AGENT_SHARE_OF_TERMINAL_SPLIT, cwd: pane.cwd ?? null, focus: false });
84
+ return nativePane.parse(result.pane).pane_id;
85
+ }
86
+ async closePane(paneId: string) { await this.api.request('pane.close', { pane_id: paneId }); }
87
+ async renameTab(snapshot: NativeSnapshot, tabId: string, label: string, renameWorkspace: boolean) {
88
+ const tab = snapshot.tabs.find((tab) => tab.tab_id === tabId);
89
+ if (!tab) throw new Error('thread_not_found');
90
+ await this.api.request('tab.rename', { tab_id: tabId, label });
91
+ if (renameWorkspace && this.isOnlyTabOfItsWorkspace(snapshot, tab)) await this.api.request('workspace.rename', { workspace_id: tab.workspace_id, label });
92
+ }
93
+ private isOnlyTabOfItsWorkspace(snapshot: NativeSnapshot, tab: NativeSnapshot['tabs'][number]) {
94
+ return snapshot.tabs.filter((other) => other.workspace_id === tab.workspace_id).length === 1;
95
+ }
96
+ async discardTab(snapshot: NativeSnapshot, tabId: string, removeWorktree: boolean) {
97
+ const tab = snapshot.tabs.find((tab) => tab.tab_id === tabId);
98
+ if (!tab) return;
99
+ const linkedWorktree = snapshot.workspaces.find((workspace) => workspace.workspace_id === tab.workspace_id)?.worktree?.is_linked_worktree;
100
+ if (removeWorktree && linkedWorktree && this.isOnlyTabOfItsWorkspace(snapshot, tab)) await this.api.request('worktree.remove', { workspace_id: tab.workspace_id, force: true });
101
+ else await this.api.request('tab.close', { tab_id: tabId });
102
+ }
103
+ async prompt(paneId: string, prompt: string, terminalId: string, threadId: string, kind: string, input?: LaunchInput) {
104
+ this.requireCapability(input);
105
+ const name = agentName(threadId);
106
+ const result = await this.api.request('agent.get', { target: name });
107
+ const agent = startedAgent.parse(result.agent);
108
+ if (agent.name !== name || agent.terminal_id !== terminalId || agent.pane_id !== paneId || agent.agent !== kind || !['idle', 'done'].includes(agent.agent_status)) throw new Error('agent_occupant_changed');
109
+ await this.untilScreenSettles(paneId);
110
+ await this.api.request('agent.prompt', { target: name, text: prompt });
111
+ }
112
+ private async untilScreenSettles(paneId: string) {
113
+ const read = async () => z.object({ text: z.string() }).safeParse((await this.api.request('pane.read', { pane_id: paneId, source: 'recent_unwrapped', format: 'text', strip_ansi: true, lines: 120 })).read).data?.text;
114
+ const end = Date.now() + SCREEN_SETTLE_TIMEOUT_MS;
115
+ let previous = await read(); let unchanged = 0;
116
+ while (Date.now() < end && unchanged < SETTLED_READS) {
117
+ await pause(SETTLE_INTERVAL_MS);
118
+ const current = await read();
119
+ unchanged = current !== undefined && current === previous ? unchanged + 1 : 0; previous = current;
120
+ }
121
+ }
122
+ }
@@ -0,0 +1,55 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { MetadataDatabase } from '../storage/database';
3
+ import type { TargetAdapter } from './target';
4
+ import type { LaunchInput } from '../../shared/runtime';
5
+ import type { Thread } from '../../lib/models';
6
+
7
+ export class LaunchJournal {
8
+ private queues = new Map<string, Promise<void>>();
9
+ private stopping = false;
10
+ constructor(private database: MetadataDatabase, private publish: () => void) { database.markInterruptedLaunches(); }
11
+ submit(accountId: string, key: string, input: LaunchInput, target: TargetAdapter) {
12
+ if (this.stopping) throw new Error('gateway_stopping');
13
+ const thread: Thread = { id: randomUUID(), avatarIndex: this.database.nextAvatarIndex(), projectId: input.projectId, machineId: target.id, title: input.prompt.split('\n')[0].slice(0, 90), prompt: input.prompt, agent: input.agent, model: input.model, status: 'unknown', updatedAt: new Date().toISOString(), branch: '', worktree: input.worktree, ownsWorktree: input.worktree, session: target.session, tabId: '', panes: [], bindingState: 'pending', bindingFingerprint: target.fingerprint, bindingConfigVersion: target.configVersion };
14
+ const { operation, created } = this.database.beginLaunch(accountId, key, input, thread);
15
+ if (created) {
16
+ const scope = target.id + '\0' + input.projectId;
17
+ const previous = this.queues.get(scope) ?? Promise.resolve();
18
+ const next = previous.then(() => this.run(operation.id, input, target, thread));
19
+ this.queues.set(scope, next);
20
+ void next.finally(() => { if (this.queues.get(scope) === next) this.queues.delete(scope); });
21
+ this.publish();
22
+ }
23
+ return { operationId: operation.id, id: operation.threadId, state: operation.state };
24
+ }
25
+ async stop() { this.stopping = true; await Promise.allSettled([...this.queues.values()]); }
26
+ private async run(id: string, input: LaunchInput, target: TargetAdapter, thread: Thread) {
27
+ let step = 'validate';
28
+ let result: Record<string, unknown> = {};
29
+ try {
30
+ const catalog = await target.catalog(input.projectId);
31
+ if (this.stopping) throw new Error('gateway_stopping');
32
+ const harness = catalog.harnesses.find((harness) => harness.id === input.agent);
33
+ if (!catalog.connected || !harness?.launchEnabled || target.canLaunch && !target.canLaunch(input) || !target.locations.some((location) => location.projectId === input.projectId)) throw new Error('launch_unavailable');
34
+ step = 'checkout'; this.database.updateOperation(id, 'running', step, result); this.publish();
35
+ const created = await target.create(input, thread.id);
36
+ result = { ...created };
37
+ this.database.db.transaction(() => {
38
+ this.database.updateOperation(id, 'running', 'checkout_complete', result);
39
+ thread.tabId = created.tabId;
40
+ this.database.saveThread(thread, [created.terminalId], created.tabId);
41
+ });
42
+ if (this.stopping) throw new Error('gateway_stopping');
43
+ step = 'start'; this.database.updateOperation(id, 'running', step, result); this.publish();
44
+ await target.start(input, created.paneId, thread.id);
45
+ if (this.stopping) throw new Error('gateway_stopping');
46
+ step = 'prompt'; this.database.updateOperation(id, 'running', step, result); this.publish();
47
+ await target.prompt(created.paneId, input.prompt, created.terminalId, thread.id, input);
48
+ this.database.updateOperation(id, 'ready', 'ready', result);
49
+ } catch (error) {
50
+ console.error(`launch ${id} stopped at ${step}:`, error instanceof Error ? error.message : error);
51
+ this.database.updateOperation(id, step === 'validate' ? 'failed' : 'unknown', step, result);
52
+ }
53
+ this.publish();
54
+ }
55
+ }
@@ -0,0 +1,187 @@
1
+ import { EventEmitter } from 'node:events';
2
+ import type { TargetAdapter, TerminalDirection } from './target';
3
+ import type { Thread } from '../../lib/models';
4
+ import { threadAgent, threadShell } from '../../lib/terminal-split';
5
+ import { TargetSupervisor } from './supervisor';
6
+ import { reconcile } from './reconcile';
7
+ import type { MetadataDatabase } from '../storage/database';
8
+ import type { Bootstrap, Projection } from '../../shared/runtime';
9
+ import type { Machine } from '../../lib/machines';
10
+ import { LaunchJournal } from './launch';
11
+
12
+ const CATALOG_TTL_MS = 5 * 60_000;
13
+
14
+ export class RuntimeManager extends EventEmitter {
15
+ readonly supervisors = new Map<string, TargetSupervisor>();
16
+ readonly journal: LaunchJournal;
17
+ private projections = new Map<string, Projection>();
18
+ private catalogs = new Map<string, { expires: number; value: Promise<Machine> }>();
19
+ private configVersions = new Map<string, number>();
20
+ constructor(readonly database: MetadataDatabase, targets: TargetAdapter[]) {
21
+ super(); this.setMaxListeners(100);
22
+ this.journal = new LaunchJournal(database, () => this.publishAll());
23
+ for (const target of targets) {
24
+ target.configVersion = database.registerProfile(target.id, target.session, JSON.stringify({ fingerprint: target.fingerprint, locations: target.locations }), target.locations);
25
+ this.configVersions.set(target.id, target.configVersion);
26
+ const supervisor = new TargetSupervisor(target, () => this.publish(supervisor));
27
+ if (target.enabled === false) supervisor.error = 'profile_disabled';
28
+ this.supervisors.set(target.id, supervisor);
29
+ this.publish(supervisor);
30
+ }
31
+ }
32
+ start() { for (const supervisor of this.supervisors.values()) if (supervisor.target.enabled !== false) void supervisor.start(); }
33
+ async close() { for (const supervisor of this.supervisors.values()) supervisor.stop(); await this.journal.stop(); this.removeAllListeners(); }
34
+ bootstrap(): Bootstrap {
35
+ const projections = [...this.projections.values()];
36
+ return { projections, projects: projections.flatMap((projection) => projection.projects), threads: projections.flatMap((projection) => projection.threads), machines: [...this.supervisors.values()].map((supervisor) => ({ id: supervisor.target.id, name: supervisor.target.name, session: supervisor.target.session, connected: supervisor.connected, projectPaths: Object.fromEntries(supervisor.target.locations.map((location) => [location.projectId, location.path])), harnesses: [], error: supervisor.error, writable: supervisor.target.writable, configVersion: this.configVersions.get(supervisor.target.id) })) };
37
+ }
38
+ private machine(supervisor: TargetSupervisor): Machine { return { id: supervisor.target.id, name: supervisor.target.name, session: supervisor.target.session, connected: supervisor.connected, configVersion: supervisor.target.configVersion, writable: supervisor.target.writable, error: supervisor.error, projectPaths: Object.fromEntries(supervisor.target.locations.map((location) => [location.projectId, location.path])), harnesses: [] }; }
39
+ async catalog(machineId: string, projectId?: string) {
40
+ const supervisor = this.supervisors.get(machineId);
41
+ if (!supervisor?.connected) throw new Error('machine_disconnected');
42
+ if (projectId && !supervisor.target.locations.some((location) => location.projectId === projectId)) throw new Error('unknown_project_location');
43
+ const key = JSON.stringify([machineId, supervisor.target.fingerprint, supervisor.target.session, supervisor.target.configVersion]);
44
+ const existing = this.catalogs.get(key);
45
+ if (existing && existing.expires > Date.now()) return existing.value;
46
+ const value = supervisor.target.catalog(projectId);
47
+ this.catalogs.set(key, { expires: Date.now() + CATALOG_TTL_MS, value });
48
+ try { return await value; } catch (error) { this.catalogs.delete(key); throw error; }
49
+ }
50
+ refreshCatalog(machineId: string) {
51
+ for (const key of this.catalogs.keys()) if (JSON.parse(key)[0] === machineId) this.catalogs.delete(key);
52
+ this.supervisors.get(machineId)?.invalidate();
53
+ }
54
+ binding(machineId: string, threadId: string, terminalId: string) {
55
+ const supervisor = this.supervisors.get(machineId);
56
+ const projection = this.projections.get(machineId);
57
+ const thread = projection?.threads.find((thread) => thread.id === threadId);
58
+ if (!supervisor?.connected || !thread || thread.bindingFingerprint !== supervisor.target.fingerprint || thread.bindingConfigVersion !== supervisor.target.configVersion || thread.session !== supervisor.target.session || thread.bindingState !== 'attached' || !thread.panes.some((pane) => pane.terminalId === terminalId)) throw new Error('binding_invalid');
59
+ return supervisor.target;
60
+ }
61
+ async adopt(machineId: string, threadId: string, terminalIds: string[]) {
62
+ const supervisor = this.supervisors.get(machineId);
63
+ const row = this.database.threadRows(machineId).find((row) => row.id === threadId);
64
+ if (!supervisor?.connected || !row || !terminalIds.length || new Set(terminalIds).size !== terminalIds.length) throw new Error('invalid_adoption');
65
+ const snapshot = await supervisor.readFresh();
66
+ const panes = snapshot.panes.filter((pane) => terminalIds.includes(pane.terminal_id));
67
+ const tab = panes[0]?.tab_id;
68
+ if (panes.length !== terminalIds.length || !tab || panes.some((pane) => pane.tab_id !== tab) || snapshot.panes.filter((pane) => pane.tab_id === tab).length !== panes.length) throw new Error('invalid_adoption');
69
+ if (this.projections.get(machineId)?.threads.some((other) => other.id !== threadId && other.bindingState === 'attached' && other.tabId === tab)) throw new Error('binding_conflict');
70
+ this.database.saveThread({ ...row.metadata, session: supervisor.target.session, tabId: tab, bindingFingerprint: supervisor.target.fingerprint, bindingConfigVersion: supervisor.target.configVersion, semanticSignature: undefined }, terminalIds, tab); this.publish(supervisor);
71
+ }
72
+ private workspacesWithThreads(machineId: string) {
73
+ const shownTabs = new Set(this.projections.get(machineId)?.threads.filter((thread) => thread.bindingState === 'attached' && !thread.archivedAt).map((thread) => thread.tabId));
74
+ return new Set(this.supervisors.get(machineId)?.snapshot?.tabs.filter((tab) => shownTabs.has(tab.tab_id)).map((tab) => tab.workspace_id));
75
+ }
76
+ private worktreeSupervisor(machineId: string) {
77
+ const supervisor = this.supervisors.get(machineId);
78
+ if (!supervisor?.connected || !supervisor.target.worktrees || !supervisor.target.openWorktree) throw new Error('machine_disconnected');
79
+ return supervisor;
80
+ }
81
+ async worktrees(machineId: string, projectId: string) {
82
+ const supervisor = this.worktreeSupervisor(machineId);
83
+ const shown = this.workspacesWithThreads(machineId);
84
+ const entries = await supervisor.target.worktrees!(projectId);
85
+ return entries.filter((entry) => !entry.is_bare && !entry.is_prunable && !(entry.open_workspace_id && shown.has(entry.open_workspace_id))).map((entry) => ({ path: entry.path, branch: entry.branch ?? null, label: entry.label }));
86
+ }
87
+ async openWorktree(machineId: string, projectId: string, path: string) {
88
+ const supervisor = this.worktreeSupervisor(machineId);
89
+ if (!(await this.worktrees(machineId, projectId)).some((worktree) => worktree.path === path)) throw new Error('unknown_worktree');
90
+ await supervisor.target.openWorktree!(projectId, path);
91
+ const snapshot = await supervisor.readFresh(); this.publish(supervisor);
92
+ const workspace = snapshot.workspaces.find((workspace) => workspace.worktree?.checkout_path === path);
93
+ const tab = snapshot.tabs.find((tab) => tab.workspace_id === workspace?.workspace_id);
94
+ const thread = this.projections.get(machineId)?.threads.find((thread) => thread.tabId === tab?.tab_id && thread.bindingState === 'attached');
95
+ if (!thread) throw new Error('worktree_not_opened');
96
+ if (thread.archivedAt) this.archive(machineId, thread.id, false);
97
+ return { id: thread.id };
98
+ }
99
+ async focusPane(machineId: string, threadId: string, paneId: string) {
100
+ const supervisor = this.supervisors.get(machineId);
101
+ const thread = this.projections.get(machineId)?.threads.find((thread) => thread.id === threadId);
102
+ if (!supervisor?.connected || !thread?.panes.some((pane) => pane.id === paneId) || !supervisor.target.focusPane) throw new Error('invalid_focus');
103
+ await supervisor.target.focusPane(paneId);
104
+ }
105
+ archive(machineId: string, threadId: string, archived: boolean) {
106
+ const supervisor = this.supervisors.get(machineId);
107
+ const row = this.database.threadRows(machineId).find((row) => row.id === threadId);
108
+ if (!supervisor || !row) throw new Error('thread_not_found');
109
+ this.database.saveThread({ ...row.metadata, archivedAt: archived ? new Date().toISOString() : undefined }, row.anchors, row.alias);
110
+ this.publish(supervisor);
111
+ }
112
+ async renameThread(machineId: string, threadId: string, title: string) {
113
+ const supervisor = this.supervisors.get(machineId);
114
+ const thread = this.projections.get(machineId)?.threads.find((thread) => thread.id === threadId);
115
+ if (!supervisor || !thread) throw new Error('thread_not_found');
116
+ if (thread.bindingState === 'attached') {
117
+ if (!supervisor.connected || !supervisor.target.renameTab) throw new Error('machine_disconnected');
118
+ await supervisor.target.renameTab(thread.tabId, title, !!thread.ownsWorktree);
119
+ }
120
+ const row = this.database.threadRows(machineId).find((row) => row.id === threadId);
121
+ if (row) this.database.saveThread({ ...row.metadata, title }, row.anchors, row.alias);
122
+ if (thread.bindingState === 'attached') await supervisor.readFresh();
123
+ this.publish(supervisor);
124
+ }
125
+ async toggleTerminal(machineId: string, threadId: string, direction: TerminalDirection) {
126
+ const { supervisor, thread } = this.attachedThread(machineId, threadId);
127
+ const shell = threadShell(thread);
128
+ const agent = threadAgent(thread);
129
+ if (shell) { this.saveThreadMetadata(machineId, threadId, { terminalHidden: !thread.terminalHidden }); this.publish(supervisor); return { paneId: shell.id, hidden: !thread.terminalHidden }; }
130
+ if (!agent || !supervisor.target.splitTerminal) throw new Error('thread_not_found');
131
+ const paneId = await supervisor.target.splitTerminal(agent.id, direction);
132
+ this.saveThreadMetadata(machineId, threadId, { terminalHidden: false });
133
+ await supervisor.readFresh(); this.publish(supervisor);
134
+ return { paneId, hidden: false };
135
+ }
136
+ async closeTerminal(machineId: string, threadId: string) {
137
+ const { supervisor, thread } = this.attachedThread(machineId, threadId);
138
+ const shell = threadShell(thread);
139
+ if (shell) await supervisor.target.closePane?.(shell.id);
140
+ this.saveThreadMetadata(machineId, threadId, { terminalHidden: false });
141
+ await supervisor.readFresh(); this.publish(supervisor);
142
+ }
143
+ private attachedThread(machineId: string, threadId: string) {
144
+ const supervisor = this.supervisors.get(machineId);
145
+ const thread = this.projections.get(machineId)?.threads.find((thread) => thread.id === threadId);
146
+ if (!supervisor || !thread) throw new Error('thread_not_found');
147
+ if (!supervisor.connected || thread.bindingState !== 'attached') throw new Error('machine_disconnected');
148
+ return { supervisor, thread };
149
+ }
150
+ private saveThreadMetadata(machineId: string, threadId: string, change: Partial<Thread>) {
151
+ const row = this.database.threadRows(machineId).find((row) => row.id === threadId);
152
+ if (row) this.database.saveThread({ ...row.metadata, ...change }, row.anchors, row.alias);
153
+ }
154
+ async deleteThread(machineId: string, threadId: string) {
155
+ const supervisor = this.supervisors.get(machineId);
156
+ const thread = this.projections.get(machineId)?.threads.find((thread) => thread.id === threadId);
157
+ if (!supervisor || !thread) throw new Error('thread_not_found');
158
+ if (['pending', 'running'].includes(thread.operation?.state ?? '')) throw new Error('launch_in_progress');
159
+ if (thread.bindingState === 'attached') {
160
+ if (!supervisor.connected || !supervisor.target.discardTab) throw new Error('machine_disconnected');
161
+ await supervisor.target.discardTab(thread.tabId, !!thread.ownsWorktree);
162
+ await supervisor.readFresh();
163
+ }
164
+ this.database.deleteThread(threadId);
165
+ this.publish(supervisor);
166
+ }
167
+ async deleteArchived() {
168
+ const archived = [...this.projections.values()].flatMap((projection) => projection.threads.filter((thread) => thread.archivedAt));
169
+ const failed: string[] = [];
170
+ for (const thread of archived) await this.deleteThread(thread.machineId, thread.id).catch(() => failed.push(thread.id));
171
+ return { deleted: archived.length - failed.length, failed };
172
+ }
173
+ private publishAll() { for (const supervisor of this.supervisors.values()) { this.publish(supervisor); supervisor.invalidate(); } }
174
+ private publish(supervisor: TargetSupervisor) {
175
+ const records = supervisor.snapshot ? reconcile(this.database, supervisor.target, supervisor.snapshot) : { threads: this.database.threadRows(supervisor.target.id).map((row) => ({ ...row.metadata, panes: [], bindingState: 'detached' as const, status: 'unknown' as const })), projects: [] };
176
+ const previous = this.projections.get(supervisor.target.id);
177
+ const projection: Projection = { machineId: supervisor.target.id, generation: supervisor.generation, revision: (previous?.revision ?? 0) + 1, freshAt: supervisor.freshAt, connected: supervisor.connected, error: supervisor.error, ...records, layouts: supervisor.snapshot?.layouts.map((layout) => ({ workspaceId: layout.workspace_id, tabId: layout.tab_id, area: layout.area, panes: layout.panes.map((pane) => ({ paneId: pane.pane_id, rect: pane.rect })) })) ?? [] };
178
+ projection.threads = projection.threads.map((thread) => ({ ...thread, prompt: '', ...(!projection.connected ? { panes: [], status: 'unknown' as const, bindingState: 'detached' as const } : {}) }));
179
+ projection.availableTabs = projection.connected ? supervisor.snapshot?.tabs.map((tab) => ({ tabId: tab.tab_id, workspaceId: tab.workspace_id, label: tab.label ?? tab.tab_id, bound: projection.threads.some((thread) => thread.bindingState === 'attached' && thread.tabId === tab.tab_id), panes: supervisor.snapshot!.panes.filter((pane) => pane.tab_id === tab.tab_id && pane.workspace_id === tab.workspace_id).map((pane) => ({ id: pane.pane_id, terminalId: pane.terminal_id, title: pane.title ?? pane.agent ?? 'Shell' })) })) ?? [] : [];
180
+ projection.machine = this.machine(supervisor);
181
+ const semantic = (value: Projection) => JSON.stringify({ ...value, revision: 0, freshAt: null });
182
+ if (previous && semantic(previous) === semantic(projection) && (!projection.freshAt || Date.parse(projection.freshAt) - Date.parse(previous.freshAt ?? '') < 30_000)) return;
183
+ this.projections.set(supervisor.target.id, projection);
184
+ if (!projection.connected) for (const key of this.catalogs.keys()) if (JSON.parse(key)[0] === supervisor.target.id) this.catalogs.delete(key);
185
+ this.emit('projection', projection);
186
+ }
187
+ }
@@ -0,0 +1,51 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type { MetadataDatabase } from '../storage/database';
3
+ import type { NativeSnapshot } from '../protocol/native';
4
+ import type { Project, Thread } from '../../lib/models';
5
+ import type { TargetAdapter } from './target';
6
+
7
+ export const projectIdentity = (machineId: string, repository: string) => createHash('sha256').update(machineId + '\0' + repository).digest('hex').slice(0, 32);
8
+ const sameBinding = (thread: Thread, target: TargetAdapter) => thread.session === target.session && thread.bindingFingerprint === target.fingerprint && thread.bindingConfigVersion === target.configVersion;
9
+ const paneStatus = (panes: NativeSnapshot['panes']) => { const agents = panes.filter((pane) => pane.agent); return ['blocked', 'working', 'done', 'idle', 'unknown'].find((status) => (agents.length ? agents : panes).some((pane) => pane.agent_status === status)) as Thread['status'] | undefined; };
10
+ const threadTitle = (label: string | null | undefined, panes: NativeSnapshot['panes'], fallback = 'Terminal') => {
11
+ const namedTab = label?.trim();
12
+ if (namedTab && !/^\d+$/.test(namedTab)) return namedTab;
13
+ const agent = panes.find((pane) => pane.agent && pane.focused) ?? panes.find((pane) => pane.agent);
14
+ return agent?.title?.trim() || agent?.terminal_title_stripped?.trim() || namedTab || fallback;
15
+ };
16
+
17
+ export function reconcile(database: MetadataDatabase, target: TargetAdapter, snapshot: NativeSnapshot): { threads: Thread[]; projects: Project[] } {
18
+ const saved = database.threadRows(target.id);
19
+ const projects: Project[] = [];
20
+ const tabProjects = new Map<string, string>();
21
+ for (const workspace of snapshot.workspaces) {
22
+ const cwd = workspace.worktree?.repo_root ?? snapshot.panes.find((pane) => pane.workspace_id === workspace.workspace_id)?.cwd ?? '';
23
+ const location = target.locations.find((location) => location.workspaceId === workspace.workspace_id || location.path === cwd);
24
+ const id = location?.projectId ?? projectIdentity(target.id, workspace.worktree?.repo_key ?? (cwd || workspace.workspace_id));
25
+ if (!projects.some((project) => project.id === id)) projects.push({ id, machineId: target.id, localId: location?.localId, logicalId: location?.logicalId ?? workspace.worktree?.repo_key ?? id, name: workspace.worktree?.repo_name ?? workspace.label, path: location?.path ?? cwd, color: '#9b83df', initial: (workspace.label[0] ?? 'P').toUpperCase(), iconUrl: target.icon && location ? `/api/projects/${encodeURIComponent(target.id)}/${encodeURIComponent(id)}/icon` : undefined });
26
+ for (const tab of snapshot.tabs.filter((tab) => tab.workspace_id === workspace.workspace_id)) tabProjects.set(tab.tab_id, id);
27
+ }
28
+ const launching = database.operations().some((operation) => operation.input.machineId === target.id && ['pending', 'running'].includes(operation.state));
29
+ for (const tab of snapshot.tabs) {
30
+ if (launching || saved.some((row) => row.alias === tab.tab_id)) continue;
31
+ const panes = snapshot.panes.filter((pane) => pane.tab_id === tab.tab_id && pane.workspace_id === tab.workspace_id);
32
+ if (!panes.length) continue;
33
+ const thread: Thread = { id: randomUUID(), avatarIndex: database.nextAvatarIndex(), projectId: tabProjects.get(tab.tab_id) ?? '', machineId: target.id, title: threadTitle(tab.label, panes), prompt: '', agent: '', model: '', status: 'unknown', updatedAt: new Date().toISOString(), branch: '', worktree: !!snapshot.workspaces.find((workspace) => workspace.workspace_id === tab.workspace_id)?.worktree?.is_linked_worktree, session: target.session, tabId: tab.tab_id, panes: [], bindingState: 'attached', bindingFingerprint: target.fingerprint, bindingConfigVersion: target.configVersion };
34
+ database.saveThread(thread, panes.map((pane) => pane.terminal_id), tab.tab_id);
35
+ saved.push(database.threadRows(target.id).find((row) => row.id === thread.id)!);
36
+ }
37
+ const operations = database.operations();
38
+ const threads = saved.map((row): Thread => {
39
+ const tab = snapshot.tabs.find((tab) => tab.tab_id === row.alias);
40
+ const members = tab ? snapshot.panes.filter((pane) => pane.tab_id === tab.tab_id && pane.workspace_id === tab.workspace_id) : [];
41
+ const attached = sameBinding(row.metadata, target) && members.length > 0;
42
+ const panes = attached ? members : [];
43
+ const signature = JSON.stringify([tab?.label, panes.map((pane) => [pane.terminal_id, pane.pane_id, pane.agent, pane.agent_status, pane.title, pane.terminal_title_stripped, pane.focused, snapshot.agents.find((agent) => agent.terminal_id === pane.terminal_id)?.state_change_seq])]);
44
+ const meaningfulChange = attached && row.metadata.semanticSignature !== signature;
45
+ const operation = operations.find((operation) => operation.threadId === row.id);
46
+ const thread: Thread = { ...row.metadata, title: attached ? threadTitle(tab?.label, panes, row.metadata.title) : row.metadata.title, projectId: attached ? tabProjects.get(tab!.tab_id) ?? row.metadata.projectId : row.metadata.projectId, bindingState: attached ? 'attached' : operation && !row.anchors.length ? 'pending' : 'detached', status: attached ? paneStatus(panes) ?? 'unknown' : 'unknown', agent: attached ? panes.find((pane) => pane.agent)?.agent ?? '' : row.metadata.agent, semanticSignature: attached ? signature : row.metadata.semanticSignature, updatedAt: meaningfulChange ? new Date().toISOString() : row.metadata.updatedAt, panes: panes.map((pane) => ({ id: pane.pane_id, terminalId: pane.terminal_id, title: pane.title ?? pane.terminal_title_stripped ?? pane.agent ?? 'Shell', kind: pane.agent ? 'agent' : 'shell' })), operation: operation ? { id: operation.id, state: operation.state, step: operation.step } : undefined };
47
+ if (attached && (meaningfulChange || JSON.stringify(row.anchors) !== JSON.stringify(panes.map((pane) => pane.terminal_id)))) database.saveThread({ ...thread, panes: [], operation: undefined }, panes.map((pane) => pane.terminal_id), row.alias);
48
+ return thread;
49
+ });
50
+ return { threads, projects };
51
+ }