@ctrl-spc/cli 1.3.1 → 1.3.3

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.
@@ -0,0 +1,422 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { createServer } from 'node:http';
5
+ import { join } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { configDir, clearSession, getMachineIdentity, readSession } from './config.js';
8
+ import { login } from './commands/login.js';
9
+ import { getClient } from './supabase.js';
10
+ import { renderCompanionUi } from './companion-ui.js';
11
+ import { chooseCompanionFolder, createCompanionProject, discoverCompanionProject, fetchMissingCompanionFolders, linkCompanionFolder, linkCompanionProject, loadCompanionProjects, } from './companion-projects.js';
12
+ import { currentCompanionKind, LOCAL_RUNTIME_PORT, NPM_RUNTIME_PORT, runtimeStatuses, startRuntime, stopRuntime, } from './runtime-control.js';
13
+ export const LOCAL_COMPANION_PORT = 4570;
14
+ export const NPM_COMPANION_PORT = 4573;
15
+ const CLI_ENTRY_PATH = fileURLToPath(new URL('./index.js', import.meta.url));
16
+ const PROJECT_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
17
+ export function attachRedirectLocation(projectId, token) {
18
+ if (!PROJECT_ID_RE.test(projectId))
19
+ return null;
20
+ return `/?token=${encodeURIComponent(token)}&attachProject=${encodeURIComponent(projectId)}`;
21
+ }
22
+ export function linkRedirectLocation(projectId, token) {
23
+ if (!PROJECT_ID_RE.test(projectId))
24
+ return null;
25
+ return `/?token=${encodeURIComponent(token)}&linkProject=${encodeURIComponent(projectId)}`;
26
+ }
27
+ function companionPort(kind) {
28
+ return Number(process.env.CTRL_SPC_COMPANION_PORT) || (kind === 'local' ? LOCAL_COMPANION_PORT : NPM_COMPANION_PORT);
29
+ }
30
+ function statePath(kind) {
31
+ return join(configDir(), 'runtime', `companion-${kind}.json`);
32
+ }
33
+ function readState(kind) {
34
+ try {
35
+ return JSON.parse(readFileSync(statePath(kind), 'utf8'));
36
+ }
37
+ catch {
38
+ return null;
39
+ }
40
+ }
41
+ function writeState(kind, state) {
42
+ const path = statePath(kind);
43
+ mkdirSync(join(configDir(), 'runtime'), { recursive: true });
44
+ writeFileSync(path, JSON.stringify(state), { mode: 0o600 });
45
+ chmodSync(path, 0o600);
46
+ }
47
+ function openBrowser(url) {
48
+ const [command, args] = process.platform === 'darwin'
49
+ ? ['open', [url]]
50
+ : process.platform === 'win32'
51
+ ? ['cmd', ['/c', 'start', '""', url]]
52
+ : ['xdg-open', [url]];
53
+ try {
54
+ const child = spawn(command, args, { detached: true, stdio: 'ignore' });
55
+ child.on('error', () => { });
56
+ child.unref();
57
+ }
58
+ catch {
59
+ // The URL is also printed so a missing opener is non-fatal.
60
+ }
61
+ }
62
+ function json(res, status, body, headers = {}) {
63
+ res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', ...headers });
64
+ res.end(JSON.stringify(body));
65
+ }
66
+ export function companionProbeCorsHeaders(origin) {
67
+ if (!origin)
68
+ return {};
69
+ const allowed = origin === 'https://ctrl-spc.com'
70
+ || origin === 'https://www.ctrl-spc.com'
71
+ || /^http:\/\/(localhost|127\.0\.0\.1):\d+$/.test(origin);
72
+ return allowed ? {
73
+ 'Access-Control-Allow-Origin': origin,
74
+ 'Access-Control-Allow-Methods': 'GET, OPTIONS',
75
+ 'Vary': 'Origin',
76
+ } : {};
77
+ }
78
+ export function companionHealthPayload(machineId = getMachineIdentity().id) {
79
+ return { status: 'ok', machineId };
80
+ }
81
+ function authorized(req, token) {
82
+ return req.headers['x-ctrl-spc-token'] === token;
83
+ }
84
+ async function readJsonBody(req) {
85
+ const chunks = [];
86
+ let size = 0;
87
+ for await (const chunk of req) {
88
+ const buffer = chunk;
89
+ size += buffer.length;
90
+ if (size > 32 * 1024)
91
+ throw new Error('Request is too large.');
92
+ chunks.push(buffer);
93
+ }
94
+ if (chunks.length === 0)
95
+ return {};
96
+ try {
97
+ return JSON.parse(Buffer.concat(chunks).toString('utf8'));
98
+ }
99
+ catch {
100
+ throw new Error('Request contains invalid JSON.');
101
+ }
102
+ }
103
+ async function statusPayload(kind) {
104
+ const profiles = await runtimeStatuses();
105
+ const signedIn = Boolean(readSession());
106
+ let supabaseReachable = false;
107
+ if (signedIn) {
108
+ try {
109
+ const { error } = await (await getClient()).auth.getUser();
110
+ supabaseReachable = !error;
111
+ }
112
+ catch {
113
+ supabaseReachable = false;
114
+ }
115
+ }
116
+ const other = profiles.find((profile) => profile.kind !== kind);
117
+ const otherActive = other?.state === 'ready' || other?.state === 'conflict';
118
+ return {
119
+ signedIn,
120
+ supabaseReachable,
121
+ machineId: getMachineIdentity().id,
122
+ managerKind: kind,
123
+ profiles: profiles.filter((profile) => profile.kind === kind),
124
+ otherConnection: {
125
+ active: otherActive,
126
+ switchable: other?.state === 'ready',
127
+ label: kind === 'local' ? 'The installed package' : 'Another CTRL+SPC version',
128
+ },
129
+ };
130
+ }
131
+ function runtimeKind(value) {
132
+ return value === 'local' || value === 'npm' ? value : null;
133
+ }
134
+ async function handleApi(req, res, token, managerKind, path) {
135
+ if ((req.method === 'GET' || req.method === 'OPTIONS') && path === '/api/companion-health') {
136
+ const headers = companionProbeCorsHeaders(req.headers.origin);
137
+ if (req.method === 'OPTIONS') {
138
+ res.writeHead(Object.keys(headers).length ? 204 : 403, headers);
139
+ res.end();
140
+ return;
141
+ }
142
+ json(res, 200, companionHealthPayload(), headers);
143
+ return;
144
+ }
145
+ if (!authorized(req, token)) {
146
+ json(res, 403, { error: 'This connection manager session is no longer valid.' });
147
+ return;
148
+ }
149
+ if (req.method === 'GET' && path === '/api/status') {
150
+ json(res, 200, await statusPayload(managerKind));
151
+ return;
152
+ }
153
+ if (req.method === 'GET' && path === '/api/projects') {
154
+ if (!readSession()) {
155
+ json(res, 401, { error: 'Sign in to view projects on this machine.' });
156
+ return;
157
+ }
158
+ json(res, 200, await loadCompanionProjects());
159
+ return;
160
+ }
161
+ if (req.method === 'POST' && path === '/api/login') {
162
+ await login();
163
+ if (!readSession()) {
164
+ json(res, 400, { error: 'Sign in did not complete.' });
165
+ return;
166
+ }
167
+ process.exitCode = 0;
168
+ json(res, 200, await statusPayload(managerKind));
169
+ return;
170
+ }
171
+ if (req.method === 'POST' && path === '/api/logout') {
172
+ await Promise.allSettled([stopRuntime('local'), stopRuntime('npm')]);
173
+ clearSession();
174
+ json(res, 200, await statusPayload(managerKind));
175
+ return;
176
+ }
177
+ if (req.method === 'POST' && path === '/api/folders/choose') {
178
+ if (!readSession()) {
179
+ json(res, 401, { error: 'Sign in before adding a project.' });
180
+ return;
181
+ }
182
+ const folder = await chooseCompanionFolder();
183
+ json(res, 200, folder ? { cancelled: false, discovery: discoverCompanionProject(folder) } : { cancelled: true });
184
+ return;
185
+ }
186
+ if (req.method === 'POST' && path === '/api/folders/choose-path') {
187
+ if (!readSession()) {
188
+ json(res, 401, { error: 'Sign in before choosing a folder.' });
189
+ return;
190
+ }
191
+ const folder = await chooseCompanionFolder();
192
+ json(res, 200, folder ? { cancelled: false, path: folder } : { cancelled: true });
193
+ return;
194
+ }
195
+ if (req.method === 'POST' && path === '/api/projects/discover') {
196
+ if (!readSession()) {
197
+ json(res, 401, { error: 'Sign in before adding a project.' });
198
+ return;
199
+ }
200
+ const body = await readJsonBody(req);
201
+ const paths = Array.isArray(body.paths) ? body.paths.filter((value) => typeof value === 'string') : [];
202
+ const pathValue = typeof body.path === 'string' ? body.path : '';
203
+ json(res, 200, { discovery: discoverCompanionProject(paths.length ? paths : pathValue) });
204
+ return;
205
+ }
206
+ if (req.method === 'POST' && path === '/api/projects/create') {
207
+ if (!readSession()) {
208
+ json(res, 401, { error: 'Sign in before adding a project.' });
209
+ return;
210
+ }
211
+ const body = await readJsonBody(req);
212
+ const request = {
213
+ path: typeof body.path === 'string' ? body.path : '',
214
+ paths: Array.isArray(body.paths) ? body.paths.filter((value) => typeof value === 'string') : undefined,
215
+ orgId: typeof body.orgId === 'string' ? body.orgId : '',
216
+ name: typeof body.name === 'string' ? body.name : '',
217
+ existingProjectId: typeof body.existingProjectId === 'string' && body.existingProjectId
218
+ ? body.existingProjectId
219
+ : undefined,
220
+ existingProjectMode: body.existingProjectMode === 'attach' || body.existingProjectMode === 'link'
221
+ ? body.existingProjectMode
222
+ : undefined,
223
+ includedTargetIds: Array.isArray(body.includedTargetIds)
224
+ ? body.includedTargetIds.filter((value) => typeof value === 'string')
225
+ : undefined,
226
+ roles: body.roles && typeof body.roles === 'object' && !Array.isArray(body.roles)
227
+ ? body.roles
228
+ : undefined,
229
+ promotionChoice: body.promotionChoice === 'use_online' ? 'use_online' : undefined,
230
+ };
231
+ json(res, 200, await createCompanionProject(request));
232
+ return;
233
+ }
234
+ if (req.method === 'POST' && path === '/api/projects/fetch-missing') {
235
+ if (!readSession()) {
236
+ json(res, 401, { error: 'Sign in before getting missing folders.' });
237
+ return;
238
+ }
239
+ const body = await readJsonBody(req);
240
+ const projectId = typeof body.projectId === 'string' ? body.projectId : '';
241
+ const destination = typeof body.destination === 'string' ? body.destination.trim() : '';
242
+ if (!PROJECT_ID_RE.test(projectId) || !destination) {
243
+ json(res, 400, { error: 'Choose a project and destination folder.' });
244
+ return;
245
+ }
246
+ json(res, 200, await fetchMissingCompanionFolders(projectId, destination));
247
+ return;
248
+ }
249
+ if (req.method === 'POST' && path === '/api/projects/link-auto') {
250
+ if (!readSession()) {
251
+ json(res, 401, { error: 'Sign in before connecting folders.' });
252
+ return;
253
+ }
254
+ const body = await readJsonBody(req);
255
+ const projectId = typeof body.projectId === 'string' ? body.projectId : '';
256
+ if (!PROJECT_ID_RE.test(projectId)) {
257
+ json(res, 400, { error: 'Choose a project.' });
258
+ return;
259
+ }
260
+ json(res, 200, await linkCompanionProject(projectId));
261
+ return;
262
+ }
263
+ if (req.method === 'POST' && path === '/api/projects/link-folder') {
264
+ if (!readSession()) {
265
+ json(res, 401, { error: 'Sign in before connecting a folder.' });
266
+ return;
267
+ }
268
+ const body = await readJsonBody(req);
269
+ const projectId = typeof body.projectId === 'string' ? body.projectId : '';
270
+ const repositoryId = typeof body.repositoryId === 'string' ? body.repositoryId : '';
271
+ const folder = typeof body.path === 'string' ? body.path.trim() : '';
272
+ if (!PROJECT_ID_RE.test(projectId) || !PROJECT_ID_RE.test(repositoryId) || !folder) {
273
+ json(res, 400, { error: 'Choose a project folder.' });
274
+ return;
275
+ }
276
+ json(res, 200, await linkCompanionFolder(projectId, repositoryId, folder));
277
+ return;
278
+ }
279
+ const match = path.match(/^\/api\/runtime\/(local|npm)\/(start|stop)$/);
280
+ if (req.method === 'POST' && match) {
281
+ const kind = runtimeKind(match[1]);
282
+ if (!kind) {
283
+ json(res, 400, { error: 'Unknown connection.' });
284
+ return;
285
+ }
286
+ if (kind !== managerKind) {
287
+ json(res, 403, { error: 'This window cannot control the other CTRL+SPC installation.' });
288
+ return;
289
+ }
290
+ if (match[2] === 'start') {
291
+ if (!readSession()) {
292
+ json(res, 400, { error: 'Sign in before starting a connection.' });
293
+ return;
294
+ }
295
+ await startRuntime(kind);
296
+ }
297
+ else {
298
+ await stopRuntime(kind);
299
+ }
300
+ json(res, 200, await statusPayload(managerKind));
301
+ return;
302
+ }
303
+ json(res, 404, { error: 'Not found.' });
304
+ }
305
+ async function existingCompanion(kind) {
306
+ const state = readState(kind);
307
+ if (!state)
308
+ return null;
309
+ try {
310
+ const response = await fetch(`http://127.0.0.1:${state.port}/api/companion-health`, {
311
+ headers: { 'x-ctrl-spc-token': state.token },
312
+ signal: AbortSignal.timeout(600),
313
+ });
314
+ return response.ok ? state : null;
315
+ }
316
+ catch {
317
+ return null;
318
+ }
319
+ }
320
+ export async function openCompanion() {
321
+ const managerKind = currentCompanionKind();
322
+ let existing = await existingCompanion(managerKind);
323
+ if (existing) {
324
+ const url = `http://127.0.0.1:${existing.port}/?token=${existing.token}`;
325
+ console.log(`Opening CTRL+SPC connection manager — ${url}`);
326
+ openBrowser(url);
327
+ return;
328
+ }
329
+ if (existsSync(statePath(managerKind)))
330
+ rmSync(statePath(managerKind), { force: true });
331
+ const child = spawn(process.execPath, [CLI_ENTRY_PATH, '__companion'], {
332
+ detached: true,
333
+ env: { ...process.env, CTRL_SPC_COMPANION_KIND: managerKind },
334
+ stdio: 'ignore',
335
+ });
336
+ child.on('error', () => { });
337
+ child.unref();
338
+ const deadline = Date.now() + 5_000;
339
+ while (!existing && Date.now() < deadline) {
340
+ await new Promise((resolve) => setTimeout(resolve, 100));
341
+ existing = await existingCompanion(managerKind);
342
+ }
343
+ if (!existing) {
344
+ throw new Error('The CTRL+SPC connection manager did not start. Run `ctrl-spc open` again.');
345
+ }
346
+ const url = `http://127.0.0.1:${existing.port}/?token=${existing.token}`;
347
+ console.log(`Opening CTRL+SPC connection manager — ${url}`);
348
+ openBrowser(url);
349
+ }
350
+ /** Hidden detached process started by `ctrl-spc open`. */
351
+ export async function serveCompanion() {
352
+ const managerKind = currentCompanionKind();
353
+ const port = companionPort(managerKind);
354
+ if (await existingCompanion(managerKind))
355
+ return;
356
+ if (existsSync(statePath(managerKind)))
357
+ rmSync(statePath(managerKind), { force: true });
358
+ const token = randomBytes(24).toString('hex');
359
+ const server = createServer((req, res) => {
360
+ void (async () => {
361
+ try {
362
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
363
+ if (req.method === 'GET' && (url.pathname === '/attach' || url.pathname === '/link')) {
364
+ const location = url.pathname === '/attach'
365
+ ? attachRedirectLocation(url.searchParams.get('project') ?? '', token)
366
+ : linkRedirectLocation(url.searchParams.get('project') ?? '', token);
367
+ if (!location) {
368
+ res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' })
369
+ .end('A valid project id is required.');
370
+ return;
371
+ }
372
+ res.writeHead(302, {
373
+ Location: location,
374
+ 'Cache-Control': 'no-store',
375
+ 'Referrer-Policy': 'no-referrer',
376
+ }).end();
377
+ return;
378
+ }
379
+ if (req.method === 'GET' && url.pathname === '/') {
380
+ if (url.searchParams.get('token') !== token) {
381
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' }).end('Invalid connection manager session.');
382
+ return;
383
+ }
384
+ res.writeHead(200, {
385
+ 'Content-Type': 'text/html; charset=utf-8',
386
+ 'Cache-Control': 'no-store',
387
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'",
388
+ });
389
+ res.end(renderCompanionUi(token, managerKind, managerKind === 'local' ? LOCAL_RUNTIME_PORT : NPM_RUNTIME_PORT));
390
+ return;
391
+ }
392
+ if (req.method === 'GET' && url.pathname === '/favicon.ico') {
393
+ res.writeHead(204).end();
394
+ return;
395
+ }
396
+ await handleApi(req, res, token, managerKind, url.pathname);
397
+ }
398
+ catch (err) {
399
+ json(res, 500, { error: err instanceof Error ? err.message : String(err) });
400
+ }
401
+ })();
402
+ });
403
+ await new Promise((resolve, reject) => {
404
+ server.once('error', reject);
405
+ server.listen(port, '127.0.0.1', resolve);
406
+ });
407
+ writeState(managerKind, { pid: process.pid, port, token });
408
+ await new Promise((resolve) => {
409
+ let closing = false;
410
+ const close = () => {
411
+ if (closing)
412
+ return;
413
+ closing = true;
414
+ server.close(() => {
415
+ rmSync(statePath(managerKind), { force: true });
416
+ resolve();
417
+ });
418
+ };
419
+ process.once('SIGINT', close);
420
+ process.once('SIGTERM', close);
421
+ });
422
+ }
package/dist/dispatch.js CHANGED
@@ -1,19 +1,62 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { mkdtemp, rm, writeFile } from 'node:fs/promises';
2
+ import { statSync } from 'node:fs';
3
+ import { access, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
6
+ import { setTimeout as delay } from 'node:timers/promises';
5
7
  import { resolveAgentCommand } from './agents.js';
8
+ import { resolveProjectRootRows } from './resolver.js';
9
+ export const ROLE_ROUTING_ERROR = 'No connected machine has every folder this task needs.';
10
+ export async function dispatchRolesReady(client, machineId, request, inspectPath = statSync) {
11
+ const slugs = request.role_slugs ?? [];
12
+ if (slugs.length === 0)
13
+ return true;
14
+ try {
15
+ let projectId = request.project_id;
16
+ if (!projectId) {
17
+ const { data, error } = await client.from('tasks').select('project_id').eq('id', request.task_id).single();
18
+ if (error || !data)
19
+ return false;
20
+ projectId = data.project_id;
21
+ }
22
+ const rows = await resolveProjectRootRows(client, projectId, machineId);
23
+ return slugs.every(slug => rows.some(row => {
24
+ if (row.role_slug !== slug || row.status !== 'ready' || !row.local_path)
25
+ return false;
26
+ try {
27
+ inspectPath(join(row.local_path, row.scope_path));
28
+ return true;
29
+ }
30
+ catch {
31
+ return false;
32
+ }
33
+ }));
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
6
39
  function shellQuote(value) {
7
40
  return `'${value.replaceAll("'", "'\\''")}'`;
8
41
  }
9
- export function agentTerminalScript(request, executable) {
10
- const command = agentTerminalCommand(request, executable);
42
+ function agentInvocationCommand(request, executable) {
43
+ const prompt = shellQuote(request.prompt);
44
+ const binary = shellQuote(executable);
45
+ const fresh = `exec ${binary} ${prompt}`;
46
+ if (request.mode === 'start')
47
+ return fresh;
48
+ const resume = request.agent_kind === 'claude'
49
+ ? `${binary} --continue ${prompt}`
50
+ : `${binary} resume --last ${prompt}`;
51
+ return `${resume} || ${fresh}`;
52
+ }
53
+ export function agentTerminalScript(request, executable, markerPath) {
11
54
  return [
12
- '#!/bin/zsh',
13
- 'script_path="$0"',
14
- '/bin/rm -f -- "$script_path"',
15
- '/bin/rmdir -- "${script_path%/*}" 2>/dev/null || true',
16
- `exec /bin/zsh -lc ${shellQuote(command)}`,
55
+ '#!/bin/sh',
56
+ `cd ${shellQuote(request.workspace_root)} || exit 72`,
57
+ `: > ${shellQuote(markerPath)} || exit 73`,
58
+ '/bin/rm -f -- "$0"',
59
+ agentInvocationCommand(request, executable),
17
60
  '',
18
61
  ].join('\n');
19
62
  }
@@ -29,46 +72,77 @@ export function agentTerminalCommand(request, executable) {
29
72
  }
30
73
  /** Opens a visible agent conversation. Resume is best-effort and falls back
31
74
  * to a fresh task conversation in the same project checkout. */
32
- export async function launchAgentDispatch(request) {
33
- if (process.platform !== 'darwin') {
75
+ export async function launchAgentDispatch(request, deps = {}) {
76
+ if ((deps.platform ?? process.platform) !== 'darwin') {
34
77
  throw new Error('Automatic agent launch currently requires macOS Terminal.');
35
78
  }
36
- const executable = resolveAgentCommand(request.agent_kind);
79
+ const executable = (deps.resolveExecutable ?? resolveAgentCommand)(request.agent_kind);
37
80
  if (!executable)
38
81
  throw new Error(`${request.agent_kind} is not installed on this machine.`);
82
+ const timeoutMs = deps.timeoutMs ?? 10_000;
39
83
  const directory = await mkdtemp(join(tmpdir(), 'ctrl-spc-dispatch-'));
40
- const scriptPath = join(directory, `ctrl-spc-${request.id}.command`);
84
+ const scriptPath = join(directory, 'launch.command');
85
+ const markerPath = join(directory, 'launched');
41
86
  try {
42
- await writeFile(scriptPath, agentTerminalScript(request, executable), {
87
+ await writeFile(scriptPath, agentTerminalScript(request, executable, markerPath), {
43
88
  encoding: 'utf8',
44
89
  flag: 'wx',
45
90
  mode: 0o700,
46
91
  });
47
- await new Promise((resolve, reject) => {
48
- const child = spawn('/usr/bin/open', ['-a', 'Terminal', scriptPath], {
49
- stdio: ['ignore', 'ignore', 'pipe'],
50
- });
51
- let stderr = '';
52
- child.stderr.on('data', (chunk) => { stderr += String(chunk); });
53
- child.once('error', reject);
54
- child.once('exit', (code) => {
55
- if (code === 0)
56
- resolve();
57
- else
58
- reject(new Error(stderr.trim() || `Terminal launch exited with code ${code ?? 'unknown'}.`));
59
- });
60
- });
92
+ await (deps.openTerminal ?? openTerminalCommandFile)(scriptPath, timeoutMs);
93
+ await (deps.waitForAcknowledgement ?? waitForLaunchAcknowledgement)(markerPath, timeoutMs);
61
94
  }
62
- catch (error) {
95
+ finally {
63
96
  await rm(directory, { recursive: true, force: true }).catch(() => { });
64
- throw error;
65
97
  }
66
- // The script removes itself after Terminal opens it. This fallback handles
67
- // cases where Terminal accepts the file but never executes it.
68
- const cleanup = setTimeout(() => {
69
- void rm(directory, { recursive: true, force: true });
70
- }, 60_000);
71
- cleanup.unref();
98
+ }
99
+ export function terminalOpenArguments(scriptPath) {
100
+ return ['-b', 'com.apple.Terminal', scriptPath];
101
+ }
102
+ async function openTerminalCommandFile(scriptPath, timeoutMs) {
103
+ await new Promise((resolve, reject) => {
104
+ const child = spawn('/usr/bin/open', terminalOpenArguments(scriptPath), {
105
+ stdio: ['ignore', 'ignore', 'pipe'],
106
+ });
107
+ let stderr = '';
108
+ let settled = false;
109
+ const finish = (error) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ clearTimeout(timer);
114
+ if (error)
115
+ reject(error);
116
+ else
117
+ resolve();
118
+ };
119
+ const timer = setTimeout(() => {
120
+ child.kill('SIGTERM');
121
+ finish(new Error(`Terminal launch timed out after ${timeoutMs}ms.`));
122
+ }, timeoutMs);
123
+ timer.unref();
124
+ child.stderr.on('data', (chunk) => { stderr += String(chunk); });
125
+ child.once('error', (error) => finish(error));
126
+ child.once('exit', (code) => {
127
+ if (code === 0)
128
+ finish();
129
+ else
130
+ finish(new Error(stderr.trim() || `Terminal launch exited with code ${code ?? 'unknown'}.`));
131
+ });
132
+ });
133
+ }
134
+ async function waitForLaunchAcknowledgement(markerPath, timeoutMs) {
135
+ const deadline = Date.now() + timeoutMs;
136
+ while (Date.now() < deadline) {
137
+ try {
138
+ await access(markerPath);
139
+ return;
140
+ }
141
+ catch {
142
+ await delay(50);
143
+ }
144
+ }
145
+ throw new Error(`Terminal accepted the launch but did not start the agent within ${timeoutMs}ms.`);
72
146
  }
73
147
  export function createAgentDispatchController(client, machineId, agents, deps = {}) {
74
148
  const claim = deps.claim ?? (async () => {
@@ -90,6 +164,7 @@ export function createAgentDispatchController(client, machineId, agents, deps =
90
164
  throw error;
91
165
  });
92
166
  const launch = deps.launch ?? launchAgentDispatch;
167
+ const verifyRoles = deps.verifyRoles ?? ((request) => dispatchRolesReady(client, machineId, request));
93
168
  let stopped = false;
94
169
  let polling = false;
95
170
  let timer;
@@ -102,6 +177,10 @@ export function createAgentDispatchController(client, machineId, agents, deps =
102
177
  if (!request)
103
178
  return;
104
179
  try {
180
+ if (!(await verifyRoles(request))) {
181
+ await complete(request.id, 'failed', ROLE_ROUTING_ERROR);
182
+ return;
183
+ }
105
184
  await launch(request);
106
185
  await complete(request.id, 'launched');
107
186
  }