@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,148 @@
1
+ import type { TargetAdapter, LaunchLocation, TerminalDirection } from '../runtime/target';
2
+ import type { TargetProfile } from './registry';
3
+ import { localSocket } from './registry';
4
+ import { SocketApi } from '../protocol/socket';
5
+ import { nativeSnapshot, nativeWorkspace, nativeTab, nativePane } from '../protocol/native';
6
+ import { openCliStream } from '../terminal/cli';
7
+ import { boundedProcess } from './process';
8
+ import { SshForward, sshOptions, remoteCommand, quoteShell } from './ssh';
9
+ import type { LaunchInput } from '../../shared/runtime';
10
+ import type { Machine } from '../../lib/machines';
11
+ import { harnessName } from '../../lib/models';
12
+ import { acceptsModelFlag, herdrAgentKinds, isHerdrAgentKind } from '../../shared/agent-kinds';
13
+ import { modelListings, parseModelListing } from './model-discovery';
14
+ import { HerdrActions } from '../runtime/herdr-actions';
15
+ import { readApprovedIcon, type Icon } from './icons';
16
+ import { targetFingerprint } from './identity';
17
+ import { scopedProjectId } from '../../shared/projects';
18
+ import { validateInstalledSchema } from '../protocol/validate';
19
+ import { ensureLocalSession } from './local-session';
20
+ import { nativeLocations } from './native-locations';
21
+
22
+ const MODEL_LISTING_TIMEOUT_MS = 15_000;
23
+ const MODEL_LISTING_LIMIT = 4 * 1024 * 1024;
24
+ type Dependencies = { process: typeof boundedProcess; cli: typeof openCliStream };
25
+ export class HerdrTarget implements TargetAdapter {
26
+ readonly id; readonly name; readonly session; readonly locations: LaunchLocation[];
27
+ readonly fingerprint; configVersion = 1;
28
+ get enabled() { return this.profile.enabled; }
29
+ get writable() { return this.compatible; }
30
+ get acceptsLocalFiles() { return this.profile.transport === 'local'; }
31
+ private api?: SocketApi;
32
+ private forwarding?: SshForward;
33
+ private compatible = false;
34
+ private connecting?: Promise<SocketApi>;
35
+ private streams = new Set<ReturnType<typeof openCliStream>>();
36
+ private icons = new Map<string, { expires: number; value: Promise<Icon | undefined> }>();
37
+ constructor(private profile: TargetProfile, private dependencies: Dependencies = { process: boundedProcess, cli: openCliStream }) {
38
+ this.id = profile.id; this.name = profile.name; this.session = profile.session;
39
+ this.fingerprint = targetFingerprint(profile);
40
+ this.locations = profile.locations.map((location) => ({ ...location, localId: location.projectId, logicalId: location.logicalId ?? location.projectId, projectId: scopedProjectId(profile.id, location.projectId) }));
41
+ }
42
+ private command(args: string[]) {
43
+ const env: NodeJS.ProcessEnv = { ...process.env, HERDR_SOCKET_PATH: this.profile.socket ?? localSocket(this.profile) };
44
+ delete env.HERDR_SESSION;
45
+ return this.profile.transport === 'local'
46
+ ? { command: this.profile.executable ?? 'herdr', args, env }
47
+ : { command: 'ssh', args: [...sshOptions, this.profile.host!, remoteCommand(this.profile.socket!, this.profile.executable ?? 'herdr', args)], env: process.env };
48
+ }
49
+ private async connect() {
50
+ if (this.connecting) return this.connecting;
51
+ if (this.api) return this.api;
52
+ this.connecting ??= (async () => {
53
+ if (!this.profile.enabled || !(this.profile.automatic && this.profile.transport === 'local') && process.env.WEBR_CONNECT !== '1') throw new Error('connection_not_approved');
54
+ if (this.profile.automatic && this.profile.transport === 'local') await ensureLocalSession(this.profile);
55
+ if (this.profile.transport === 'ssh') {
56
+ this.forwarding = new SshForward(this.profile.host!, this.profile.socket!);
57
+ this.api = await this.forwarding.open();
58
+ } else this.api = new SocketApi(localSocket(this.profile));
59
+ const ping = await this.api.request('ping');
60
+ const command = this.command(['--version']);
61
+ const version = await this.dependencies.process(command.command, command.args, command.env);
62
+ const compatible = ping.protocol === 22 && ping.version === '0.9.3' && /^herdr 0\.9\.3\s*$/.test(version);
63
+ if (!compatible) throw new Error('unsupported_herdr_version');
64
+ const schemaCommand = this.command(['api', 'schema', '--json']);
65
+ const schema = JSON.parse(await this.dependencies.process(schemaCommand.command, schemaCommand.args, schemaCommand.env, 5000, 2 * 1024 * 1024));
66
+ if (validateInstalledSchema(schema).length) { this.compatible = false; throw new Error('unsupported_herdr_schema'); }
67
+ this.compatible = true;
68
+ return this.api;
69
+ })();
70
+ try { return await this.connecting; }
71
+ catch (error) { await this.disconnect(); throw error; }
72
+ finally { this.connecting = undefined; }
73
+ }
74
+ async subscribe(onEvent: () => void, onClose: (reason: string) => void, paneIds: string[] = []) {
75
+ return (await this.connect()).subscribe(onEvent, (reason) => { this.api?.close(); this.api = undefined; this.compatible = false; void this.forwarding?.close(); this.forwarding = undefined; onClose(reason); }, paneIds);
76
+ }
77
+ async snapshot() {
78
+ const snapshot = nativeSnapshot.parse((await (await this.connect()).request('session.snapshot')).snapshot);
79
+ if (snapshot.version !== '0.9.3') { this.compatible = false; throw new Error('unsupported_herdr_version'); }
80
+ if (this.profile.automatic && this.profile.transport === 'local') this.locations.splice(0, this.locations.length, ...nativeLocations(this.id, snapshot));
81
+ return snapshot;
82
+ }
83
+ async createWorkspace(path: string, label: string, requestId: string) {
84
+ if (this.profile.transport !== 'local' || !this.profile.automatic || !this.compatible) throw new Error('workspace_creation_unavailable');
85
+ const result = await (await this.connect()).request('workspace.create', { cwd: path, label, focus: false }, { timeoutMs: 30_000, requestId });
86
+ const workspace = nativeWorkspace.parse(result.workspace); const tab = nativeTab.parse(result.tab); const pane = nativePane.parse(result.root_pane);
87
+ if (tab.workspace_id !== workspace.workspace_id || pane.workspace_id !== workspace.workspace_id || pane.tab_id !== tab.tab_id) throw new Error('workspace_identity_mismatch');
88
+ return { workspaceId: workspace.workspace_id, tabId: tab.tab_id, terminalId: pane.terminal_id };
89
+ }
90
+ async catalog(_projectId?: string): Promise<Machine> {
91
+ const api = await this.connect();
92
+ await api.request('ping');
93
+ const run = (command: string, args: string[]) => this.profile.transport === 'local'
94
+ ? this.dependencies.process(command, args, undefined, MODEL_LISTING_TIMEOUT_MS, MODEL_LISTING_LIMIT)
95
+ : this.dependencies.process('ssh', [...sshOptions, this.profile.host!, [command, ...args].map(quoteShell).join(' ')], undefined, MODEL_LISTING_TIMEOUT_MS, MODEL_LISTING_LIMIT);
96
+ const isPresent = (kind: string) => run('/bin/sh', ['-c', 'command -v "$1" >/dev/null', 'sh', kind]).then(() => true, () => false);
97
+ const listModels = (kind: string) => modelListings[kind] ? run(kind, modelListings[kind].args).then((output) => parseModelListing(kind, output), () => []) : Promise.resolve([]);
98
+ const present = (await Promise.all(herdrAgentKinds.map(async (kind) => (await isPresent(kind)) ? kind : undefined))).filter((kind): kind is string => !!kind);
99
+ const discovered = await Promise.all(present.map(listModels));
100
+ return { id: this.id, name: this.name, session: this.session, connected: true, writable: this.writable, configVersion: this.configVersion, projectPaths: Object.fromEntries(this.locations.map((location) => [location.projectId, location.path])), harnesses: present.map((id, index) => ({ id, name: harnessName(id), models: ['Default', ...discovered[index]], customModels: acceptsModelFlag(id), launchEnabled: true })) };
101
+ }
102
+ async icon(projectId: string) {
103
+ const location = this.locations.find((location) => location.projectId === projectId);
104
+ if (this.profile.transport !== 'local' || !location) return;
105
+ const cached = this.icons.get(projectId);
106
+ if (cached && cached.expires > Date.now()) return cached.value;
107
+ const value = readApprovedIcon(location.path);
108
+ this.icons.set(projectId, { expires: Date.now() + 60_000, value }); return value;
109
+ }
110
+ openTerminal: TargetAdapter['openTerminal'] = (terminalId, mode, cols, rows, takeover, onFrame, onClose) => {
111
+ if (!this.compatible || mode === 'control' && !this.writable || mode === 'observe' && takeover) throw new Error('terminal_control_unavailable');
112
+ const command = this.command(['terminal', 'session', mode, terminalId, '--cols', String(cols), '--rows', String(rows), ...(takeover ? ['--takeover'] : [])]);
113
+ const stream = this.dependencies.cli(command.command, command.args, { env: command.env }, onFrame, (reason) => { this.streams.delete(stream); onClose(reason); });
114
+ this.streams.add(stream); return stream;
115
+ };
116
+ async create(input: LaunchInput, threadId: string) { return this.actions().create(input, threadId); }
117
+ canLaunch(input: LaunchInput) { return this.compatible && isHerdrAgentKind(input.agent) && this.locations.some((location) => location.projectId === input.projectId); }
118
+ async start(input: LaunchInput, paneId: string, threadId: string) { return this.actions().start(input, paneId, threadId); }
119
+ async prompt(paneId: string, prompt: string, terminalId: string, threadId: string, input: LaunchInput) { return this.actions().prompt(paneId, prompt, terminalId, threadId, input.agent, input); }
120
+ async worktrees(projectId: string) { return this.actions().worktrees(this.workspaceOf(projectId)); }
121
+ async openWorktree(projectId: string, path: string) { return this.actions().openWorktree(this.workspaceOf(projectId), path); }
122
+ private workspaceOf(projectId: string) {
123
+ const location = this.locations.find((location) => location.projectId === projectId);
124
+ if (!location) throw new Error('unknown_project_location');
125
+ return location.workspaceId;
126
+ }
127
+ /** 'app' when a full-screen program, not the shell, owns the pane and has no history to scroll through. */
128
+ async scrollMode(terminalId: string): Promise<'scrollback' | 'app'> {
129
+ const pane = (await this.snapshot()).panes.find((candidate) => candidate.terminal_id === terminalId);
130
+ if (!pane || (pane.scroll?.max_offset_from_bottom ?? 0) > 0) return 'scrollback';
131
+ const info = (await (await this.connect()).request('pane.process_info', { pane_id: pane.pane_id })).process_info as { foreground_process_group_id?: number; shell_pid?: number } | undefined;
132
+ return info?.foreground_process_group_id !== undefined && info.foreground_process_group_id !== info.shell_pid ? 'app' : 'scrollback';
133
+ }
134
+ async focusPane(paneId: string) { return this.actions().focus(paneId); }
135
+ async renameTab(tabId: string, label: string, renameWorkspace: boolean) { return this.actions().renameTab(await this.snapshot(), tabId, label, renameWorkspace); }
136
+ async splitTerminal(paneId: string, direction: TerminalDirection) { return this.actions().splitTerminal(await this.snapshot(), paneId, direction); }
137
+ async closePane(paneId: string) { return this.actions().closePane(paneId); }
138
+ async discardTab(tabId: string, removeWorktree: boolean) { return this.actions().discardTab(await this.snapshot(), tabId, removeWorktree); }
139
+ private actions() {
140
+ return new HerdrActions({ request: async (method, params, options) => {
141
+ const api = await this.connect(); const ping = await api.request('ping');
142
+ if (ping.version !== '0.9.3' || ping.protocol !== 22) { this.compatible = false; throw new Error('unsupported_herdr_version'); }
143
+ return api.request(method, params, options);
144
+ } }, this.locations, (input) => { if (!input || !this.canLaunch(input)) throw new Error('launch_unavailable'); });
145
+ }
146
+ close() { this.compatible = false; this.api?.close(); this.api = undefined; for (const stream of this.streams) stream.close(); void this.forwarding?.close(); }
147
+ private async disconnect() { this.compatible = false; this.api?.close(); this.api = undefined; await this.forwarding?.close(); this.forwarding = undefined; }
148
+ }
@@ -0,0 +1,65 @@
1
+ import { readdir, realpath, stat, readFile } from 'node:fs/promises';
2
+ import { basename, dirname, extname, isAbsolute, join, relative, sep } from 'node:path';
3
+
4
+ const contentTypes: Record<string, string> = { '.ico': 'image/x-icon', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp' };
5
+ const directories = ['', 'public', 'static', 'assets', 'web/public', 'app/public', 'src/assets', 'src/app', 'frontend/public'];
6
+ export type Icon = { bytes: Buffer; contentType: string };
7
+
8
+ function withinRoot(root: string, path: string) {
9
+ const part = relative(root, path);
10
+ return part !== '..' && !part.startsWith(`..${sep}`) && !isAbsolute(part);
11
+ }
12
+
13
+ function rank(name: string) {
14
+ const extension = extname(name).toLowerCase();
15
+ if (!contentTypes[extension]) return Infinity;
16
+ const stem = name.slice(0, -extension.length).toLowerCase();
17
+ if (stem === 'favicon') return 0;
18
+ if (stem.startsWith('favicon-')) return 1;
19
+ if (stem.startsWith('apple-touch-icon')) return 2;
20
+ if (stem === 'icon' || stem === 'app-icon') return 3;
21
+ if (stem.startsWith('icon-') || stem === 'logo' || stem === 'brand' || stem === 'mark') return 4;
22
+ if (stem.startsWith('logo-') || stem.startsWith('brand-')) return 5;
23
+ return Infinity;
24
+ }
25
+
26
+ async function iconInDirectory(root: string, directory: string): Promise<Icon | undefined> {
27
+ let actual: string;
28
+ try { actual = await realpath(directory); } catch { return; }
29
+ if (!withinRoot(root, actual)) return;
30
+ let entries;
31
+ try { entries = await readdir(actual, { withFileTypes: true }); } catch { return; }
32
+ entries = entries
33
+ .filter((entry) => (entry.isFile() || entry.isSymbolicLink()) && Number.isFinite(rank(entry.name)))
34
+ .sort((a, b) => rank(a.name) - rank(b.name) || a.name.localeCompare(b.name));
35
+ for (const entry of entries) {
36
+ try {
37
+ const file = await realpath(join(actual, entry.name));
38
+ if (!withinRoot(root, file)) continue;
39
+ const info = await stat(file);
40
+ if (!info.isFile() || info.size <= 0 || info.size > 1024 * 1024) continue;
41
+ const bytes = await readFile(file);
42
+ if (bytes.length <= 1024 * 1024) return { bytes, contentType: contentTypes[extname(entry.name).toLowerCase()] };
43
+ } catch {}
44
+ }
45
+ }
46
+
47
+ async function appDirectories(root: string) {
48
+ let apps: string;
49
+ try { apps = await realpath(join(root, 'apps')); } catch { return []; }
50
+ if (!withinRoot(root, apps)) return [];
51
+ const brand = (basename(root) === 'monorepo' ? basename(dirname(root)) : basename(root)).toLowerCase();
52
+ let entries;
53
+ try { entries = (await readdir(apps, { withFileTypes: true })).filter((entry) => entry.isDirectory()); } catch { return []; }
54
+ return entries.sort((a, b) => Number(!a.name.toLowerCase().includes(brand)) - Number(!b.name.toLowerCase().includes(brand)) || a.name.localeCompare(b.name))
55
+ .slice(0, 32).flatMap((entry) => ['public', 'static', 'assets', 'src/assets', 'src/app'].map((part) => join(apps, entry.name, part)));
56
+ }
57
+
58
+ export async function readApprovedIcon(path: string): Promise<Icon | undefined> {
59
+ let root: string;
60
+ try { root = await realpath(path); } catch { return; }
61
+ for (const directory of [...directories.map((part) => join(root, part)), ...await appDirectories(root)]) {
62
+ const icon = await iconInDirectory(root, directory);
63
+ if (icon) return icon;
64
+ }
65
+ }
@@ -0,0 +1,5 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { localSocket, type TargetProfile } from './registry';
3
+ export function targetFingerprint(profile: TargetProfile) {
4
+ return createHash('sha256').update(JSON.stringify({ id: profile.id, transport: profile.transport, host: profile.transport === 'ssh' ? profile.host : 'local', socket: profile.transport === 'ssh' ? profile.socket : localSocket(profile), session: profile.session, executable: profile.executable ?? 'herdr' })).digest('hex');
5
+ }
@@ -0,0 +1,65 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { lstat } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { z } from 'zod';
5
+ import { SocketApi } from '../protocol/socket';
6
+ import { boundedProcess } from './process';
7
+ import { localSocket, profileSchema, type TargetProfile } from './registry';
8
+
9
+ const sessionName = z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/).refine((name) => !['.', '..'].includes(name));
10
+ const sessionsResult = z.object({ sessions: z.array(z.object({ name: sessionName, default: z.boolean(), running: z.boolean(), socket_path: z.string().startsWith('/') })).min(1).max(1000) });
11
+ const pathExists = async (path: string) => { try { await lstat(path); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false; throw error; } };
12
+ const ping = async (socket: string) => { const api = new SocketApi(socket); try { return await api.request('ping'); } finally { api.close(); } };
13
+ const start = async (profile: TargetProfile) => {
14
+ const env: NodeJS.ProcessEnv = { ...process.env, HERDR_SOCKET_PATH: localSocket(profile), HERDR_SESSION: profile.session };
15
+ delete env.HERDR_CLIENT_SOCKET_PATH;
16
+ delete env.HERDR_STARTUP_CWD;
17
+ const child = spawn(profile.executable ?? 'herdr', ['server'], { env, detached: true, stdio: 'ignore' });
18
+ await new Promise<void>((resolve, reject) => { child.once('spawn', resolve); child.once('error', reject); });
19
+ child.unref();
20
+ };
21
+ export type LocalSessionDependencies = { process: typeof boundedProcess; exists: typeof pathExists; ping: typeof ping; start: typeof start; sleep: (ms: number) => Promise<void> };
22
+ const defaults: LocalSessionDependencies = { process: boundedProcess, exists: pathExists, ping, start, sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
23
+
24
+ export async function discoverLocalSession(env = process.env, runProcess = boundedProcess, exists = pathExists): Promise<TargetProfile> {
25
+ const base = { id: 'local', name: 'Local', session: env.HERDR_SESSION ?? 'default', enabled: true, transport: 'local', locations: [], automatic: true };
26
+ if (env.HERDR_SOCKET_PATH) return profileSchema.parse({ ...base, socket: env.HERDR_SOCKET_PATH });
27
+ if (env.HERDR_SESSION) return profileSchema.parse(base);
28
+ const { sessions } = sessionsResult.parse(JSON.parse(await runProcess('herdr', ['session', 'list', '--json'], env)));
29
+ const running = sessions.filter((session) => session.running);
30
+ const selected = running.find((session) => session.default) ?? (running.length === 1 ? running[0] : undefined);
31
+ if (running.length && !selected) throw new Error('multiple_herdr_sessions: start the app from the desired Herdr session');
32
+ const current = selected ?? sessions.find((session) => session.default);
33
+ if (!current) throw new Error('missing_default_session');
34
+ if (!selected) for (const session of sessions) {
35
+ if (await exists(session.socket_path) || await exists(join(dirname(session.socket_path), 'herdr-client.sock'))) throw new Error('herdr_session_unreachable');
36
+ }
37
+ return profileSchema.parse({ ...base, session: current.name, socket: current.socket_path });
38
+ }
39
+
40
+ export function localSessionBootstrap(dependencies: LocalSessionDependencies = defaults) {
41
+ const pending = new Map<string, Promise<void>>();
42
+ return (profile: TargetProfile): Promise<void> => {
43
+ const socket = localSocket(profile);
44
+ const existing = pending.get(socket);
45
+ if (existing) return existing;
46
+ const promise = (async () => {
47
+ try { await dependencies.ping(socket); return; }
48
+ catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
49
+ if (await dependencies.exists(socket) || await dependencies.exists(join(dirname(socket), 'herdr-client.sock'))) throw new Error('herdr_session_unreachable');
50
+ const version = await dependencies.process(profile.executable ?? 'herdr', ['--version']);
51
+ if (version.trim() !== 'herdr 0.9.3') throw new Error('unsupported_herdr_version');
52
+ await dependencies.start(profile);
53
+ for (let attempt = 0; attempt < 100; attempt++) {
54
+ try { await dependencies.ping(socket); return; }
55
+ catch (error) { if (!['ENOENT', 'ECONNREFUSED'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; }
56
+ await dependencies.sleep(100);
57
+ }
58
+ throw new Error('herdr_session_start_unknown');
59
+ })();
60
+ pending.set(socket, promise);
61
+ return promise;
62
+ };
63
+ }
64
+
65
+ export const ensureLocalSession = localSessionBootstrap();
@@ -0,0 +1,18 @@
1
+ const lines = (output: string) => output.split('\n').map((line) => line.trim()).filter(Boolean);
2
+ const codexSlugs = (output: string) => (JSON.parse(output).models as { slug: string }[]).map((model) => model.slug);
3
+ const piIds = (output: string) => lines(output).slice(1).map((line) => line.split(/\s+/)).map(([provider, model]) => provider + '/' + model);
4
+ const firstColumn = (output: string) => lines(output).map((line) => line.split('\t')).filter((columns) => columns.length > 1).map(([id]) => id);
5
+
6
+ export const modelListings: Record<string, { args: string[]; parse: (output: string) => string[] }> = {
7
+ opencode: { args: ['models'], parse: lines },
8
+ pi: { args: ['--list-models'], parse: piIds },
9
+ codex: { args: ['debug', 'models'], parse: codexSlugs },
10
+ agy: { args: ['models'], parse: firstColumn },
11
+ };
12
+ const validModel = /^[A-Za-z0-9_/.:+-]{1,120}$/;
13
+
14
+ export function parseModelListing(kind: string, output: string): string[] {
15
+ const listing = modelListings[kind];
16
+ if (!listing) return [];
17
+ try { return [...new Set(listing.parse(output).filter((id) => validModel.test(id)))]; } catch { return []; }
18
+ }
@@ -0,0 +1,17 @@
1
+ import type { NativeSnapshot } from '../protocol/native';
2
+ import type { LaunchLocation } from '../runtime/target';
3
+ import { projectIdentity } from '../runtime/reconcile';
4
+ import { scopedProjectId } from '../../shared/projects';
5
+
6
+ export function nativeLocations(machineId: string, snapshot: NativeSnapshot): LaunchLocation[] {
7
+ const locations = new Map<string, LaunchLocation>();
8
+ for (const workspace of snapshot.workspaces) {
9
+ const path = workspace.worktree?.repo_root ?? snapshot.panes.find((pane) => pane.workspace_id === workspace.workspace_id)?.cwd;
10
+ if (!path?.startsWith('/')) continue;
11
+ const logicalId = workspace.worktree?.repo_key ?? path;
12
+ const localId = projectIdentity(machineId, logicalId);
13
+ const projectId = scopedProjectId(machineId, localId);
14
+ if (!locations.has(projectId) || !workspace.worktree?.is_linked_worktree) locations.set(projectId, { projectId, localId, logicalId, path, workspaceId: workspace.workspace_id });
15
+ }
16
+ return [...locations.values()];
17
+ }
@@ -0,0 +1,21 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ export function boundedProcess(command: string, args: string[], env?: NodeJS.ProcessEnv, timeout = 5000, limit = 64 * 1024): Promise<string> {
4
+ return new Promise((resolve, reject) => {
5
+ const child = spawn(command, args, { env, shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
6
+ const chunks: Buffer[] = [];
7
+ let size = 0;
8
+ let stderrSize = 0;
9
+ let finished = false;
10
+ const finish = (error?: Error) => {
11
+ if (finished) return;
12
+ finished = true; clearTimeout(timer);
13
+ if (error) { child.kill('SIGTERM'); reject(error); } else resolve(Buffer.concat(chunks).toString('utf8'));
14
+ };
15
+ const timer = setTimeout(() => finish(new Error('process_timeout')), timeout);
16
+ child.stdout.on('data', (chunk: Buffer) => { size += chunk.length; if (size > limit) finish(new Error('process_output_limit')); else chunks.push(chunk); });
17
+ child.stderr.on('data', (chunk: Buffer) => { stderrSize += chunk.length; if (stderrSize > limit) finish(new Error('process_stderr_limit')); });
18
+ child.on('error', () => finish(new Error('process_unavailable')));
19
+ child.on('exit', (code) => finish(code === 0 ? undefined : new Error('process_failed')));
20
+ });
21
+ }
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { readFile } from 'node:fs/promises';
5
+
6
+ export const profileSchema = z.object({
7
+ id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/), name: z.string().min(1).max(80), session: z.string().regex(/^[A-Za-z0-9_.-]{1,64}$/).refine((name) => !['.', '..'].includes(name)), enabled: z.boolean(),
8
+ automatic: z.boolean().optional(),
9
+ transport: z.enum(['local', 'ssh']), host: z.string().regex(/^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,200}$/).optional(),
10
+ socket: z.string().regex(/^\/[A-Za-z0-9_./-]+$/).optional(),
11
+ executable: z.string().regex(/^(?:\/[A-Za-z0-9_./-]+|[A-Za-z0-9_.-]+)$/).optional(),
12
+ locations: z.array(z.object({ projectId: z.string().regex(/^[A-Za-z0-9_-]+$/), logicalId: z.string().regex(/^[A-Za-z0-9_.-]{1,128}$/).optional(), path: z.string().startsWith('/').max(1000), workspaceId: z.string().min(1).max(160) }).strict()).max(100),
13
+ }).strict().superRefine((value, context) => { if (value.transport === 'ssh' && (!value.host || !value.socket)) context.addIssue({ code: 'custom', message: 'SSH requires a host and a verified absolute socket' }); });
14
+ export type TargetProfile = z.infer<typeof profileSchema>;
15
+ export async function loadRegistry(path?: string): Promise<TargetProfile[]> {
16
+ if (!path) return [await (await import('./local-session')).discoverLocalSession()];
17
+ const profiles = z.array(profileSchema).max(32).parse(JSON.parse(await readFile(path, 'utf8')));
18
+ if (new Set(profiles.map((profile) => profile.id)).size !== profiles.length) throw new Error('duplicate_target');
19
+ if (profiles.some((profile) => new Set(profile.locations.map((location) => location.projectId)).size !== profile.locations.length)) throw new Error('duplicate_project_location');
20
+ return profiles;
21
+ }
22
+ export function localSocket(profile: TargetProfile) {
23
+ if (profile.transport !== 'local') throw new Error('not_local');
24
+ if (profile.socket) return profile.socket;
25
+ const config = process.env.HERDR_CONFIG_PATH ? join(process.env.HERDR_CONFIG_PATH, '..') : join(process.env.XDG_CONFIG_HOME ?? join(homedir(), '.config'), 'herdr');
26
+ return profile.session === 'default' ? join(config, 'herdr.sock') : join(config, 'sessions', profile.session, 'herdr.sock');
27
+ }
@@ -0,0 +1,47 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { mkdtemp, chmod, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { SocketApi } from '../protocol/socket';
6
+
7
+ export const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'";
8
+ export const sshOptions = ['-T', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=5', '-o', 'ServerAliveInterval=5', '-o', 'ServerAliveCountMax=2', '-o', 'ExitOnForwardFailure=yes', '-o', 'ForwardAgent=no'];
9
+ export function remoteCommand(socket: string, command: string, args: string[]) {
10
+ if (/[\0\r\n]/.test(socket + command + args.join(''))) throw new Error('invalid_remote_command');
11
+ return ['env', 'HERDR_SOCKET_PATH=' + socket, command, ...args].map(quoteShell).join(' ');
12
+ }
13
+
14
+ export class SshForward {
15
+ private process?: ChildProcess;
16
+ private directory?: string;
17
+ private api?: SocketApi;
18
+ constructor(private host: string, private remoteSocket: string) {
19
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.@-]{0,200}$/.test(host) || !/^\/[A-Za-z0-9_./-]+$/.test(remoteSocket)) throw new Error('invalid_ssh_target');
20
+ }
21
+ async open() {
22
+ if (this.api) return this.api;
23
+ this.directory = await mkdtemp(join(tmpdir(), 'hw-'));
24
+ await chmod(this.directory, 0o700);
25
+ const path = join(this.directory, 'a.sock');
26
+ if (Buffer.byteLength(path) > 103) { await this.close(); throw new Error('socket_path_limit'); }
27
+ const child = spawn('ssh', [...sshOptions, '-N', '-L', `${path}:${this.remoteSocket}`, this.host], { stdio: ['ignore', 'ignore', 'pipe'] });
28
+ this.process = child;
29
+ const api = new SocketApi(path);
30
+ let failed = false;
31
+ let stderrBytes = 0;
32
+ child.on('error', () => { failed = true; });
33
+ child.on('exit', () => { failed = true; api.close(); this.api = undefined; });
34
+ child.stderr?.on('data', (chunk: Buffer) => { stderrBytes += chunk.length; if (stderrBytes > 16 * 1024) child.kill('SIGTERM'); });
35
+ const end = Date.now() + 5000;
36
+ while (!failed && Date.now() < end) {
37
+ try { await api.request('ping'); this.api = api; return api; } catch { await new Promise((resolve) => setTimeout(resolve, 50)); }
38
+ }
39
+ await this.close(); throw new Error('ssh_forward_failed');
40
+ }
41
+ async close() {
42
+ this.api?.close(); this.api = undefined;
43
+ this.process?.kill('SIGTERM'); this.process = undefined;
44
+ if (this.directory) await rm(this.directory, { recursive: true, force: true });
45
+ this.directory = undefined;
46
+ }
47
+ }
@@ -0,0 +1,40 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { mkdir, readdir, rm, stat, writeFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+
5
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
6
+ const UPLOAD_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
7
+ const startsWith = (bytes: Buffer, signature: number[], offset = 0) => signature.every((byte, index) => bytes[offset + index] === byte);
8
+ const ascii = (text: string) => [...text].map((character) => character.charCodeAt(0));
9
+ const imageTypes: Record<string, { extension: string; matches: (bytes: Buffer) => boolean }> = {
10
+ 'image/png': { extension: 'png', matches: (bytes) => startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) },
11
+ 'image/jpeg': { extension: 'jpg', matches: (bytes) => startsWith(bytes, [0xff, 0xd8, 0xff]) },
12
+ 'image/gif': { extension: 'gif', matches: (bytes) => startsWith(bytes, ascii('GIF87a')) || startsWith(bytes, ascii('GIF89a')) },
13
+ 'image/webp': { extension: 'webp', matches: (bytes) => startsWith(bytes, ascii('RIFF')) && startsWith(bytes, ascii('WEBP'), 8) },
14
+ };
15
+ export const uploadContentTypes = Object.keys(imageTypes);
16
+
17
+ export function imageExtension(contentType: string | undefined, bytes: Buffer) {
18
+ const type = imageTypes[contentType?.split(';')[0].trim().toLowerCase() ?? ''];
19
+ if (!type || !bytes.length || !type.matches(bytes)) throw new Error('unsupported_image');
20
+ return type.extension;
21
+ }
22
+
23
+ export class UploadStore {
24
+ constructor(readonly directory = resolve(process.env.WEBR_UPLOADS ?? '.data/uploads'), private lifetimeMs = UPLOAD_LIFETIME_MS) {}
25
+ async save(contentType: string | undefined, bytes: Buffer) {
26
+ const extension = imageExtension(contentType, bytes);
27
+ await mkdir(this.directory, { recursive: true, mode: 0o700 });
28
+ const path = join(this.directory, `image-${randomUUID()}.${extension}`);
29
+ await writeFile(path, bytes, { mode: 0o600, flag: 'wx' });
30
+ void this.removeExpired().catch(() => {});
31
+ return path;
32
+ }
33
+ async removeExpired(now = Date.now()) {
34
+ for (const name of await readdir(this.directory).catch(() => [] as string[])) {
35
+ if (!/^image-[0-9a-f-]{36}\.(png|jpg|gif|webp)$/.test(name)) continue;
36
+ const path = join(this.directory, name);
37
+ if (now - (await stat(path)).mtimeMs > this.lifetimeMs) await rm(path, { force: true });
38
+ }
39
+ }
40
+ }
@@ -0,0 +1,62 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import type { WebSocket } from 'ws';
3
+ import type { RuntimeManager } from './runtime/manager';
4
+ import { TerminalHub } from './terminal/hub';
5
+ import { terminalAction } from '../shared/runtime';
6
+ import { z } from 'zod';
7
+
8
+ export function registerWebsockets(app: FastifyInstance, manager: RuntimeManager) {
9
+ const hub = new TerminalHub(manager);
10
+ const pairs = new Map<string, { metadata?: WebSocket; terminal?: WebSocket; close: () => void }>();
11
+ for (const kind of ['metadata', 'terminal'] as const) {
12
+ app.get(`/api/ws/${kind}`, { websocket: true }, (socket, request) => {
13
+ const parsed = z.object({ instance: z.uuid() }).strict().safeParse(request.query);
14
+ if (!parsed.success) { socket.close(1008); return; }
15
+ const owner = parsed.data.instance;
16
+ let pair = pairs.get(owner);
17
+ if (!pair) {
18
+ if (pairs.size >= 32) { socket.close(1013); return; }
19
+ let pairing: ReturnType<typeof setTimeout> | undefined;
20
+ const publish = (projection: unknown) => {
21
+ if (!pair?.metadata || pair.metadata.readyState !== 1) return;
22
+ const value = JSON.stringify({ type: 'projection', projection });
23
+ if (Buffer.byteLength(value) + pair.metadata.bufferedAmount > 2 * 1024 * 1024) { pair.close(); return; }
24
+ pair.metadata.send(value);
25
+ };
26
+ const close = () => {
27
+ if (!pairs.has(owner)) return;
28
+ pairs.delete(owner); clearTimeout(pairing);
29
+ manager.off('projection', publish); hub.detach(owner);
30
+ pair?.metadata?.close(1008); pair?.terminal?.close(1008);
31
+ };
32
+ pair = { close }; pairs.set(owner, pair);
33
+ manager.on('projection', publish);
34
+ pairing = setTimeout(() => { if (!pair?.metadata || !pair.terminal) close(); }, 5000);
35
+ pairing.unref();
36
+ }
37
+ if (pair[kind]) { socket.close(1008); return; }
38
+ pair[kind] = socket;
39
+ if (kind === 'terminal') hub.attach(owner, socket);
40
+ let pending = 0;
41
+ let commands = Promise.resolve();
42
+ socket.on('message', (data, binary) => {
43
+ if (binary || kind !== 'terminal' || ++pending > 16) { pair!.close(); return; }
44
+ let value: unknown;
45
+ try { value = JSON.parse(data.toString()); } catch { pair!.close(); return; }
46
+ const validated = terminalAction.safeParse(value);
47
+ if (!validated.success) { pair!.close(); return; }
48
+ commands = commands.then(async () => {
49
+ if (!pairs.has(owner)) return;
50
+ if (!pair?.metadata || !pair.terminal) { pair!.close(); return; }
51
+ try { hub.action(owner, validated.data); }
52
+ catch (error) { if (socket.readyState === 1) socket.send(JSON.stringify({ type: 'stream.error', streamId: validated.data.streamId, generation: validated.data.generation, reason: error instanceof Error ? error.message : 'action_failed' })); }
53
+ }).catch(() => pair!.close()).finally(() => { --pending; });
54
+ });
55
+ socket.on('error', () => pair!.close());
56
+ socket.on('close', () => pair!.close());
57
+ if (kind === 'metadata') for (const projection of manager.bootstrap().projections) socket.send(JSON.stringify({ type: 'projection', projection }));
58
+ });
59
+ }
60
+ app.addHook('onClose', async () => { for (const pair of pairs.values()) pair.close(); hub.close(); });
61
+ return hub;
62
+ }
@@ -0,0 +1,4 @@
1
+ export const herdrAgentKinds = ['pi', 'claude', 'codex', 'gemini', 'cursor', 'devin', 'agy', 'cline', 'omp', 'mastracode', 'opencode', 'copilot', 'kimi', 'kiro', 'droid', 'amp', 'grok', 'hermes', 'kilo', 'qodercli', 'qwen', 'letta', 'maki', 'muse'];
2
+ const kindsWithModelFlag = ['claude', 'codex', 'opencode', 'pi', 'agy', 'hermes'];
3
+ export const isHerdrAgentKind = (kind: string) => herdrAgentKinds.includes(kind);
4
+ export const acceptsModelFlag = (kind: string) => kindsWithModelFlag.includes(kind);
@@ -0,0 +1,38 @@
1
+ export const HEADER_SIZE = 32;
2
+ export const MAX_FRAME_BYTES = 256 * 1024;
3
+ export type Frame = { streamId: number; generation: number; seq: number; width: number; height: number; full: boolean; bytes: Uint8Array };
4
+
5
+ export function encodeFrame(frame: Frame): Uint8Array {
6
+ if (frame.bytes.length > MAX_FRAME_BYTES) throw new Error('frame_limit');
7
+ const packet = new Uint8Array(HEADER_SIZE + frame.bytes.length);
8
+ const view = new DataView(packet.buffer);
9
+ view.setUint32(0, 0x48445231);
10
+ view.setUint32(4, frame.streamId);
11
+ view.setUint32(8, frame.generation);
12
+ view.setBigUint64(12, BigInt(frame.seq));
13
+ view.setUint16(20, frame.width);
14
+ view.setUint16(22, frame.height);
15
+ view.setUint32(24, frame.bytes.length);
16
+ view.setUint8(28, frame.full ? 1 : 0);
17
+ packet.set(frame.bytes, HEADER_SIZE);
18
+ return packet;
19
+ }
20
+
21
+ export function decodeFrame(packet: Uint8Array): Frame {
22
+ if (packet.length < HEADER_SIZE) throw new Error('short_frame');
23
+ const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength);
24
+ const seq = Number(view.getBigUint64(12));
25
+ if (view.getUint32(0) !== 0x48445231 || view.getUint32(24) !== packet.length - HEADER_SIZE || packet.length - HEADER_SIZE > MAX_FRAME_BYTES || !Number.isSafeInteger(seq) || view.getUint8(28) > 1 || view.getUint8(29) || view.getUint16(30)) throw new Error('invalid_frame');
26
+ const frame = { streamId: view.getUint32(4), generation: view.getUint32(8), seq, width: view.getUint16(20), height: view.getUint16(22), full: !!view.getUint8(28), bytes: packet.subarray(HEADER_SIZE) };
27
+ if (!frame.streamId || !frame.generation || !frame.width || frame.width > 500 || !frame.height || frame.height > 300) throw new Error('invalid_dimensions');
28
+ return frame;
29
+ }
30
+
31
+ export class FrameSequence {
32
+ private seq: number | undefined;
33
+ accept(frame: Frame) {
34
+ if (this.seq === undefined && !frame.full) throw new Error('baseline_required');
35
+ if (this.seq !== undefined && frame.seq !== this.seq + 1) throw new Error('sequence_gap');
36
+ this.seq = frame.seq;
37
+ }
38
+ }
@@ -0,0 +1 @@
1
+ export const scopedProjectId = (machineId: string, localId: string) => `${machineId}:${localId}`;
@@ -0,0 +1 @@
1
+ export const retryDelay = (attempt: number, random = Math.random) => Math.min(30_000, Math.round(Math.min(25_000, 1000 * 2 ** Math.min(attempt, 5)) * (0.8 + random() * 0.4)));
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod';
2
+ import type { Project, Thread } from '../lib/models';
3
+ import type { Machine } from '../lib/machines';
4
+
5
+ export const opaqueId = z.string().min(1).max(160).regex(/^[A-Za-z0-9_:.-]+$/);
6
+ export const launchInput = z.object({
7
+ machineId: opaqueId, projectId: opaqueId,
8
+ prompt: z.string().trim().min(1).max(8000).refine((text) => !/[\x00\x1b]/.test(text)),
9
+ agent: z.string().regex(/^[a-z][a-z0-9_-]{0,31}$/),
10
+ model: z.string().trim().min(1).max(120).regex(/^[A-Za-z0-9_/.:+-]+$/),
11
+ worktree: z.boolean(),
12
+ }).strict();
13
+ export type LaunchInput = z.infer<typeof launchInput>;
14
+ export type Rect = { x: number; y: number; width: number; height: number };
15
+ export type Layout = { workspaceId: string; tabId: string; area: Rect; panes: { paneId: string; rect: Rect }[] };
16
+ export type Projection = {
17
+ machineId: string; generation: string; revision: number; freshAt: string | null;
18
+ connected: boolean; error?: string; threads: Thread[]; projects: Project[]; layouts: Layout[];
19
+ availableTabs?: { tabId: string; workspaceId: string; label: string; bound: boolean; panes: { id: string; terminalId: string; title: string }[] }[];
20
+ machine?: Machine;
21
+ };
22
+ export type Bootstrap = { projections: Projection[]; machines: Machine[]; projects: Project[]; threads: Thread[] };
23
+ const streamRef = { streamId: z.number().int().min(1).max(0xffffffff), generation: z.number().int().min(1).max(0xffffffff) };
24
+ export const terminalAction = z.discriminatedUnion('type', [
25
+ z.object({ type: z.literal('open'), ...streamRef, machineId: opaqueId, threadId: z.uuid(), terminalId: opaqueId, cols: z.number().int().min(2).max(500), rows: z.number().int().min(1).max(300), mode: z.enum(['observe', 'control']), takeover: z.boolean() }).strict(),
26
+ z.object({ type: z.literal('ack'), ...streamRef, seq: z.number().int().nonnegative() }).strict(),
27
+ z.object({ type: z.literal('release'), ...streamRef }).strict(),
28
+ z.object({ type: z.literal('input'), ...streamRef, text: z.string().min(1).max(8192), paste: z.boolean() }).strict(),
29
+ z.object({ type: z.literal('resize'), ...streamRef, cols: z.number().int().min(2).max(500), rows: z.number().int().min(1).max(300) }).strict(),
30
+ z.object({ type: z.literal('scroll'), ...streamRef, direction: z.enum(['up', 'down']), lines: z.number().int().min(1).max(100), column: z.number().int().min(1).max(500).optional(), row: z.number().int().min(1).max(300).optional() }).strict(),
31
+ z.object({ type: z.literal('mouse'), ...streamRef, action: z.enum(['down', 'up', 'drag', 'move']), button: z.enum(['left', 'right', 'middle']), column: z.number().int().min(0).max(499), row: z.number().int().min(0).max(299), modifiers: z.number().int().min(0).max(7) }).strict(),
32
+ ]);
33
+ export type TerminalAction = z.infer<typeof terminalAction>;