agent-companion 0.1.0

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,2812 @@
1
+ import cors from "cors";
2
+ import express from "express";
3
+ import { execSync, spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import os from "node:os";
6
+ import path from "node:path";
7
+ import { buildDefaultState, sanitizeState } from "./defaultState.mjs";
8
+ import { collectDirectSnapshot } from "./directIngest.mjs";
9
+
10
+ const PORT = Number(process.env.AGENT_BRIDGE_PORT || 8787);
11
+ const STATE_FILE = path.resolve(process.cwd(), "bridge", "state.json");
12
+ const BRIDGE_URL = `http://localhost:${PORT}`;
13
+ const BRIDGE_TOKEN = String(process.env.AGENT_BRIDGE_TOKEN || "").trim();
14
+ const ALLOW_ANY_WORKSPACE =
15
+ String(process.env.AGENT_BRIDGE_ALLOW_ANY_WORKSPACE || "true").trim().toLowerCase() !== "false";
16
+ const WORKSPACE_ROOTS = resolveWorkspaceRoots();
17
+ const DEFAULT_WORKSPACE_ROOT = resolveDefaultWorkspaceRoot();
18
+ const WORKSPACE_MARKER_FILES = [".git", ".vscode", "package.json", "pyproject.toml", "go.mod", "Cargo.toml"];
19
+ const MAX_LAUNCHER_RUN_OUTPUT_LINES = 80;
20
+ const MAX_LAUNCHER_RUNS = 150;
21
+ const MAX_CHAT_TURNS = 4000;
22
+ const MAX_TRANSCRIPT_SEGMENTS_PER_RUN = 220;
23
+ const RUN_OUTPUT_TOUCH_INTERVAL_MS = 1200;
24
+ const DIRECT_PENDING_STALE_MS = 90_000;
25
+ const DIRECT_EVENT_STALE_MS = 2 * 60 * 60 * 1000;
26
+ const DIRECT_SNAPSHOT_POLL_INTERVAL_MS = 2_500;
27
+ const MAX_MANAGED_SERVICES = 120;
28
+ const MAX_MANAGED_SERVICE_OUTPUT_LINES = 180;
29
+
30
+ const app = express();
31
+ app.use(cors());
32
+ app.use(express.json({ limit: "1mb" }));
33
+
34
+ let state = loadState();
35
+ const launcherRuns = new Map();
36
+ const managedServices = new Map();
37
+
38
+ function loadState() {
39
+ try {
40
+ if (!fs.existsSync(STATE_FILE)) {
41
+ const initial = buildDefaultState();
42
+ persistState(initial);
43
+ return initial;
44
+ }
45
+
46
+ const raw = fs.readFileSync(STATE_FILE, "utf8");
47
+ return sanitizeState(JSON.parse(raw));
48
+ } catch {
49
+ const fallback = buildDefaultState();
50
+ persistState(fallback);
51
+ return fallback;
52
+ }
53
+ }
54
+
55
+ function persistState(nextState) {
56
+ fs.mkdirSync(path.dirname(STATE_FILE), { recursive: true });
57
+ fs.writeFileSync(STATE_FILE, JSON.stringify(nextState, null, 2));
58
+ }
59
+
60
+ function withPersist(mutator) {
61
+ mutator();
62
+ state = {
63
+ ...state,
64
+ updatedAt: Date.now(),
65
+ sessions: state.sessions
66
+ .slice()
67
+ .sort((a, b) => b.lastUpdated - a.lastUpdated),
68
+ sessionThreads: (Array.isArray(state.sessionThreads) ? state.sessionThreads : [])
69
+ .slice()
70
+ .sort((a, b) => safeNumber(b.updatedAt, 0) - safeNumber(a.updatedAt, 0)),
71
+ chatTurns: (Array.isArray(state.chatTurns) ? state.chatTurns : [])
72
+ .slice()
73
+ .sort((a, b) => safeNumber(a.createdAt, 0) - safeNumber(b.createdAt, 0))
74
+ .slice(-MAX_CHAT_TURNS),
75
+ events: state.events
76
+ .slice()
77
+ .sort((a, b) => b.timestamp - a.timestamp)
78
+ .slice(0, 500)
79
+ };
80
+ persistState(state);
81
+ }
82
+
83
+ function safeNumber(value, fallback = 0) {
84
+ const parsed = Number(value);
85
+ return Number.isFinite(parsed) ? parsed : fallback;
86
+ }
87
+
88
+ function findSession(sessionId) {
89
+ return state.sessions.find((item) => item.id === sessionId) || null;
90
+ }
91
+
92
+ function upsertSession(input) {
93
+ const now = Date.now();
94
+ const existing = state.sessions.find((item) => item.id === input.id);
95
+
96
+ const previousTokens = existing?.tokenUsage ?? {
97
+ promptTokens: 0,
98
+ completionTokens: 0,
99
+ totalTokens: 0,
100
+ costUsd: 0
101
+ };
102
+
103
+ const incomingTokens = {
104
+ promptTokens: safeNumber(input?.tokenUsage?.promptTokens, previousTokens.promptTokens),
105
+ completionTokens: safeNumber(input?.tokenUsage?.completionTokens, previousTokens.completionTokens),
106
+ totalTokens: safeNumber(input?.tokenUsage?.totalTokens, previousTokens.totalTokens),
107
+ costUsd: safeNumber(input?.tokenUsage?.costUsd, previousTokens.costUsd)
108
+ };
109
+
110
+ if (!input?.tokenUsage?.totalTokens) {
111
+ incomingTokens.totalTokens = incomingTokens.promptTokens + incomingTokens.completionTokens;
112
+ }
113
+
114
+ const next = {
115
+ id: input.id,
116
+ agentType: input.agentType === "CLAUDE" ? "CLAUDE" : "CODEX",
117
+ title: input.title || existing?.title || "Untitled session",
118
+ repo: input.repo || existing?.repo || "unknown-repo",
119
+ branch: input.branch || existing?.branch || "main",
120
+ state: normalizeState(input.state || existing?.state || "RUNNING"),
121
+ lastUpdated: safeNumber(input.lastUpdated, now),
122
+ progress: Math.max(0, Math.min(100, safeNumber(input.progress, existing?.progress ?? 0))),
123
+ tokenUsage: incomingTokens
124
+ };
125
+
126
+ if (!existing) {
127
+ state.sessions.push(next);
128
+ syncSessionThreadFromSession(next, {
129
+ workspacePath: input?.workspacePath,
130
+ threadLookupKey: input?.threadLookupKey,
131
+ updatedAt: next.lastUpdated,
132
+ codexThreadId: input?.codexThreadId,
133
+ claudeSessionId: input?.claudeSessionId
134
+ });
135
+ return next;
136
+ }
137
+
138
+ Object.assign(existing, next);
139
+ syncSessionThreadFromSession(existing, {
140
+ workspacePath: input?.workspacePath,
141
+ threadLookupKey: input?.threadLookupKey,
142
+ updatedAt: existing.lastUpdated,
143
+ codexThreadId: input?.codexThreadId,
144
+ claudeSessionId: input?.claudeSessionId
145
+ });
146
+ return existing;
147
+ }
148
+
149
+ function normalizeState(value) {
150
+ const allowed = ["RUNNING", "WAITING_INPUT", "COMPLETED", "FAILED", "CANCELLED"];
151
+ return allowed.includes(value) ? value : "RUNNING";
152
+ }
153
+
154
+ function normalizeCategory(value) {
155
+ const allowed = ["INFO", "ACTION", "INPUT", "ERROR"];
156
+ return allowed.includes(value) ? value : "INFO";
157
+ }
158
+
159
+ function addEvent(input) {
160
+ if (!input?.sessionId) return null;
161
+
162
+ const event = {
163
+ id: input.id || `e_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
164
+ sessionId: input.sessionId,
165
+ summary: input.summary || "Event",
166
+ timestamp: safeNumber(input.timestamp, Date.now()),
167
+ category: normalizeCategory(input.category)
168
+ };
169
+
170
+ state.events.unshift(event);
171
+ return event;
172
+ }
173
+
174
+ function addPendingInput(input) {
175
+ if (!input?.sessionId || !input?.prompt) return null;
176
+
177
+ const sessionId = String(input.sessionId).trim();
178
+ const defaultActionable = !sessionId.startsWith("codex:") && !sessionId.startsWith("claude:");
179
+ const pending = {
180
+ id: input.id || `p_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
181
+ sessionId,
182
+ prompt: input.prompt,
183
+ requestedAt: safeNumber(input.requestedAt, Date.now()),
184
+ priority: normalizePriority(input.priority),
185
+ actionable: safeBoolean(input.actionable, defaultActionable),
186
+ source: safeTrimmedText(input.source, 32) || (defaultActionable ? "BRIDGE" : "DIRECT"),
187
+ meta: input.meta && typeof input.meta === "object" ? input.meta : null
188
+ };
189
+
190
+ const existing = state.pendingInputs.find((item) => item.id === pending.id);
191
+ if (!existing) {
192
+ state.pendingInputs.unshift(pending);
193
+ appendChatTurn({
194
+ sessionId,
195
+ role: "ASSISTANT",
196
+ kind: "MESSAGE",
197
+ text: pending.prompt,
198
+ createdAt: pending.requestedAt,
199
+ approvalId: pending.id,
200
+ source: "PENDING"
201
+ });
202
+ if (state.pendingHandledAt && typeof state.pendingHandledAt === "object") {
203
+ delete state.pendingHandledAt[pending.id];
204
+ }
205
+ }
206
+
207
+ const session = findSession(input.sessionId);
208
+ if (session) {
209
+ session.state = "WAITING_INPUT";
210
+ session.lastUpdated = Date.now();
211
+ syncSessionThreadFromSession(session, {
212
+ updatedAt: session.lastUpdated,
213
+ lastMessageAt: pending.requestedAt
214
+ });
215
+ } else {
216
+ upsertSessionThread({
217
+ id: sessionId,
218
+ agentType: "CODEX",
219
+ title: "Untitled session",
220
+ repo: "unknown-repo",
221
+ branch: "main",
222
+ updatedAt: pending.requestedAt,
223
+ lastMessageAt: pending.requestedAt
224
+ });
225
+ }
226
+
227
+ return pending;
228
+ }
229
+
230
+ function normalizePriority(value) {
231
+ if (value === "HIGH" || value === "MEDIUM" || value === "LOW") {
232
+ return value;
233
+ }
234
+ return "MEDIUM";
235
+ }
236
+
237
+ function requireBridgeToken(req, res, next) {
238
+ if (!BRIDGE_TOKEN) return next();
239
+
240
+ const candidate =
241
+ req.header("x-bridge-token") ||
242
+ req.query?.token ||
243
+ req.body?.token ||
244
+ "";
245
+
246
+ if (String(candidate).trim() === BRIDGE_TOKEN) {
247
+ return next();
248
+ }
249
+
250
+ return res.status(401).json({
251
+ ok: false,
252
+ error: "bridge token required",
253
+ hint: "Provide x-bridge-token header"
254
+ });
255
+ }
256
+
257
+ function resolveWorkspaceRoots() {
258
+ const configured = String(process.env.AGENT_BRIDGE_WORKSPACE_ROOTS || "")
259
+ .split(",")
260
+ .map((item) => item.trim())
261
+ .filter(Boolean);
262
+
263
+ const fallbackRoots = [
264
+ process.cwd(),
265
+ path.join(os.homedir(), "Desktop"),
266
+ path.join(os.homedir(), "Documents"),
267
+ path.join(os.homedir(), "Projects")
268
+ ];
269
+
270
+ const roots = configured.length > 0 ? configured : fallbackRoots;
271
+ const deduped = new Set();
272
+ const resolved = [];
273
+
274
+ for (const candidate of roots) {
275
+ try {
276
+ const absolute = path.resolve(candidate);
277
+ const real = fs.realpathSync(absolute);
278
+ const stat = fs.statSync(real);
279
+ if (!stat.isDirectory()) continue;
280
+ if (deduped.has(real)) continue;
281
+ deduped.add(real);
282
+ resolved.push(real);
283
+ } catch {
284
+ continue;
285
+ }
286
+ }
287
+
288
+ return resolved;
289
+ }
290
+
291
+ function resolveDefaultWorkspaceRoot() {
292
+ const preferred = [
293
+ path.join(os.homedir(), "Documents"),
294
+ path.join(os.homedir(), "Desktop"),
295
+ WORKSPACE_ROOTS[0] || "",
296
+ process.cwd()
297
+ ].filter(Boolean);
298
+
299
+ for (const candidate of preferred) {
300
+ const normalized = normalizeExistingDirectoryPath(candidate);
301
+ if (normalized) return normalized;
302
+ }
303
+
304
+ return process.cwd();
305
+ }
306
+
307
+ function normalizeExistingDirectoryPath(inputPath) {
308
+ if (typeof inputPath !== "string" || !inputPath.trim()) return "";
309
+ try {
310
+ const absolute = path.resolve(inputPath.trim());
311
+ const real = fs.realpathSync(absolute);
312
+ const stat = fs.statSync(real);
313
+ if (!stat.isDirectory()) return "";
314
+ return real;
315
+ } catch {
316
+ return "";
317
+ }
318
+ }
319
+
320
+ function getWorkspaceRootSetting() {
321
+ const fromSettings = normalizeExistingDirectoryPath(safeTrimmedText(state?.settings?.workspaceRoot, 1000));
322
+ return fromSettings || DEFAULT_WORKSPACE_ROOT;
323
+ }
324
+
325
+ function isPathInside(root, target) {
326
+ if (root === target) return true;
327
+ return target.startsWith(`${root}${path.sep}`);
328
+ }
329
+
330
+ function normalizeWorkspacePath(inputPath) {
331
+ const normalized = normalizeExistingDirectoryPath(inputPath);
332
+ return normalized || null;
333
+ }
334
+
335
+ function isWorkspaceAllowed(workspacePath) {
336
+ if (ALLOW_ANY_WORKSPACE) return true;
337
+ return WORKSPACE_ROOTS.some((root) => isPathInside(root, workspacePath));
338
+ }
339
+
340
+ function safeInt(value, fallback = 0) {
341
+ const parsed = Number.parseInt(String(value), 10);
342
+ return Number.isFinite(parsed) ? parsed : fallback;
343
+ }
344
+
345
+ function safeBoolean(value, fallback = false) {
346
+ if (typeof value === "boolean") return value;
347
+ if (typeof value !== "string" && typeof value !== "number") return fallback;
348
+ const normalized = String(value).trim().toLowerCase();
349
+ if (!normalized) return fallback;
350
+ if (normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on") {
351
+ return true;
352
+ }
353
+ if (normalized === "0" || normalized === "false" || normalized === "no" || normalized === "off") {
354
+ return false;
355
+ }
356
+ return fallback;
357
+ }
358
+
359
+ function safeTrimmedText(value, maxLength) {
360
+ if (typeof value !== "string") return "";
361
+ const trimmed = value.trim();
362
+ if (!trimmed) return "";
363
+ return trimmed.slice(0, maxLength);
364
+ }
365
+
366
+ function normalizeLookupText(value) {
367
+ if (typeof value !== "string") return "";
368
+ return value
369
+ .trim()
370
+ .toLowerCase()
371
+ .replace(/\s+/g, " ");
372
+ }
373
+
374
+ function normalizeThreadTitle(value) {
375
+ const normalized = normalizeLookupText(value)
376
+ .replace(/[^a-z0-9 ]+/g, " ")
377
+ .replace(/\s+/g, " ")
378
+ .trim();
379
+ return normalized || "untitled";
380
+ }
381
+
382
+ function normalizeWorkspaceLookup(workspacePath, repo) {
383
+ const normalizedPath = safeTrimmedText(workspacePath, 500);
384
+ if (normalizedPath) {
385
+ return normalizedPath.toLowerCase();
386
+ }
387
+ const normalizedRepo = normalizeLookupText(repo);
388
+ return normalizedRepo ? `repo:${normalizedRepo}` : "repo:unknown";
389
+ }
390
+
391
+ function buildSessionThreadLookupKey(input) {
392
+ const agentType = normalizeAgentType(input?.agentType);
393
+ const workspaceKey = normalizeWorkspaceLookup(input?.workspacePath, input?.repo);
394
+ const titleKey = normalizeThreadTitle(input?.title);
395
+ return `${agentType}|${workspaceKey}|${titleKey}`;
396
+ }
397
+
398
+ function findSessionThread(sessionId) {
399
+ const id = safeTrimmedText(sessionId, 160);
400
+ if (!id) return null;
401
+ return (Array.isArray(state.sessionThreads) ? state.sessionThreads : []).find((item) => item.id === id) || null;
402
+ }
403
+
404
+ function findLatestSessionThreadByLookupKey(lookupKey) {
405
+ const target = safeTrimmedText(lookupKey, 640);
406
+ if (!target) return null;
407
+
408
+ return (Array.isArray(state.sessionThreads) ? state.sessionThreads : [])
409
+ .slice()
410
+ .sort((a, b) => safeNumber(b.updatedAt, 0) - safeNumber(a.updatedAt, 0))
411
+ .find((thread) => safeTrimmedText(thread.lookupKey || thread.key, 640) === target) || null;
412
+ }
413
+
414
+ function upsertSessionThread(input) {
415
+ const now = Date.now();
416
+ const id = safeTrimmedText(input?.id, 160);
417
+ if (!id) return null;
418
+
419
+ if (!Array.isArray(state.sessionThreads)) {
420
+ state.sessionThreads = [];
421
+ }
422
+
423
+ const existing = findSessionThread(id);
424
+ const agentType = normalizeAgentType(input?.agentType || existing?.agentType);
425
+ const title = safeTrimmedText(input?.title, 140) || safeTrimmedText(existing?.title, 140) || "Untitled session";
426
+ const repo = safeTrimmedText(input?.repo, 120) || safeTrimmedText(existing?.repo, 120) || "unknown-repo";
427
+ const branch = safeTrimmedText(input?.branch, 120) || safeTrimmedText(existing?.branch, 120) || "main";
428
+ const workspacePath =
429
+ safeTrimmedText(input?.workspacePath, 500) || safeTrimmedText(existing?.workspacePath, 500) || "";
430
+ const normalizedTitle = normalizeThreadTitle(input?.normalizedTitle || title);
431
+ const lookupKey =
432
+ safeTrimmedText(input?.lookupKey || input?.key, 640) ||
433
+ safeTrimmedText(existing?.lookupKey || existing?.key, 640) ||
434
+ buildSessionThreadLookupKey({
435
+ agentType,
436
+ workspacePath,
437
+ repo,
438
+ title: normalizedTitle
439
+ });
440
+ const runCount = Math.max(
441
+ 0,
442
+ safeInt(input?.runCount, safeInt(existing?.runCount, 0))
443
+ );
444
+
445
+ const next = {
446
+ id,
447
+ key: lookupKey,
448
+ lookupKey,
449
+ agentType,
450
+ workspacePath,
451
+ repo,
452
+ branch,
453
+ title,
454
+ normalizedTitle,
455
+ createdAt: safeNumber(input?.createdAt, safeNumber(existing?.createdAt, now)),
456
+ updatedAt: safeNumber(input?.updatedAt, now),
457
+ lastRunId: safeTrimmedText(input?.lastRunId, 160) || safeTrimmedText(existing?.lastRunId, 160) || null,
458
+ runCount,
459
+ lastMessageAt: safeNumber(input?.lastMessageAt, safeNumber(existing?.lastMessageAt, 0)),
460
+ codexThreadId:
461
+ safeTrimmedText(input?.codexThreadId, 120) ||
462
+ safeTrimmedText(existing?.codexThreadId, 120) ||
463
+ null,
464
+ claudeSessionId:
465
+ safeTrimmedText(input?.claudeSessionId, 120).toLowerCase() ||
466
+ safeTrimmedText(existing?.claudeSessionId, 120).toLowerCase() ||
467
+ null
468
+ };
469
+
470
+ if (!existing) {
471
+ state.sessionThreads.push(next);
472
+ return next;
473
+ }
474
+
475
+ Object.assign(existing, next);
476
+ return existing;
477
+ }
478
+
479
+ function syncSessionThreadFromSession(session, input = {}) {
480
+ if (!session?.id) return null;
481
+ const existingThread = findSessionThread(session.id);
482
+ const fallbackLookupKey = buildSessionThreadLookupKey({
483
+ agentType: session.agentType,
484
+ workspacePath: input.workspacePath || existingThread?.workspacePath || "",
485
+ repo: session.repo,
486
+ title: session.title
487
+ });
488
+
489
+ return upsertSessionThread({
490
+ id: session.id,
491
+ agentType: session.agentType,
492
+ workspacePath: safeTrimmedText(input.workspacePath, 500) || existingThread?.workspacePath || "",
493
+ repo: session.repo,
494
+ branch: session.branch,
495
+ title: session.title,
496
+ normalizedTitle: normalizeThreadTitle(session.title),
497
+ key: safeTrimmedText(input.threadLookupKey, 640) || existingThread?.key || fallbackLookupKey,
498
+ lookupKey: safeTrimmedText(input.threadLookupKey, 640) || existingThread?.lookupKey || fallbackLookupKey,
499
+ createdAt: safeNumber(input.createdAt, safeNumber(existingThread?.createdAt, session.lastUpdated)),
500
+ updatedAt: safeNumber(input.updatedAt, safeNumber(session.lastUpdated, Date.now())),
501
+ lastRunId: safeTrimmedText(input.lastRunId, 160) || existingThread?.lastRunId || null,
502
+ runCount: safeInt(input.runCount, safeInt(existingThread?.runCount, 0)),
503
+ lastMessageAt: safeNumber(
504
+ input.lastMessageAt,
505
+ safeNumber(existingThread?.lastMessageAt, safeNumber(session.lastUpdated, 0))
506
+ ),
507
+ codexThreadId: safeTrimmedText(input.codexThreadId, 120) || existingThread?.codexThreadId || null,
508
+ claudeSessionId:
509
+ safeTrimmedText(input.claudeSessionId, 120).toLowerCase() ||
510
+ safeTrimmedText(existingThread?.claudeSessionId, 120).toLowerCase() ||
511
+ null
512
+ });
513
+ }
514
+
515
+ function sanitizeTurnText(value, maxLength = 12000) {
516
+ const text = String(value ?? "")
517
+ .replace(/\u0000/g, "")
518
+ .replace(/\r\n/g, "\n")
519
+ .trim();
520
+ if (!text) return "";
521
+ return text.slice(0, maxLength);
522
+ }
523
+
524
+ function appendChatTurn(input) {
525
+ const sessionId = safeTrimmedText(input?.sessionId, 160);
526
+ const text = sanitizeTurnText(input?.text);
527
+ if (!sessionId || !text) return null;
528
+
529
+ if (!Array.isArray(state.chatTurns)) {
530
+ state.chatTurns = [];
531
+ }
532
+
533
+ const role = input?.role === "ASSISTANT" ? "ASSISTANT" : "USER";
534
+ const allowedKinds = new Set(["MESSAGE", "FINAL_OUTPUT", "APPROVAL_ACTION"]);
535
+ const kind = allowedKinds.has(input?.kind) ? input.kind : "MESSAGE";
536
+ const turn = {
537
+ id: safeTrimmedText(input?.id, 180) || `turn_${Date.now()}_${Math.floor(Math.random() * 1000)}`,
538
+ sessionId,
539
+ role,
540
+ kind,
541
+ text,
542
+ createdAt: safeNumber(input?.createdAt, Date.now()),
543
+ runId: safeTrimmedText(input?.runId, 160) || null,
544
+ approvalId: safeTrimmedText(input?.approvalId, 160) || null,
545
+ source: safeTrimmedText(input?.source, 48) || "BRIDGE"
546
+ };
547
+
548
+ const duplicate = state.chatTurns.find((item) => item.id === turn.id);
549
+ if (duplicate) {
550
+ return duplicate;
551
+ }
552
+
553
+ state.chatTurns.push(turn);
554
+ return turn;
555
+ }
556
+
557
+ function listChatTurnsForSession(sessionId) {
558
+ const id = safeTrimmedText(sessionId, 160);
559
+ if (!id) return [];
560
+
561
+ return (Array.isArray(state.chatTurns) ? state.chatTurns : [])
562
+ .filter((turn) => turn.sessionId === id)
563
+ .slice()
564
+ .sort((a, b) => safeNumber(a.createdAt, 0) - safeNumber(b.createdAt, 0));
565
+ }
566
+
567
+ function findLastTurnForSession(sessionId) {
568
+ const turns = listChatTurnsForSession(sessionId);
569
+ return turns.length > 0 ? turns[turns.length - 1] : null;
570
+ }
571
+
572
+ function findMatchingSessionId(agentType, workspacePath, repo, title) {
573
+ const lookupKey = buildSessionThreadLookupKey({ agentType, workspacePath, repo, title });
574
+ const matchedThread = findLatestSessionThreadByLookupKey(lookupKey);
575
+ if (
576
+ matchedThread?.id &&
577
+ (matchedThread.codexThreadId || matchedThread.claudeSessionId) &&
578
+ !String(matchedThread.id).startsWith("codex:") &&
579
+ !String(matchedThread.id).startsWith("claude:")
580
+ ) {
581
+ const matchedSession = findSession(matchedThread.id);
582
+ if (matchedSession && (matchedSession.state === "RUNNING" || matchedSession.state === "WAITING_INPUT")) {
583
+ return matchedThread.id;
584
+ }
585
+ }
586
+
587
+ return "";
588
+ }
589
+
590
+ function findActiveRunForSession(sessionId) {
591
+ return [...launcherRuns.values()]
592
+ .sort((a, b) => b.createdAt - a.createdAt)
593
+ .find((run) => run.sessionId === sessionId && (run.status === "RUNNING" || run.status === "STARTING")) || null;
594
+ }
595
+
596
+ function findLatestRunForSession(sessionId) {
597
+ return [...launcherRuns.values()]
598
+ .sort((a, b) => b.createdAt - a.createdAt)
599
+ .find((run) => run.sessionId === sessionId) || null;
600
+ }
601
+
602
+ function extractCodexThreadIdFromRun(run) {
603
+ const direct = safeTrimmedText(run?.codexThreadId, 120);
604
+ if (direct) return direct.toLowerCase();
605
+
606
+ const resume = safeTrimmedText(run?.resumeCommand, 400);
607
+ if (resume) {
608
+ const match = resume.match(/codex(?:\s+exec)?\s+resume\s+([0-9a-f-]+)/i);
609
+ if (match?.[1]) return match[1].toLowerCase();
610
+ }
611
+
612
+ return "";
613
+ }
614
+
615
+ function extractClaudeSessionIdFromRun(run) {
616
+ const direct = safeTrimmedText(run?.claudeSessionId, 120).toLowerCase();
617
+ if (direct) return direct;
618
+
619
+ const resume = safeTrimmedText(run?.resumeCommand, 400);
620
+ if (resume) {
621
+ const match =
622
+ resume.match(/claude(?:\s+code)?\s+--resume\s+([0-9a-f-]{36})/i) ||
623
+ resume.match(/claude(?:\s+code)?\s+-r\s+([0-9a-f-]{36})/i);
624
+ if (match?.[1]) return match[1].toLowerCase();
625
+ }
626
+
627
+ return "";
628
+ }
629
+
630
+ function buildContinueCommandFromThread(thread, prompt) {
631
+ const safePrompt = safeTrimmedText(prompt, 1500);
632
+ if (!safePrompt || !thread) return null;
633
+
634
+ if (thread.agentType === "CODEX") {
635
+ const threadId = safeTrimmedText(thread.codexThreadId, 120).toLowerCase();
636
+ if (!threadId) return null;
637
+ return ["codex", "exec", "resume", threadId, safePrompt];
638
+ }
639
+
640
+ const claudeSessionId = safeTrimmedText(thread.claudeSessionId, 120).toLowerCase();
641
+ if (!claudeSessionId) return null;
642
+ return ["claude", "--resume", claudeSessionId, "-p", safePrompt, "--output-format", "stream-json"];
643
+ }
644
+
645
+ function resolveContinueCommandForSession(sessionId, prompt) {
646
+ const latestRun = findLatestRunForSession(sessionId);
647
+ const fromRun = buildContinueCommandFromRun(latestRun, prompt);
648
+ if (fromRun) return fromRun;
649
+
650
+ const thread = findSessionThread(sessionId);
651
+ const fromThread = buildContinueCommandFromThread(thread, prompt);
652
+ if (fromThread) return fromThread;
653
+
654
+ return null;
655
+ }
656
+
657
+ function validateSessionReuse(input) {
658
+ const sessionId = safeTrimmedText(input?.sessionId, 160);
659
+ if (!sessionId) {
660
+ return { ok: true, sessionId: "", continueCommand: null };
661
+ }
662
+
663
+ if (sessionId.startsWith("codex:") || sessionId.startsWith("claude:")) {
664
+ return { ok: false, error: "direct local sessions cannot be reused from launcher" };
665
+ }
666
+
667
+ const session = findSession(sessionId);
668
+ const thread = findSessionThread(sessionId);
669
+ if (!session && !thread) {
670
+ return { ok: false, error: "session to continue was not found" };
671
+ }
672
+
673
+ const agentType = normalizeAgentType(input?.agentType);
674
+ const existingAgentType = normalizeAgentType(session?.agentType || thread?.agentType);
675
+ if (existingAgentType !== agentType) {
676
+ return { ok: false, error: "session agent type does not match requested agent" };
677
+ }
678
+
679
+ const requestedWorkspacePath = normalizeWorkspacePath(input?.workspacePath);
680
+ const knownWorkspacePath = normalizeWorkspacePath(thread?.workspacePath || "");
681
+ if (knownWorkspacePath && requestedWorkspacePath && knownWorkspacePath !== requestedWorkspacePath) {
682
+ return { ok: false, error: "session workspace does not match requested workspace" };
683
+ }
684
+
685
+ const continueCommand = resolveContinueCommandForSession(sessionId, input?.prompt);
686
+ if (!continueCommand) {
687
+ return { ok: false, error: "session is not ready to resume yet" };
688
+ }
689
+
690
+ return { ok: true, sessionId, continueCommand };
691
+ }
692
+
693
+ function buildContinueCommandFromRun(run, prompt) {
694
+ const safePrompt = safeTrimmedText(prompt, 1500);
695
+ if (!safePrompt || !run) return null;
696
+
697
+ if (run.agentType === "CODEX") {
698
+ const threadId = extractCodexThreadIdFromRun(run);
699
+ if (!threadId) return null;
700
+ return ["codex", "exec", "resume", threadId, safePrompt];
701
+ }
702
+
703
+ const claudeSessionId = extractClaudeSessionIdFromRun(run);
704
+ if (!claudeSessionId) return null;
705
+ return ["claude", "--resume", claudeSessionId, "-p", safePrompt, "--output-format", "stream-json"];
706
+ }
707
+
708
+ function writeToRunStdin(run, message) {
709
+ const target = run;
710
+ if (!target || target.agentType !== "CODEX") {
711
+ return false;
712
+ }
713
+ if (
714
+ !target?.child ||
715
+ !target.child.stdin ||
716
+ target.child.stdin.destroyed ||
717
+ !target.child.stdin.writable
718
+ ) {
719
+ return false;
720
+ }
721
+
722
+ try {
723
+ target.child.stdin.write(message);
724
+ return true;
725
+ } catch {
726
+ return false;
727
+ }
728
+ }
729
+
730
+ function buildEphemeralSessionThread(session) {
731
+ if (!session?.id) return null;
732
+ const lookupKey = buildSessionThreadLookupKey({
733
+ agentType: session.agentType,
734
+ workspacePath: "",
735
+ repo: session.repo,
736
+ title: session.title
737
+ });
738
+ return {
739
+ id: session.id,
740
+ key: lookupKey,
741
+ lookupKey,
742
+ agentType: session.agentType,
743
+ workspacePath: "",
744
+ repo: session.repo,
745
+ branch: session.branch,
746
+ title: session.title,
747
+ normalizedTitle: normalizeThreadTitle(session.title),
748
+ createdAt: safeNumber(session.lastUpdated, Date.now()),
749
+ updatedAt: safeNumber(session.lastUpdated, Date.now()),
750
+ lastRunId: null,
751
+ runCount: 0,
752
+ lastMessageAt: safeNumber(session.lastUpdated, 0)
753
+ };
754
+ }
755
+
756
+ function getSessionThreadSummary(sessionId) {
757
+ const id = safeTrimmedText(sessionId, 160);
758
+ if (!id) return null;
759
+
760
+ const session = findSession(id);
761
+ const thread = findSessionThread(id);
762
+ if (!session && !thread) return null;
763
+ const turns = listChatTurnsForSession(id);
764
+ const lastTurn = turns.length > 0 ? turns[turns.length - 1] : null;
765
+ const pendingApprovals = state.pendingInputs.filter((item) => item.sessionId === id);
766
+ const latestRun = [...launcherRuns.values()]
767
+ .filter((run) => run.sessionId === id)
768
+ .sort((a, b) => b.createdAt - a.createdAt)[0];
769
+
770
+ return {
771
+ id,
772
+ thread: thread || buildEphemeralSessionThread(session),
773
+ session: session || null,
774
+ turnCount: turns.length,
775
+ lastTurn,
776
+ pendingApprovals,
777
+ latestRun: latestRun ? serializeRun(latestRun) : null
778
+ };
779
+ }
780
+
781
+ function listSessionThreadSummaries() {
782
+ const threadIds = new Set((Array.isArray(state.sessionThreads) ? state.sessionThreads : []).map((item) => item.id));
783
+ for (const session of state.sessions) {
784
+ threadIds.add(session.id);
785
+ }
786
+
787
+ return [...threadIds]
788
+ .map((id) => getSessionThreadSummary(id))
789
+ .filter(Boolean)
790
+ .sort(
791
+ (a, b) =>
792
+ safeNumber(b.thread?.updatedAt, safeNumber(b.session?.lastUpdated, 0)) -
793
+ safeNumber(a.thread?.updatedAt, safeNumber(a.session?.lastUpdated, 0))
794
+ );
795
+ }
796
+
797
+ function listWorkspaceCandidates(limit = 100) {
798
+ const candidates = new Map();
799
+ const preferredRoot = getWorkspaceRootSetting();
800
+ const scanRoots = [...WORKSPACE_ROOTS];
801
+ if (preferredRoot && !scanRoots.includes(preferredRoot)) {
802
+ scanRoots.unshift(preferredRoot);
803
+ }
804
+
805
+ for (const root of scanRoots) {
806
+ const forceChildren = root === preferredRoot;
807
+ collectWorkspaceCandidate(root, forceChildren);
808
+
809
+ let entries = [];
810
+ try {
811
+ entries = fs.readdirSync(root, { withFileTypes: true });
812
+ } catch {
813
+ continue;
814
+ }
815
+
816
+ for (const entry of entries) {
817
+ if (!entry.isDirectory()) continue;
818
+ if (entry.name.startsWith(".") && entry.name !== ".vscode") continue;
819
+ collectWorkspaceCandidate(path.join(root, entry.name), forceChildren);
820
+ if (candidates.size >= limit) break;
821
+ }
822
+ }
823
+
824
+ for (const run of launcherRuns.values()) {
825
+ if (run.workspacePath) {
826
+ collectWorkspaceCandidate(run.workspacePath, true);
827
+ }
828
+ }
829
+
830
+ return [...candidates.values()]
831
+ .sort((a, b) => b.lastModified - a.lastModified)
832
+ .slice(0, limit);
833
+
834
+ function collectWorkspaceCandidate(dirPath, force = false) {
835
+ if (candidates.has(dirPath)) return;
836
+ const workspace = describeWorkspaceCandidate(dirPath);
837
+ if (!workspace) return;
838
+ if (!force && workspace.score === 0) return;
839
+ candidates.set(dirPath, workspace);
840
+ }
841
+ }
842
+
843
+ function describeWorkspaceCandidate(dirPath) {
844
+ const normalized = normalizeExistingDirectoryPath(dirPath);
845
+ if (!normalized) return null;
846
+
847
+ let stat;
848
+ try {
849
+ stat = fs.statSync(normalized);
850
+ } catch {
851
+ return null;
852
+ }
853
+ if (!stat.isDirectory()) return null;
854
+
855
+ let score = 0;
856
+ let hasGit = false;
857
+
858
+ for (const marker of WORKSPACE_MARKER_FILES) {
859
+ if (fs.existsSync(path.join(normalized, marker))) {
860
+ score += marker === ".git" ? 3 : 1;
861
+ if (marker === ".git") hasGit = true;
862
+ }
863
+ }
864
+
865
+ return {
866
+ path: normalized,
867
+ name: path.basename(normalized),
868
+ hasGit,
869
+ score,
870
+ lastModified: stat.mtimeMs
871
+ };
872
+ }
873
+
874
+ function sanitizeWorkspaceName(value) {
875
+ const raw = safeTrimmedText(value, 120);
876
+ if (!raw) return "";
877
+
878
+ const normalized = raw
879
+ .replace(/[\\/]+/g, " ")
880
+ .replace(/[<>:"|?*\u0000-\u001F]/g, "")
881
+ .replace(/\s+/g, " ")
882
+ .trim();
883
+
884
+ if (!normalized || normalized === "." || normalized === "..") return "";
885
+ return normalized;
886
+ }
887
+
888
+ function createWorkspaceFolder(input) {
889
+ const name = sanitizeWorkspaceName(input?.name || input?.workspaceName || input?.folderName);
890
+ if (!name) {
891
+ return { statusCode: 400, error: "workspace name is required" };
892
+ }
893
+
894
+ const explicitParent = safeTrimmedText(input?.parentPath || input?.workspaceRoot, 1000);
895
+ const parentPath = normalizeExistingDirectoryPath(explicitParent) || getWorkspaceRootSetting();
896
+ if (!parentPath) {
897
+ return { statusCode: 400, error: "workspace root is not configured" };
898
+ }
899
+
900
+ if (!ALLOW_ANY_WORKSPACE && !WORKSPACE_ROOTS.some((root) => isPathInside(root, parentPath))) {
901
+ return {
902
+ statusCode: 403,
903
+ error: "workspace root is outside allowed roots",
904
+ workspaceRoots: WORKSPACE_ROOTS
905
+ };
906
+ }
907
+
908
+ const targetPath = path.resolve(parentPath, name);
909
+ if (!isPathInside(parentPath, targetPath)) {
910
+ return { statusCode: 400, error: "invalid workspace name" };
911
+ }
912
+
913
+ if (!ALLOW_ANY_WORKSPACE && !WORKSPACE_ROOTS.some((root) => isPathInside(root, targetPath))) {
914
+ return {
915
+ statusCode: 403,
916
+ error: "workspacePath is outside allowed roots",
917
+ workspaceRoots: WORKSPACE_ROOTS
918
+ };
919
+ }
920
+
921
+ let created = false;
922
+ try {
923
+ if (fs.existsSync(targetPath)) {
924
+ const stat = fs.statSync(targetPath);
925
+ if (!stat.isDirectory()) {
926
+ return { statusCode: 409, error: "path exists and is not a directory" };
927
+ }
928
+ } else {
929
+ fs.mkdirSync(targetPath, { recursive: false });
930
+ created = true;
931
+ }
932
+ } catch (error) {
933
+ return { statusCode: 500, error: String(error?.message || error) };
934
+ }
935
+
936
+ const normalizedPath = normalizeExistingDirectoryPath(targetPath);
937
+ if (!normalizedPath) {
938
+ return { statusCode: 500, error: "workspace path could not be resolved" };
939
+ }
940
+
941
+ const workspace = describeWorkspaceCandidate(normalizedPath);
942
+ if (!workspace) {
943
+ return { statusCode: 500, error: "workspace could not be indexed" };
944
+ }
945
+
946
+ return {
947
+ statusCode: created ? 201 : 200,
948
+ workspace,
949
+ created,
950
+ parentPath
951
+ };
952
+ }
953
+
954
+ function detectWorkspaceMeta(workspacePath) {
955
+ const repo = path.basename(workspacePath);
956
+ const branch = detectGitBranch(workspacePath);
957
+ return { repo, branch };
958
+ }
959
+
960
+ function detectGitBranch(workspacePath) {
961
+ try {
962
+ return execSync("git rev-parse --abbrev-ref HEAD", {
963
+ cwd: workspacePath,
964
+ stdio: ["ignore", "pipe", "ignore"]
965
+ })
966
+ .toString()
967
+ .trim();
968
+ } catch {
969
+ return "main";
970
+ }
971
+ }
972
+
973
+ function normalizeAgentType(value) {
974
+ return value === "CLAUDE" ? "CLAUDE" : "CODEX";
975
+ }
976
+
977
+ function normalizeCommandList(input, agentType, prompt) {
978
+ if (Array.isArray(input)) {
979
+ const parts = input
980
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
981
+ .filter(Boolean);
982
+ while (parts[0] === "--") {
983
+ parts.shift();
984
+ }
985
+ return parts.length > 0 ? parts : null;
986
+ }
987
+
988
+ if (typeof input === "string" && input.trim()) {
989
+ const parts = input
990
+ .trim()
991
+ .split(/\s+/)
992
+ .filter(Boolean);
993
+ while (parts[0] === "--") {
994
+ parts.shift();
995
+ }
996
+ return parts.length > 0 ? parts : null;
997
+ }
998
+
999
+ const trimmedPrompt = safeTrimmedText(prompt, 1500);
1000
+ if (!trimmedPrompt) return null;
1001
+
1002
+ if (agentType === "CLAUDE") {
1003
+ return ["claude", "-p", trimmedPrompt];
1004
+ }
1005
+ return ["codex", "exec", trimmedPrompt];
1006
+ }
1007
+
1008
+ function buildPlanPrompt(prompt) {
1009
+ const base = safeTrimmedText(prompt, 1500);
1010
+ if (!base) return "";
1011
+ const suffix =
1012
+ "\n\nYou are in PLAN MODE. Do not implement changes yet. Return only a concrete plan, then end with a single explicit approval question asking whether to proceed with implementation.";
1013
+ return safeTrimmedText(`${base}${suffix}`, 1900);
1014
+ }
1015
+
1016
+ function createLauncherRun(input) {
1017
+ const forceNewThread = safeBoolean(input?.newThread, false);
1018
+ const requestedSessionId = forceNewThread ? "" : safeTrimmedText(input?.sessionId, 120);
1019
+ const runId = `run_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
1020
+ const agentType = normalizeAgentType(input?.agentType);
1021
+ const workspacePath = normalizeWorkspacePath(input?.workspacePath);
1022
+
1023
+ if (!workspacePath) {
1024
+ return { error: "workspacePath must point to an existing local directory" };
1025
+ }
1026
+ if (!isWorkspaceAllowed(workspacePath)) {
1027
+ return {
1028
+ error: "workspacePath is outside allowed roots",
1029
+ allowedRoots: WORKSPACE_ROOTS
1030
+ };
1031
+ }
1032
+
1033
+ const fullWorkspaceAccess = safeBoolean(input?.fullWorkspaceAccess, false);
1034
+ const skipPermissions = safeBoolean(input?.skipPermissions, false);
1035
+ const planMode = safeBoolean(input?.planMode, false);
1036
+ if (planMode && (fullWorkspaceAccess || skipPermissions)) {
1037
+ return {
1038
+ error: "planMode cannot be combined with fullWorkspaceAccess or skipPermissions"
1039
+ };
1040
+ }
1041
+ const displayPrompt = safeTrimmedText(input?.prompt, 1500);
1042
+ const effectivePrompt = planMode ? buildPlanPrompt(displayPrompt) : displayPrompt;
1043
+ const workspaceMeta = detectWorkspaceMeta(workspacePath);
1044
+ const title =
1045
+ safeTrimmedText(input?.title, 140) ||
1046
+ safeTrimmedText(displayPrompt, 140) ||
1047
+ `${agentType} local task`;
1048
+ const threadLookupKey = buildSessionThreadLookupKey({
1049
+ agentType,
1050
+ workspacePath,
1051
+ repo: workspaceMeta.repo,
1052
+ title
1053
+ });
1054
+ const validatedRequestedSession = requestedSessionId
1055
+ ? validateSessionReuse({
1056
+ sessionId: requestedSessionId,
1057
+ agentType,
1058
+ workspacePath,
1059
+ prompt: effectivePrompt
1060
+ })
1061
+ : { ok: true, sessionId: "", continueCommand: null };
1062
+ if (!validatedRequestedSession.ok) {
1063
+ return { error: validatedRequestedSession.error || "session could not be resumed" };
1064
+ }
1065
+
1066
+ const autoMatchedSessionId =
1067
+ validatedRequestedSession.sessionId ||
1068
+ (forceNewThread ? "" : findMatchingSessionId(agentType, workspacePath, workspaceMeta.repo, title));
1069
+ const command = autoMatchedSessionId
1070
+ ? resolveContinueCommandForSession(autoMatchedSessionId, effectivePrompt)
1071
+ : normalizeCommandList(input?.command, agentType, effectivePrompt);
1072
+ if (!command || command.length === 0) {
1073
+ return {
1074
+ error: autoMatchedSessionId
1075
+ ? "session is not ready to resume yet"
1076
+ : "prompt is required when command is not provided"
1077
+ };
1078
+ }
1079
+
1080
+ const matchedSessionId = validatedRequestedSession.sessionId || autoMatchedSessionId;
1081
+ const sessionId =
1082
+ matchedSessionId || `${agentType.toLowerCase()}_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
1083
+ const reusedExistingSession = Boolean(
1084
+ matchedSessionId &&
1085
+ (findSession(matchedSessionId) || findSessionThread(matchedSessionId))
1086
+ );
1087
+ const existingThread = findSessionThread(sessionId);
1088
+
1089
+ const run = {
1090
+ id: runId,
1091
+ sessionId,
1092
+ threadLookupKey,
1093
+ agentType,
1094
+ title,
1095
+ prompt: displayPrompt,
1096
+ executionPrompt: effectivePrompt,
1097
+ workspacePath,
1098
+ repo: workspaceMeta.repo,
1099
+ branch: workspaceMeta.branch,
1100
+ command,
1101
+ launchOptions: {
1102
+ fullWorkspaceAccess,
1103
+ skipPermissions,
1104
+ planMode,
1105
+ newThread: forceNewThread
1106
+ },
1107
+ status: "STARTING",
1108
+ createdAt: Date.now(),
1109
+ startedAt: null,
1110
+ endedAt: null,
1111
+ pid: null,
1112
+ exitCode: null,
1113
+ signal: null,
1114
+ error: null,
1115
+ codexThreadId: null,
1116
+ claudeSessionId: null,
1117
+ resumeCommand: null,
1118
+ stopRequested: false,
1119
+ assistantOutputSegments: [],
1120
+ outputTail: []
1121
+ };
1122
+
1123
+ launcherRuns.set(runId, run);
1124
+ trimLauncherRuns();
1125
+
1126
+ withPersist(() => {
1127
+ const launchMode = [];
1128
+ if (run.launchOptions.fullWorkspaceAccess) launchMode.push("full-workspace-access");
1129
+ if (run.launchOptions.skipPermissions) launchMode.push("skip-permissions");
1130
+ if (run.launchOptions.planMode) launchMode.push("plan-mode");
1131
+ if (run.launchOptions.newThread) launchMode.push("new-thread");
1132
+ const launchSuffix = launchMode.length > 0 ? ` (${launchMode.join(", ")})` : "";
1133
+ const reuseSuffix = reusedExistingSession ? " [reused existing session]" : "";
1134
+ const promptTokens = run.executionPrompt ? safeInt(run.executionPrompt.length / 4, 0) : 0;
1135
+ const now = Date.now();
1136
+
1137
+ upsertSession({
1138
+ id: run.sessionId,
1139
+ agentType: run.agentType,
1140
+ title: run.title,
1141
+ repo: run.repo,
1142
+ branch: run.branch,
1143
+ state: "RUNNING",
1144
+ progress: 2,
1145
+ lastUpdated: now,
1146
+ workspacePath: run.workspacePath,
1147
+ threadLookupKey: run.threadLookupKey,
1148
+ tokenUsage: {
1149
+ promptTokens,
1150
+ completionTokens: 0,
1151
+ totalTokens: promptTokens,
1152
+ costUsd: 0
1153
+ }
1154
+ });
1155
+ upsertSessionThread({
1156
+ id: run.sessionId,
1157
+ agentType: run.agentType,
1158
+ key: run.threadLookupKey,
1159
+ lookupKey: run.threadLookupKey,
1160
+ workspacePath: run.workspacePath,
1161
+ repo: run.repo,
1162
+ branch: run.branch,
1163
+ title: run.title,
1164
+ normalizedTitle: normalizeThreadTitle(run.title),
1165
+ createdAt: safeNumber(existingThread?.createdAt, now),
1166
+ updatedAt: now,
1167
+ lastRunId: run.id,
1168
+ runCount: safeInt(existingThread?.runCount, 0) + 1,
1169
+ lastMessageAt: now
1170
+ });
1171
+
1172
+ if (run.prompt) {
1173
+ appendChatTurn({
1174
+ sessionId: run.sessionId,
1175
+ role: "USER",
1176
+ kind: "MESSAGE",
1177
+ text: run.prompt,
1178
+ runId: run.id,
1179
+ createdAt: now,
1180
+ source: "LAUNCH"
1181
+ });
1182
+ }
1183
+
1184
+ addEvent({
1185
+ sessionId: run.sessionId,
1186
+ summary: `Launch requested from phone for ${run.repo}${launchSuffix}${reuseSuffix}.`,
1187
+ category: "ACTION",
1188
+ timestamp: now
1189
+ });
1190
+ });
1191
+
1192
+ const runnerScript = path.resolve(process.cwd(), "scripts", "agent-runner.mjs");
1193
+ const runnerArgs = [
1194
+ runnerScript,
1195
+ "--agent",
1196
+ run.agentType,
1197
+ "--session",
1198
+ run.sessionId,
1199
+ "--run",
1200
+ run.id,
1201
+ "--title",
1202
+ run.title,
1203
+ "--repo",
1204
+ run.repo,
1205
+ "--branch",
1206
+ run.branch,
1207
+ "--bridge",
1208
+ BRIDGE_URL,
1209
+ ...(run.launchOptions.fullWorkspaceAccess ? ["--fullWorkspaceAccess"] : []),
1210
+ ...(run.launchOptions.skipPermissions ? ["--skipPermissions"] : []),
1211
+ ...(run.launchOptions.planMode ? ["--planMode"] : []),
1212
+ "--",
1213
+ ...run.command
1214
+ ];
1215
+
1216
+ try {
1217
+ const child = spawn(process.execPath, runnerArgs, {
1218
+ cwd: run.workspacePath,
1219
+ env: process.env,
1220
+ stdio: ["pipe", "pipe", "pipe"]
1221
+ });
1222
+
1223
+ run.child = child;
1224
+
1225
+ child.on("spawn", () => {
1226
+ run.status = "RUNNING";
1227
+ run.startedAt = Date.now();
1228
+ run.pid = child.pid ?? null;
1229
+ });
1230
+
1231
+ child.stdout.on("data", (chunk) => {
1232
+ appendRunOutput(run, chunk.toString());
1233
+ });
1234
+
1235
+ child.stderr.on("data", (chunk) => {
1236
+ appendRunOutput(run, chunk.toString());
1237
+ });
1238
+
1239
+ child.on("error", (error) => {
1240
+ run.status = "FAILED";
1241
+ run.error = String(error);
1242
+ run.endedAt = Date.now();
1243
+ run.child = null;
1244
+
1245
+ withPersist(() => {
1246
+ upsertSession({
1247
+ id: run.sessionId,
1248
+ agentType: run.agentType,
1249
+ title: run.title,
1250
+ repo: run.repo,
1251
+ branch: run.branch,
1252
+ state: "FAILED",
1253
+ progress: 100,
1254
+ lastUpdated: Date.now(),
1255
+ workspacePath: run.workspacePath,
1256
+ threadLookupKey: run.threadLookupKey
1257
+ });
1258
+ addEvent({
1259
+ sessionId: run.sessionId,
1260
+ summary: `Unable to launch local command: ${run.error}`,
1261
+ category: "ERROR",
1262
+ timestamp: Date.now()
1263
+ });
1264
+ });
1265
+ });
1266
+
1267
+ child.on("close", (code, signal) => {
1268
+ run.exitCode = Number.isInteger(code) ? code : null;
1269
+ run.signal = signal ?? null;
1270
+ run.endedAt = Date.now();
1271
+ run.child = null;
1272
+ const finalOutputText = deriveAssistantFinalOutput(run);
1273
+
1274
+ if (run.stopRequested) {
1275
+ run.status = "STOPPED";
1276
+
1277
+ withPersist(() => {
1278
+ upsertSession({
1279
+ id: run.sessionId,
1280
+ agentType: run.agentType,
1281
+ title: run.title,
1282
+ repo: run.repo,
1283
+ branch: run.branch,
1284
+ state: "CANCELLED",
1285
+ progress: 100,
1286
+ lastUpdated: Date.now(),
1287
+ workspacePath: run.workspacePath,
1288
+ threadLookupKey: run.threadLookupKey
1289
+ });
1290
+ if (finalOutputText) {
1291
+ appendChatTurn({
1292
+ sessionId: run.sessionId,
1293
+ role: "ASSISTANT",
1294
+ kind: "FINAL_OUTPUT",
1295
+ text: finalOutputText,
1296
+ runId: run.id,
1297
+ createdAt: Date.now(),
1298
+ source: "RUN_OUTPUT"
1299
+ });
1300
+ }
1301
+ addEvent({
1302
+ sessionId: run.sessionId,
1303
+ summary: "Session stopped from phone.",
1304
+ category: "ACTION",
1305
+ timestamp: Date.now()
1306
+ });
1307
+ });
1308
+ } else {
1309
+ run.status = run.exitCode === 0 ? "COMPLETED" : "FAILED";
1310
+ withPersist(() => {
1311
+ upsertSession({
1312
+ id: run.sessionId,
1313
+ agentType: run.agentType,
1314
+ title: run.title,
1315
+ repo: run.repo,
1316
+ branch: run.branch,
1317
+ state: run.status,
1318
+ progress: 100,
1319
+ lastUpdated: Date.now(),
1320
+ workspacePath: run.workspacePath,
1321
+ threadLookupKey: run.threadLookupKey
1322
+ });
1323
+
1324
+ if (finalOutputText) {
1325
+ appendChatTurn({
1326
+ sessionId: run.sessionId,
1327
+ role: "ASSISTANT",
1328
+ kind: "FINAL_OUTPUT",
1329
+ text: finalOutputText,
1330
+ runId: run.id,
1331
+ createdAt: Date.now(),
1332
+ source: "RUN_OUTPUT"
1333
+ });
1334
+ }
1335
+ });
1336
+ }
1337
+ });
1338
+ } catch (error) {
1339
+ run.status = "FAILED";
1340
+ run.error = String(error);
1341
+ run.endedAt = Date.now();
1342
+
1343
+ withPersist(() => {
1344
+ upsertSession({
1345
+ id: run.sessionId,
1346
+ agentType: run.agentType,
1347
+ title: run.title,
1348
+ repo: run.repo,
1349
+ branch: run.branch,
1350
+ state: "FAILED",
1351
+ progress: 100,
1352
+ lastUpdated: Date.now(),
1353
+ workspacePath: run.workspacePath,
1354
+ threadLookupKey: run.threadLookupKey
1355
+ });
1356
+ addEvent({
1357
+ sessionId: run.sessionId,
1358
+ summary: `Failed to start task: ${run.error}`,
1359
+ category: "ERROR",
1360
+ timestamp: Date.now()
1361
+ });
1362
+ });
1363
+ }
1364
+
1365
+ return { run };
1366
+ }
1367
+
1368
+ function stripAnsi(text) {
1369
+ return String(text || "").replace(/\u001b\[[0-9;]*m/g, "");
1370
+ }
1371
+
1372
+ function tryParseJsonLine(line) {
1373
+ const trimmed = line.trim();
1374
+ if (!trimmed || trimmed[0] !== "{") return null;
1375
+ try {
1376
+ return JSON.parse(trimmed);
1377
+ } catch {
1378
+ return null;
1379
+ }
1380
+ }
1381
+
1382
+ function extractTextFromContent(content) {
1383
+ if (typeof content === "string") {
1384
+ return [content];
1385
+ }
1386
+
1387
+ if (!Array.isArray(content)) {
1388
+ if (content && typeof content === "object") {
1389
+ const candidate =
1390
+ safeTrimmedText(content.text, 40_000) ||
1391
+ safeTrimmedText(content.value, 40_000) ||
1392
+ safeTrimmedText(content.message, 40_000);
1393
+ return candidate ? [candidate] : [];
1394
+ }
1395
+ return [];
1396
+ }
1397
+
1398
+ const extracted = [];
1399
+ for (const item of content) {
1400
+ if (!item || typeof item !== "object") continue;
1401
+ const candidate =
1402
+ safeTrimmedText(item.text, 40_000) ||
1403
+ safeTrimmedText(item.value, 40_000) ||
1404
+ safeTrimmedText(item.message, 40_000);
1405
+ if (candidate) {
1406
+ extracted.push(candidate);
1407
+ }
1408
+ }
1409
+ return extracted;
1410
+ }
1411
+
1412
+ function extractAssistantSegmentsFromJson(payload) {
1413
+ if (!payload || typeof payload !== "object") return [];
1414
+
1415
+ if (payload.type === "assistant") {
1416
+ const message = payload.message && typeof payload.message === "object" ? payload.message : null;
1417
+ if (message && message.role === "assistant") {
1418
+ return extractTextFromContent(message.content || message.text);
1419
+ }
1420
+ return [];
1421
+ }
1422
+
1423
+ if (payload.type === "result" && typeof payload.result === "string") {
1424
+ return extractTextFromContent(payload.result);
1425
+ }
1426
+
1427
+ if (payload.type === "item.completed" && payload.item && typeof payload.item === "object") {
1428
+ const itemType = safeTrimmedText(payload.item.type, 80).toLowerCase();
1429
+ if (itemType === "agent_message" || itemType === "message") {
1430
+ return extractTextFromContent(payload.item.text || payload.item.content);
1431
+ }
1432
+ if (itemType.includes("tool") || itemType.includes("function") || itemType.includes("call")) {
1433
+ const toolName =
1434
+ safeTrimmedText(payload.item.name, 120) ||
1435
+ safeTrimmedText(payload.item.tool_name, 120) ||
1436
+ safeTrimmedText(payload.item.toolName, 120) ||
1437
+ safeTrimmedText(payload.item.function?.name, 120);
1438
+ const toolStatus = safeTrimmedText(payload.item.status, 40);
1439
+ const header = `[tool] ${toolName || "call"}${toolStatus ? ` (${toolStatus})` : ""}`;
1440
+ const details = extractTextFromContent(payload.item.text || payload.item.content || payload.item.output);
1441
+ return details.length > 0 ? [header, ...details] : [header];
1442
+ }
1443
+ }
1444
+
1445
+ if (payload.type === "event_msg" && payload.payload && typeof payload.payload === "object") {
1446
+ const payloadType = safeTrimmedText(payload.payload.type, 80).toLowerCase();
1447
+ if (payloadType === "agent_message" || payloadType === "message") {
1448
+ return extractTextFromContent(payload.payload.message || payload.payload.content);
1449
+ }
1450
+ if (payloadType.includes("tool") || payloadType.includes("function") || payloadType.includes("call")) {
1451
+ const toolName =
1452
+ safeTrimmedText(payload.payload.name, 120) ||
1453
+ safeTrimmedText(payload.payload.tool_name, 120) ||
1454
+ safeTrimmedText(payload.payload.toolName, 120) ||
1455
+ safeTrimmedText(payload.payload.function?.name, 120);
1456
+ const header = `[tool] ${toolName || "call"}`;
1457
+ const details = extractTextFromContent(payload.payload.message || payload.payload.content || payload.payload.output);
1458
+ return details.length > 0 ? [header, ...details] : [header];
1459
+ }
1460
+ }
1461
+
1462
+ if (payload.type === "response_item" && payload.payload && typeof payload.payload === "object") {
1463
+ const payloadType = safeTrimmedText(payload.payload.type, 80).toLowerCase();
1464
+ if (payloadType === "message" && payload.payload.role === "assistant") {
1465
+ return extractTextFromContent(payload.payload.content || payload.payload.message || payload.payload.text);
1466
+ }
1467
+ if (payloadType.includes("tool") || payloadType.includes("function") || payloadType.includes("call")) {
1468
+ const toolName =
1469
+ safeTrimmedText(payload.payload.name, 120) ||
1470
+ safeTrimmedText(payload.payload.tool_name, 120) ||
1471
+ safeTrimmedText(payload.payload.toolName, 120) ||
1472
+ safeTrimmedText(payload.payload.function?.name, 120);
1473
+ const header = `[tool] ${toolName || "call"}`;
1474
+ const details = extractTextFromContent(payload.payload.output || payload.payload.content || payload.payload.message);
1475
+ return details.length > 0 ? [header, ...details] : [header];
1476
+ }
1477
+ }
1478
+
1479
+ if (payload.type === "message" && payload.role === "assistant") {
1480
+ return extractTextFromContent(payload.content || payload.text);
1481
+ }
1482
+
1483
+ return [];
1484
+ }
1485
+
1486
+ function isTranscriptNoiseLine(line) {
1487
+ if (!line) return true;
1488
+ if (/^\[agent-runner\]/i.test(line)) return true;
1489
+ if (/^(session started|session completed|session failed|resume later:|codex thread ready:)/i.test(line)) {
1490
+ return true;
1491
+ }
1492
+ if (/^(prompt|completion|total)[_\s-]*tokens?\s*[:=]/i.test(line)) return true;
1493
+ if (/^cost(?:[_\s-]*usd)?\s*[:=]/i.test(line)) return true;
1494
+ if (/^\d{4}-\d{2}-\d{2}t/i.test(line) && /\b(error|warn|info|debug)\b/i.test(line)) return true;
1495
+ if (/codex_core::|codex_exec|rollout::list|mcp/i.test(line)) return true;
1496
+ return false;
1497
+ }
1498
+
1499
+ function collectAssistantSegmentsFromLine(rawLine) {
1500
+ const line = stripAnsi(rawLine).trim();
1501
+ if (!line) return [];
1502
+
1503
+ const parsed = tryParseJsonLine(line);
1504
+ if (parsed) {
1505
+ return extractAssistantSegmentsFromJson(parsed)
1506
+ .map((item) => sanitizeTurnText(item))
1507
+ .filter(Boolean);
1508
+ }
1509
+
1510
+ if (isTranscriptNoiseLine(line)) {
1511
+ return [];
1512
+ }
1513
+
1514
+ return [sanitizeTurnText(line)].filter(Boolean);
1515
+ }
1516
+
1517
+ function deriveAssistantFinalOutput(run) {
1518
+ const segments = Array.isArray(run?.assistantOutputSegments) ? run.assistantOutputSegments : [];
1519
+ if (segments.length === 0) return "";
1520
+
1521
+ const merged = [];
1522
+ for (const segment of segments) {
1523
+ const cleaned = sanitizeTurnText(segment, 6000);
1524
+ if (!cleaned) continue;
1525
+ if (merged[merged.length - 1] === cleaned) continue;
1526
+ merged.push(cleaned);
1527
+ }
1528
+
1529
+ return sanitizeTurnText(merged.join("\n\n"), 12000);
1530
+ }
1531
+
1532
+ function appendRunOutput(run, text) {
1533
+ if (!text) return;
1534
+ const rawLines = String(text).split(/\r?\n/);
1535
+ const lines = [];
1536
+
1537
+ for (const rawLine of rawLines) {
1538
+ const line = stripAnsi(rawLine).trim();
1539
+ if (!line) continue;
1540
+ lines.push(line);
1541
+
1542
+ const threadMatch = line.match(/\[agent-runner\]\s+codex_thread=([0-9a-f-]+)/i);
1543
+ if (threadMatch && threadMatch[1]) {
1544
+ run.codexThreadId = threadMatch[1];
1545
+ run.resumeCommand = `codex exec resume ${threadMatch[1]}`;
1546
+ const session = findSession(run.sessionId);
1547
+ if (session) {
1548
+ syncSessionThreadFromSession(session, {
1549
+ updatedAt: Date.now(),
1550
+ lastRunId: run.id,
1551
+ codexThreadId: run.codexThreadId
1552
+ });
1553
+ }
1554
+ }
1555
+
1556
+ const parsedLine = tryParseJsonLine(line);
1557
+ if (parsedLine && typeof parsedLine === "object") {
1558
+ const claudeSessionId = safeTrimmedText(parsedLine.session_id, 120);
1559
+ if (claudeSessionId) {
1560
+ run.claudeSessionId = claudeSessionId;
1561
+ run.resumeCommand = `claude --resume ${claudeSessionId}`;
1562
+ const session = findSession(run.sessionId);
1563
+ if (session) {
1564
+ syncSessionThreadFromSession(session, {
1565
+ updatedAt: Date.now(),
1566
+ lastRunId: run.id,
1567
+ claudeSessionId: run.claudeSessionId
1568
+ });
1569
+ }
1570
+ }
1571
+ }
1572
+
1573
+ const segments = collectAssistantSegmentsFromLine(rawLine);
1574
+ if (segments.length > 0) {
1575
+ if (!Array.isArray(run.assistantOutputSegments)) {
1576
+ run.assistantOutputSegments = [];
1577
+ }
1578
+ run.assistantOutputSegments.push(...segments);
1579
+ if (run.assistantOutputSegments.length > MAX_TRANSCRIPT_SEGMENTS_PER_RUN) {
1580
+ run.assistantOutputSegments = run.assistantOutputSegments.slice(-MAX_TRANSCRIPT_SEGMENTS_PER_RUN);
1581
+ }
1582
+ }
1583
+ }
1584
+
1585
+ if (lines.length === 0) return;
1586
+
1587
+ run.outputTail.push(...lines);
1588
+ if (run.outputTail.length > MAX_LAUNCHER_RUN_OUTPUT_LINES) {
1589
+ run.outputTail = run.outputTail.slice(-MAX_LAUNCHER_RUN_OUTPUT_LINES);
1590
+ }
1591
+
1592
+ const now = Date.now();
1593
+ const previousTouchAt = safeNumber(run.lastSessionTouchAt, 0);
1594
+ if (now - previousTouchAt >= RUN_OUTPUT_TOUCH_INTERVAL_MS) {
1595
+ run.lastSessionTouchAt = now;
1596
+ const session = findSession(run.sessionId);
1597
+ if (session && (session.state === "RUNNING" || session.state === "WAITING_INPUT")) {
1598
+ session.lastUpdated = now;
1599
+ session.progress = Math.max(4, Math.min(99, safeNumber(session.progress, 4)));
1600
+ syncSessionThreadFromSession(session, {
1601
+ updatedAt: now,
1602
+ lastMessageAt: now,
1603
+ lastRunId: run.id,
1604
+ codexThreadId: run.codexThreadId,
1605
+ claudeSessionId: run.claudeSessionId
1606
+ });
1607
+ }
1608
+ }
1609
+ }
1610
+
1611
+ function trimLauncherRuns() {
1612
+ if (launcherRuns.size <= MAX_LAUNCHER_RUNS) return;
1613
+ const entries = [...launcherRuns.entries()].sort((a, b) => b[1].createdAt - a[1].createdAt);
1614
+ const keep = new Set(entries.slice(0, MAX_LAUNCHER_RUNS).map(([id]) => id));
1615
+ for (const [id] of launcherRuns.entries()) {
1616
+ if (!keep.has(id)) launcherRuns.delete(id);
1617
+ }
1618
+ }
1619
+
1620
+ function serializeRun(run) {
1621
+ return {
1622
+ id: run.id,
1623
+ sessionId: run.sessionId,
1624
+ threadLookupKey: run.threadLookupKey ?? null,
1625
+ agentType: run.agentType,
1626
+ title: run.title,
1627
+ prompt: run.prompt,
1628
+ workspacePath: run.workspacePath,
1629
+ repo: run.repo,
1630
+ branch: run.branch,
1631
+ command: run.command,
1632
+ fullWorkspaceAccess: Boolean(run.launchOptions?.fullWorkspaceAccess),
1633
+ skipPermissions: Boolean(run.launchOptions?.skipPermissions),
1634
+ planMode: Boolean(run.launchOptions?.planMode),
1635
+ newThread: Boolean(run.launchOptions?.newThread),
1636
+ status: run.status,
1637
+ createdAt: run.createdAt,
1638
+ startedAt: run.startedAt,
1639
+ endedAt: run.endedAt,
1640
+ pid: run.pid,
1641
+ exitCode: run.exitCode,
1642
+ signal: run.signal,
1643
+ error: run.error,
1644
+ codexThreadId: run.codexThreadId ?? null,
1645
+ claudeSessionId: run.claudeSessionId ?? null,
1646
+ resumeCommand: run.resumeCommand ?? null,
1647
+ assistantFinalOutput: deriveAssistantFinalOutput(run) || null,
1648
+ stopRequested: run.stopRequested,
1649
+ outputTail: run.outputTail
1650
+ };
1651
+ }
1652
+
1653
+ function trimManagedServices() {
1654
+ if (managedServices.size <= MAX_MANAGED_SERVICES) return;
1655
+ const entries = [...managedServices.entries()].sort((a, b) => safeNumber(b[1]?.createdAt, 0) - safeNumber(a[1]?.createdAt, 0));
1656
+ const keep = new Set(entries.slice(0, MAX_MANAGED_SERVICES).map(([id]) => id));
1657
+ for (const [id, service] of managedServices.entries()) {
1658
+ if (keep.has(id)) continue;
1659
+ if (service?.status === "RUNNING" || service?.status === "STARTING") {
1660
+ continue;
1661
+ }
1662
+ managedServices.delete(id);
1663
+ }
1664
+ }
1665
+
1666
+ function parseCommandInput(input) {
1667
+ if (Array.isArray(input)) {
1668
+ const parts = input
1669
+ .map((item) => (typeof item === "string" ? item.trim() : ""))
1670
+ .filter(Boolean);
1671
+ return parts.length > 0 ? parts : null;
1672
+ }
1673
+
1674
+ const value = safeTrimmedText(input, 5000);
1675
+ if (!value) return null;
1676
+ return ["/bin/zsh", "-lc", value];
1677
+ }
1678
+
1679
+ function appendManagedServiceOutput(service, text) {
1680
+ if (!service || !text) return;
1681
+ const lines = String(text)
1682
+ .split(/\r?\n/)
1683
+ .map((line) => stripAnsi(line).trim())
1684
+ .filter(Boolean);
1685
+ if (lines.length === 0) return;
1686
+ service.outputTail.push(...lines);
1687
+ if (service.outputTail.length > MAX_MANAGED_SERVICE_OUTPUT_LINES) {
1688
+ service.outputTail = service.outputTail.slice(-MAX_MANAGED_SERVICE_OUTPUT_LINES);
1689
+ }
1690
+ service.lastOutputAt = Date.now();
1691
+ }
1692
+
1693
+ function isPidRunning(pid) {
1694
+ const numericPid = safeInt(pid, 0);
1695
+ if (numericPid <= 0) return false;
1696
+ try {
1697
+ process.kill(numericPid, 0);
1698
+ return true;
1699
+ } catch {
1700
+ return false;
1701
+ }
1702
+ }
1703
+
1704
+ function refreshManagedServiceRuntime(service) {
1705
+ if (!service) return;
1706
+ if (service.status !== "RUNNING" && service.status !== "STARTING") return;
1707
+ if (!isPidRunning(service.pid)) {
1708
+ service.status = service.stopRequested ? "STOPPED" : "FAILED";
1709
+ service.endedAt = service.endedAt || Date.now();
1710
+ service.error = service.error || "process is no longer running";
1711
+ }
1712
+ }
1713
+
1714
+ function serializeManagedService(service) {
1715
+ refreshManagedServiceRuntime(service);
1716
+ return {
1717
+ id: service.id,
1718
+ sessionId: service.sessionId || null,
1719
+ workspacePath: service.workspacePath,
1720
+ label: service.label,
1721
+ command: service.command,
1722
+ status: service.status,
1723
+ createdAt: service.createdAt,
1724
+ startedAt: service.startedAt,
1725
+ endedAt: service.endedAt,
1726
+ pid: service.pid,
1727
+ exitCode: service.exitCode,
1728
+ signal: service.signal,
1729
+ error: service.error,
1730
+ port: service.port,
1731
+ localhostUrl: service.localhostUrl,
1732
+ stopRequested: Boolean(service.stopRequested),
1733
+ lastOutputAt: service.lastOutputAt || null,
1734
+ outputTail: Array.isArray(service.outputTail) ? service.outputTail.slice(-MAX_MANAGED_SERVICE_OUTPUT_LINES) : []
1735
+ };
1736
+ }
1737
+
1738
+ function startManagedService(input) {
1739
+ const workspacePath = normalizeWorkspacePath(input?.workspacePath);
1740
+ if (!workspacePath) {
1741
+ return { statusCode: 400, error: "workspacePath must point to an existing local directory" };
1742
+ }
1743
+ if (!isWorkspaceAllowed(workspacePath)) {
1744
+ return {
1745
+ statusCode: 403,
1746
+ error: "workspacePath is outside allowed roots",
1747
+ allowedRoots: WORKSPACE_ROOTS
1748
+ };
1749
+ }
1750
+
1751
+ const command = parseCommandInput(input?.command);
1752
+ if (!command || command.length === 0) {
1753
+ return { statusCode: 400, error: "command is required" };
1754
+ }
1755
+
1756
+ const sessionId = safeTrimmedText(input?.sessionId, 180) || null;
1757
+ const serviceId = `svc_${Date.now()}_${Math.floor(Math.random() * 10_000)}`;
1758
+ const label =
1759
+ safeTrimmedText(input?.label, 140) ||
1760
+ safeTrimmedText(command.join(" "), 140) ||
1761
+ "Background service";
1762
+ const portRaw = safeInt(input?.port, 0);
1763
+ const port = portRaw > 0 && portRaw <= 65535 ? portRaw : null;
1764
+ const localhostUrl = port ? `http://localhost:${port}` : null;
1765
+
1766
+ const envInput = input?.env && typeof input.env === "object" ? input.env : {};
1767
+ const env = { ...process.env };
1768
+ for (const [key, value] of Object.entries(envInput)) {
1769
+ const envKey = safeTrimmedText(key, 120);
1770
+ if (!envKey || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(envKey)) continue;
1771
+ if (typeof value !== "string") continue;
1772
+ env[envKey] = value;
1773
+ }
1774
+
1775
+ const service = {
1776
+ id: serviceId,
1777
+ sessionId,
1778
+ workspacePath,
1779
+ label,
1780
+ command,
1781
+ status: "STARTING",
1782
+ createdAt: Date.now(),
1783
+ startedAt: null,
1784
+ endedAt: null,
1785
+ pid: null,
1786
+ exitCode: null,
1787
+ signal: null,
1788
+ error: null,
1789
+ stopRequested: false,
1790
+ port,
1791
+ localhostUrl,
1792
+ outputTail: [],
1793
+ lastOutputAt: null,
1794
+ child: null
1795
+ };
1796
+
1797
+ managedServices.set(serviceId, service);
1798
+ trimManagedServices();
1799
+
1800
+ try {
1801
+ const child = spawn(command[0], command.slice(1), {
1802
+ cwd: workspacePath,
1803
+ env,
1804
+ detached: true,
1805
+ stdio: ["ignore", "pipe", "pipe"]
1806
+ });
1807
+ child.unref();
1808
+ service.child = child;
1809
+
1810
+ child.on("spawn", () => {
1811
+ service.status = "RUNNING";
1812
+ service.startedAt = Date.now();
1813
+ service.pid = child.pid ?? null;
1814
+ if (sessionId) {
1815
+ withPersist(() => {
1816
+ addEvent({
1817
+ sessionId,
1818
+ summary: `Background service started: ${label}${localhostUrl ? ` (${localhostUrl})` : ""}`,
1819
+ category: "ACTION",
1820
+ timestamp: Date.now()
1821
+ });
1822
+ });
1823
+ }
1824
+ });
1825
+
1826
+ child.stdout.on("data", (chunk) => {
1827
+ appendManagedServiceOutput(service, chunk.toString());
1828
+ });
1829
+ child.stderr.on("data", (chunk) => {
1830
+ appendManagedServiceOutput(service, chunk.toString());
1831
+ });
1832
+
1833
+ child.on("error", (error) => {
1834
+ service.status = "FAILED";
1835
+ service.error = String(error?.message || error);
1836
+ service.endedAt = Date.now();
1837
+ if (sessionId) {
1838
+ withPersist(() => {
1839
+ addEvent({
1840
+ sessionId,
1841
+ summary: `Background service failed to start: ${service.error}`,
1842
+ category: "ERROR",
1843
+ timestamp: Date.now()
1844
+ });
1845
+ });
1846
+ }
1847
+ });
1848
+
1849
+ child.on("close", (code, signal) => {
1850
+ service.exitCode = Number.isInteger(code) ? code : null;
1851
+ service.signal = signal ?? null;
1852
+ service.endedAt = Date.now();
1853
+ service.child = null;
1854
+ if (service.stopRequested) {
1855
+ service.status = "STOPPED";
1856
+ } else {
1857
+ service.status = service.exitCode === 0 ? "COMPLETED" : "FAILED";
1858
+ }
1859
+ if (sessionId) {
1860
+ withPersist(() => {
1861
+ addEvent({
1862
+ sessionId,
1863
+ summary:
1864
+ service.status === "COMPLETED"
1865
+ ? `Background service completed: ${label}`
1866
+ : service.status === "STOPPED"
1867
+ ? `Background service stopped: ${label}`
1868
+ : `Background service exited: ${label}${service.exitCode != null ? ` (code ${service.exitCode})` : ""}`,
1869
+ category: service.status === "FAILED" ? "ERROR" : "INFO",
1870
+ timestamp: Date.now()
1871
+ });
1872
+ });
1873
+ }
1874
+ });
1875
+ } catch (error) {
1876
+ service.status = "FAILED";
1877
+ service.error = String(error?.message || error);
1878
+ service.endedAt = Date.now();
1879
+ return { statusCode: 500, error: service.error, service };
1880
+ }
1881
+
1882
+ return { statusCode: 201, service };
1883
+ }
1884
+
1885
+ function stopManagedService(service, signalInput) {
1886
+ if (!service) {
1887
+ return { statusCode: 404, error: "service not found" };
1888
+ }
1889
+
1890
+ const signal = safeTrimmedText(signalInput, 24) || "SIGTERM";
1891
+ service.stopRequested = true;
1892
+
1893
+ const pid = safeInt(service.pid, 0);
1894
+ let stopped = false;
1895
+ try {
1896
+ if (pid > 0) {
1897
+ process.kill(pid, signal);
1898
+ stopped = true;
1899
+ }
1900
+ } catch {
1901
+ stopped = false;
1902
+ }
1903
+
1904
+ if (!stopped && service.child) {
1905
+ try {
1906
+ service.child.kill(signal);
1907
+ stopped = true;
1908
+ } catch {
1909
+ stopped = false;
1910
+ }
1911
+ }
1912
+
1913
+ if (!stopped) {
1914
+ refreshManagedServiceRuntime(service);
1915
+ if (service.status === "RUNNING" || service.status === "STARTING") {
1916
+ return { statusCode: 409, error: "unable to stop process" };
1917
+ }
1918
+ }
1919
+
1920
+ service.status = "STOPPED";
1921
+ service.endedAt = Date.now();
1922
+
1923
+ if (service.sessionId) {
1924
+ withPersist(() => {
1925
+ addEvent({
1926
+ sessionId: service.sessionId,
1927
+ summary: `Background service stop requested (${signal}): ${service.label}`,
1928
+ category: "ACTION",
1929
+ timestamp: Date.now()
1930
+ });
1931
+ });
1932
+ }
1933
+
1934
+ return { statusCode: 200, service };
1935
+ }
1936
+
1937
+ function mergeDirectSnapshot(snapshot) {
1938
+ if (!snapshot || typeof snapshot !== "object") return;
1939
+
1940
+ const incomingDirectSessionIds = new Set(
1941
+ (Array.isArray(snapshot.sessions) ? snapshot.sessions : [])
1942
+ .map((item) => item?.id)
1943
+ .filter((id) => typeof id === "string")
1944
+ );
1945
+
1946
+ if (incomingDirectSessionIds.size > 0) {
1947
+ state.sessions = state.sessions.filter((item) => {
1948
+ const id = String(item?.id || "");
1949
+ if (!id.startsWith("codex:") && !id.startsWith("claude:")) return true;
1950
+ return incomingDirectSessionIds.has(id);
1951
+ });
1952
+ state.sessionThreads = (Array.isArray(state.sessionThreads) ? state.sessionThreads : []).filter((item) => {
1953
+ const id = String(item?.id || "");
1954
+ if (!id.startsWith("codex:") && !id.startsWith("claude:")) return true;
1955
+ return incomingDirectSessionIds.has(id);
1956
+ });
1957
+ }
1958
+
1959
+ const existingSessions = new Map(state.sessions.map((item) => [item.id, item]));
1960
+ for (const incoming of Array.isArray(snapshot.sessions) ? snapshot.sessions : []) {
1961
+ if (!incoming?.id) continue;
1962
+ const previous = existingSessions.get(incoming.id);
1963
+ if (previous) {
1964
+ Object.assign(previous, incoming);
1965
+ syncSessionThreadFromSession(previous, {
1966
+ updatedAt: safeNumber(previous.lastUpdated, Date.now())
1967
+ });
1968
+ } else {
1969
+ state.sessions.push(incoming);
1970
+ syncSessionThreadFromSession(incoming, {
1971
+ updatedAt: safeNumber(incoming.lastUpdated, Date.now())
1972
+ });
1973
+ }
1974
+ }
1975
+
1976
+ const incomingPendingIds = new Set();
1977
+ for (const pending of Array.isArray(snapshot.pendingInputs) ? snapshot.pendingInputs : []) {
1978
+ if (!pending?.id) continue;
1979
+ const handledAt = safeNumber(state.pendingHandledAt?.[pending.id], 0);
1980
+ const requestedAt = safeNumber(pending.requestedAt, 0);
1981
+ if (handledAt > 0 && requestedAt > 0 && requestedAt <= handledAt) {
1982
+ continue;
1983
+ }
1984
+ incomingPendingIds.add(pending.id);
1985
+ const normalizedPending = {
1986
+ ...pending,
1987
+ actionable: safeBoolean(pending.actionable, false),
1988
+ source: safeTrimmedText(pending.source, 32) || "DIRECT"
1989
+ };
1990
+ const index = state.pendingInputs.findIndex((item) => item.id === pending.id);
1991
+ if (index >= 0) {
1992
+ state.pendingInputs[index] = { ...state.pendingInputs[index], ...normalizedPending };
1993
+ } else {
1994
+ state.pendingInputs.unshift(normalizedPending);
1995
+ }
1996
+ }
1997
+
1998
+ const now = Date.now();
1999
+ state.pendingInputs = state.pendingInputs.filter((item) => {
2000
+ if (!String(item.id || "").startsWith("pending:")) return true;
2001
+ if (incomingPendingIds.has(item.id)) return true;
2002
+ return now - safeNumber(item.requestedAt, 0) <= DIRECT_PENDING_STALE_MS;
2003
+ });
2004
+
2005
+ const incomingDirectEventIds = new Set(
2006
+ (Array.isArray(snapshot.events) ? snapshot.events : [])
2007
+ .map((item) => item?.id)
2008
+ .filter((id) => typeof id === "string")
2009
+ );
2010
+ state.events = state.events.filter((item) => {
2011
+ const id = String(item?.id || "");
2012
+ if (!id.startsWith("event:codex:") && !id.startsWith("event:claude:")) return true;
2013
+ if (incomingDirectEventIds.has(id)) return true;
2014
+ return now - safeNumber(item.timestamp, 0) <= DIRECT_EVENT_STALE_MS;
2015
+ });
2016
+
2017
+ const existingEvents = new Map(state.events.map((item) => [item.id, item]));
2018
+ for (const incoming of Array.isArray(snapshot.events) ? snapshot.events : []) {
2019
+ if (!incoming?.id) continue;
2020
+ const previous = existingEvents.get(incoming.id);
2021
+ if (previous) {
2022
+ Object.assign(previous, incoming);
2023
+ } else {
2024
+ state.events.unshift(incoming);
2025
+ }
2026
+ }
2027
+
2028
+ const incomingDirectTurnIds = new Set(
2029
+ (Array.isArray(snapshot.chatTurns) ? snapshot.chatTurns : [])
2030
+ .map((item) => item?.id)
2031
+ .filter((id) => typeof id === "string")
2032
+ );
2033
+ state.chatTurns = (Array.isArray(state.chatTurns) ? state.chatTurns : []).filter((item) => {
2034
+ const sessionId = String(item?.sessionId || "");
2035
+ const isDirectSession = sessionId.startsWith("codex:") || sessionId.startsWith("claude:");
2036
+ const isDirectTurn = safeTrimmedText(item?.source, 48).toUpperCase() === "DIRECT" || String(item?.id || "").startsWith("direct:");
2037
+ if (!isDirectSession || !isDirectTurn) return true;
2038
+ if (!incomingDirectSessionIds.has(sessionId)) return false;
2039
+ return incomingDirectTurnIds.has(item.id);
2040
+ });
2041
+
2042
+ const existingTurns = new Map((Array.isArray(state.chatTurns) ? state.chatTurns : []).map((item) => [item.id, item]));
2043
+ for (const incoming of Array.isArray(snapshot.chatTurns) ? snapshot.chatTurns : []) {
2044
+ if (!incoming?.id || !incoming?.sessionId) continue;
2045
+ const previous = existingTurns.get(incoming.id);
2046
+ if (previous) {
2047
+ Object.assign(previous, incoming);
2048
+ } else {
2049
+ state.chatTurns.push(incoming);
2050
+ }
2051
+ }
2052
+
2053
+ if (snapshot.settings && typeof snapshot.settings === "object") {
2054
+ state.settings = {
2055
+ ...state.settings,
2056
+ ...snapshot.settings,
2057
+ darkLocked: true
2058
+ };
2059
+ }
2060
+ }
2061
+
2062
+ function buildBootstrapPayload() {
2063
+ const sessionSummaries = listSessionThreadSummaries().map((item) => ({
2064
+ id: item.id,
2065
+ thread: item.thread,
2066
+ session: item.session,
2067
+ turnCount: item.turnCount,
2068
+ lastTurn: item.lastTurn,
2069
+ pendingApprovals: item.pendingApprovals,
2070
+ latestRun: item.latestRun
2071
+ }));
2072
+
2073
+ return {
2074
+ ...state,
2075
+ source: "bridge",
2076
+ snapshotVersion: safeNumber(state.updatedAt, Date.now()),
2077
+ runs: [...launcherRuns.values()]
2078
+ .sort((a, b) => b.createdAt - a.createdAt)
2079
+ .slice(0, 80)
2080
+ .map((run) => serializeRun(run)),
2081
+ sessionSummaries
2082
+ };
2083
+ }
2084
+
2085
+ function normalizeActionType(value) {
2086
+ const normalized = String(value || "")
2087
+ .trim()
2088
+ .toUpperCase();
2089
+ if (normalized === "APPROVE" || normalized === "REJECT" || normalized === "TEXT_REPLY") {
2090
+ return normalized;
2091
+ }
2092
+ if (normalized === "TEXT" || normalized === "REPLY" || normalized === "MESSAGE") {
2093
+ return "TEXT_REPLY";
2094
+ }
2095
+ return "";
2096
+ }
2097
+
2098
+ function resolvePendingKind(pending, latestRun) {
2099
+ const fromMeta = safeTrimmedText(pending?.meta?.kind, 64).toUpperCase();
2100
+ if (fromMeta) return fromMeta;
2101
+ if (safeBoolean(pending?.meta?.questionRequest, false)) return "QUESTION_REQUEST";
2102
+ if (safeBoolean(pending?.meta?.planMode, false)) return "PLAN_CONFIRM";
2103
+ if (safeBoolean(latestRun?.launchOptions?.planMode, false)) return "PLAN_CONFIRM";
2104
+ return "RUNTIME_APPROVAL";
2105
+ }
2106
+
2107
+ function launchFollowUpRun(latestRun, sessionId, promptText, options = {}) {
2108
+ if (!latestRun?.workspacePath) {
2109
+ return { ok: false, error: "missing workspace context for follow-up run" };
2110
+ }
2111
+
2112
+ const followUpPrompt = sanitizeTurnText(promptText || "", 1500);
2113
+ if (!followUpPrompt) {
2114
+ return { ok: false, error: "follow-up prompt is required" };
2115
+ }
2116
+
2117
+ const nextPlanMode = safeBoolean(options?.planMode, safeBoolean(latestRun?.launchOptions?.planMode, false));
2118
+ const nextFullWorkspaceAccess = safeBoolean(
2119
+ options?.fullWorkspaceAccess,
2120
+ safeBoolean(latestRun?.launchOptions?.fullWorkspaceAccess, false)
2121
+ );
2122
+ const nextSkipPermissions = safeBoolean(
2123
+ options?.skipPermissions,
2124
+ safeBoolean(latestRun?.launchOptions?.skipPermissions, false)
2125
+ );
2126
+
2127
+ const command = buildContinueCommandFromRun(latestRun, followUpPrompt);
2128
+ const started = createLauncherRun({
2129
+ agentType: latestRun.agentType,
2130
+ workspacePath: latestRun.workspacePath,
2131
+ prompt: followUpPrompt,
2132
+ title: latestRun.title,
2133
+ sessionId,
2134
+ newThread: false,
2135
+ planMode: nextPlanMode,
2136
+ fullWorkspaceAccess: nextFullWorkspaceAccess,
2137
+ skipPermissions: nextSkipPermissions,
2138
+ command: command || undefined
2139
+ });
2140
+
2141
+ if (!started?.run) {
2142
+ return { ok: false, error: started?.error || "unable to start follow-up run" };
2143
+ }
2144
+
2145
+ return { ok: true, run: started.run };
2146
+ }
2147
+
2148
+ function applyPendingAction(input) {
2149
+ const pendingInputId = safeTrimmedText(input?.pendingInputId, 180);
2150
+ const sessionId = safeTrimmedText(input?.sessionId, 160);
2151
+ const type = normalizeActionType(input?.type);
2152
+ const text = sanitizeTurnText(input?.text || "", 3000);
2153
+
2154
+ if (!pendingInputId || !sessionId || !type) {
2155
+ return {
2156
+ statusCode: 400,
2157
+ body: { ok: false, error: "pendingInputId, sessionId, and valid type are required" }
2158
+ };
2159
+ }
2160
+
2161
+ const pending = state.pendingInputs.find((item) => item.id === pendingInputId && item.sessionId === sessionId);
2162
+ if (!pending) {
2163
+ return { statusCode: 404, body: { ok: false, error: "pending input not found" } };
2164
+ }
2165
+
2166
+ const defaultActionable = !String(sessionId).startsWith("codex:") && !String(sessionId).startsWith("claude:");
2167
+ if (!safeBoolean(pending.actionable, defaultActionable)) {
2168
+ return {
2169
+ statusCode: 409,
2170
+ body: {
2171
+ ok: false,
2172
+ error: "pending input is not remotely actionable",
2173
+ hint: "Handle this approval directly in the local CLI session."
2174
+ }
2175
+ };
2176
+ }
2177
+
2178
+ const latestRun = findLatestRunForSession(sessionId);
2179
+ const pendingKind = resolvePendingKind(pending, latestRun);
2180
+ const isPlanConfirm = pendingKind === "PLAN_CONFIRM";
2181
+ const isQuestionRequest = pendingKind === "QUESTION_REQUEST";
2182
+
2183
+ const questionReplyText =
2184
+ type === "TEXT_REPLY"
2185
+ ? text
2186
+ : type === "APPROVE"
2187
+ ? "Yes, continue."
2188
+ : type === "REJECT"
2189
+ ? "No, do not proceed with that approach."
2190
+ : "";
2191
+ if (isQuestionRequest && !sanitizeTurnText(questionReplyText, 3000)) {
2192
+ return {
2193
+ statusCode: 400,
2194
+ body: {
2195
+ ok: false,
2196
+ error: "text reply is required for question requests",
2197
+ pendingInputId,
2198
+ sessionId
2199
+ }
2200
+ };
2201
+ }
2202
+ const actionText = isQuestionRequest
2203
+ ? `${sanitizeTurnText(questionReplyText, 3000)}\n`
2204
+ : type === "APPROVE"
2205
+ ? "approve\n"
2206
+ : type === "REJECT"
2207
+ ? "reject\n"
2208
+ : `${text}\n`;
2209
+ const targetRun = findActiveRunForSession(sessionId);
2210
+ let actionDelivered = writeToRunStdin(targetRun, actionText);
2211
+ let actionMode = actionDelivered ? "STDIN" : "NONE";
2212
+ let launchedFollowUpRun = null;
2213
+
2214
+ if (!actionDelivered && isQuestionRequest) {
2215
+ const launched = launchFollowUpRun(latestRun, sessionId, sanitizeTurnText(questionReplyText, 1500), {
2216
+ planMode: safeBoolean(latestRun?.launchOptions?.planMode, false),
2217
+ fullWorkspaceAccess: safeBoolean(latestRun?.launchOptions?.fullWorkspaceAccess, false),
2218
+ skipPermissions: safeBoolean(latestRun?.launchOptions?.skipPermissions, false)
2219
+ });
2220
+ if (launched.ok) {
2221
+ launchedFollowUpRun = launched.run;
2222
+ actionDelivered = true;
2223
+ actionMode = "QUESTION_FOLLOW_UP";
2224
+ }
2225
+ }
2226
+
2227
+ if (!actionDelivered && isPlanConfirm && type === "REJECT") {
2228
+ actionDelivered = true;
2229
+ actionMode = "PLAN_REJECT";
2230
+ }
2231
+
2232
+ if (!actionDelivered && isPlanConfirm && (type === "APPROVE" || (type === "TEXT_REPLY" && text))) {
2233
+ const launched = launchFollowUpRun(
2234
+ latestRun,
2235
+ sessionId,
2236
+ type === "TEXT_REPLY" ? text : "Proceed with implementation based on the approved plan.",
2237
+ {
2238
+ planMode: false
2239
+ }
2240
+ );
2241
+ if (!launched.ok) {
2242
+ return {
2243
+ statusCode: 409,
2244
+ body: {
2245
+ ok: false,
2246
+ error: launched.error || "unable to start follow-up run",
2247
+ delivered: false,
2248
+ pendingInputId,
2249
+ sessionId
2250
+ }
2251
+ };
2252
+ }
2253
+ launchedFollowUpRun = launched.run;
2254
+ actionDelivered = true;
2255
+ actionMode = "PLAN_LAUNCH";
2256
+ }
2257
+
2258
+ if (!actionDelivered && latestRun?.agentType === "CLAUDE") {
2259
+ const fallbackPrompt =
2260
+ type === "APPROVE"
2261
+ ? "Approved. Continue with the task."
2262
+ : type === "REJECT"
2263
+ ? "Rejected. Revise and propose a safer alternative."
2264
+ : text;
2265
+ const launched = launchFollowUpRun(latestRun, sessionId, fallbackPrompt);
2266
+ if (launched.ok) {
2267
+ launchedFollowUpRun = launched.run;
2268
+ actionDelivered = true;
2269
+ actionMode = "CLAUDE_FOLLOW_UP";
2270
+ }
2271
+ }
2272
+
2273
+ if (!actionDelivered) {
2274
+ return {
2275
+ statusCode: 409,
2276
+ body: {
2277
+ ok: false,
2278
+ error: "no active writable agent run for this approval",
2279
+ delivered: false,
2280
+ pendingInputId,
2281
+ sessionId
2282
+ }
2283
+ };
2284
+ }
2285
+
2286
+ const now = Date.now();
2287
+
2288
+ withPersist(() => {
2289
+ state.pendingInputs = state.pendingInputs.filter((item) => item.id !== pendingInputId);
2290
+ state.pendingHandledAt = {
2291
+ ...(state.pendingHandledAt && typeof state.pendingHandledAt === "object" ? state.pendingHandledAt : {}),
2292
+ [pendingInputId]: now
2293
+ };
2294
+
2295
+ const session = findSession(sessionId);
2296
+ if (session) {
2297
+ session.lastUpdated = now;
2298
+
2299
+ if (type === "REJECT") {
2300
+ if (actionMode === "CLAUDE_FOLLOW_UP" || actionMode === "QUESTION_FOLLOW_UP") {
2301
+ session.state = "RUNNING";
2302
+ } else {
2303
+ session.state = isPlanConfirm ? "COMPLETED" : "CANCELLED";
2304
+ }
2305
+ } else {
2306
+ session.state = "RUNNING";
2307
+ session.progress = Math.min(100, Math.max(0, safeNumber(session.progress, 0) + 3));
2308
+ }
2309
+
2310
+ syncSessionThreadFromSession(session, {
2311
+ updatedAt: now,
2312
+ lastMessageAt: now,
2313
+ lastRunId: launchedFollowUpRun?.id || targetRun?.id || latestRun?.id || null
2314
+ });
2315
+ }
2316
+
2317
+ const isTextualReply = isQuestionRequest || type === "TEXT_REPLY";
2318
+ const turnText = isQuestionRequest
2319
+ ? questionReplyText
2320
+ : type === "APPROVE"
2321
+ ? "Approved"
2322
+ : type === "REJECT"
2323
+ ? "Rejected"
2324
+ : text;
2325
+ appendChatTurn({
2326
+ sessionId,
2327
+ role: "USER",
2328
+ kind: isTextualReply ? "MESSAGE" : "APPROVAL_ACTION",
2329
+ text: turnText,
2330
+ createdAt: now,
2331
+ runId: launchedFollowUpRun?.id || targetRun?.id || null,
2332
+ approvalId: pendingInputId,
2333
+ source: "APPROVAL"
2334
+ });
2335
+
2336
+ const summary =
2337
+ type === "APPROVE"
2338
+ ? actionMode === "PLAN_LAUNCH"
2339
+ ? "Plan approved from phone. Implementation run started."
2340
+ : actionMode === "QUESTION_FOLLOW_UP"
2341
+ ? "Question answered from phone. Follow-up run started."
2342
+ : actionMode === "CLAUDE_FOLLOW_UP"
2343
+ ? "Approval sent from phone. Claude follow-up run started."
2344
+ : "Input approved from phone. Sent to running agent."
2345
+ : type === "REJECT"
2346
+ ? isPlanConfirm
2347
+ ? "Plan declined from phone."
2348
+ : actionMode === "QUESTION_FOLLOW_UP"
2349
+ ? "Question response sent from phone. Follow-up run started."
2350
+ : actionMode === "CLAUDE_FOLLOW_UP"
2351
+ ? "Rejection sent from phone. Claude follow-up run started."
2352
+ : "Input rejected from phone. Session cancelled."
2353
+ : actionMode === "PLAN_LAUNCH"
2354
+ ? `Plan follow-up started from phone: ${text || "(empty)"}`
2355
+ : actionMode === "QUESTION_FOLLOW_UP"
2356
+ ? `Question reply sent from phone: ${questionReplyText || "(empty)"}`
2357
+ : actionMode === "CLAUDE_FOLLOW_UP"
2358
+ ? `Reply sent from phone. Claude follow-up run started: ${text || "(empty)"}`
2359
+ : `Text reply from phone sent to agent: ${text || "(empty)"}`;
2360
+
2361
+ addEvent({ sessionId, summary, category: "ACTION", timestamp: now });
2362
+ });
2363
+
2364
+ return {
2365
+ statusCode: 200,
2366
+ body: {
2367
+ ok: true,
2368
+ delivered: actionMode === "STDIN",
2369
+ resolvedVia: actionMode,
2370
+ launchedRunId: launchedFollowUpRun?.id || null,
2371
+ pendingInputId,
2372
+ sessionId,
2373
+ type
2374
+ }
2375
+ };
2376
+ }
2377
+
2378
+ app.get("/health", (_req, res) => {
2379
+ const activeRuns = [...launcherRuns.values()].filter((run) => run.status === "RUNNING").length;
2380
+ const activeServices = [...managedServices.values()].filter((service) => {
2381
+ refreshManagedServiceRuntime(service);
2382
+ return service.status === "RUNNING" || service.status === "STARTING";
2383
+ }).length;
2384
+ res.json({ ok: true, service: "agent-bridge", activeRuns, activeServices });
2385
+ });
2386
+
2387
+ app.get("/api/bootstrap", (_req, res) => {
2388
+ res.json(buildBootstrapPayload());
2389
+ });
2390
+
2391
+ app.get("/api/sessions", (_req, res) => {
2392
+ const sessions = listSessionThreadSummaries();
2393
+ return res.json({ ok: true, sessions });
2394
+ });
2395
+
2396
+ app.get("/api/sessions/:id", (req, res) => {
2397
+ const sessionId = safeTrimmedText(req.params.id, 160);
2398
+ const summary = getSessionThreadSummary(sessionId);
2399
+ if (!summary) {
2400
+ return res.status(404).json({ ok: false, error: "session not found" });
2401
+ }
2402
+ return res.json({ ok: true, session: summary });
2403
+ });
2404
+
2405
+ app.get("/api/sessions/:id/turns", (req, res) => {
2406
+ const sessionId = safeTrimmedText(req.params.id, 160);
2407
+ const summary = getSessionThreadSummary(sessionId);
2408
+ if (!summary) {
2409
+ return res.status(404).json({ ok: false, error: "session not found" });
2410
+ }
2411
+ const turns = listChatTurnsForSession(sessionId);
2412
+ return res.json({ ok: true, sessionId, turns });
2413
+ });
2414
+
2415
+ app.post("/api/sessions/:id/messages", (req, res) => {
2416
+ const sessionId = safeTrimmedText(req.params.id, 160);
2417
+ const text = sanitizeTurnText(req.body?.text || req.body?.message || "", 3000);
2418
+ if (!sessionId) {
2419
+ return res.status(400).json({ ok: false, error: "session id is required" });
2420
+ }
2421
+ if (!text) {
2422
+ return res.status(400).json({ ok: false, error: "text is required" });
2423
+ }
2424
+
2425
+ const existingSession = findSession(sessionId);
2426
+ const existingThread = findSessionThread(sessionId);
2427
+ if (!existingSession && !existingThread) {
2428
+ return res.status(404).json({ ok: false, error: "session not found" });
2429
+ }
2430
+
2431
+ const targetRun = findActiveRunForSession(sessionId);
2432
+ const delivered = writeToRunStdin(targetRun, `${text}\n`);
2433
+ let turn = null;
2434
+
2435
+ withPersist(() => {
2436
+ const now = Date.now();
2437
+ let session = findSession(sessionId);
2438
+ const thread = findSessionThread(sessionId);
2439
+
2440
+ if (!session && thread) {
2441
+ session = upsertSession({
2442
+ id: thread.id,
2443
+ agentType: thread.agentType,
2444
+ title: thread.title,
2445
+ repo: thread.repo,
2446
+ branch: thread.branch,
2447
+ state: delivered ? "RUNNING" : "WAITING_INPUT",
2448
+ progress: 0,
2449
+ lastUpdated: now,
2450
+ workspacePath: thread.workspacePath,
2451
+ threadLookupKey: thread.lookupKey || thread.key
2452
+ });
2453
+ }
2454
+
2455
+ if (session) {
2456
+ session.lastUpdated = now;
2457
+ if (delivered) {
2458
+ session.state = "RUNNING";
2459
+ } else if (session.state === "RUNNING") {
2460
+ session.state = "WAITING_INPUT";
2461
+ }
2462
+ syncSessionThreadFromSession(session, {
2463
+ updatedAt: now,
2464
+ lastMessageAt: now,
2465
+ lastRunId: targetRun?.id || null
2466
+ });
2467
+ } else if (thread) {
2468
+ upsertSessionThread({
2469
+ id: thread.id,
2470
+ updatedAt: now,
2471
+ lastMessageAt: now,
2472
+ lastRunId: targetRun?.id || null
2473
+ });
2474
+ }
2475
+
2476
+ const questionPending = state.pendingInputs.find((item) => {
2477
+ if (item.sessionId !== sessionId) return false;
2478
+ const kind = safeTrimmedText(item?.meta?.kind, 64).toUpperCase();
2479
+ return kind === "QUESTION_REQUEST";
2480
+ });
2481
+ if (questionPending) {
2482
+ state.pendingInputs = state.pendingInputs.filter((item) => item.id !== questionPending.id);
2483
+ state.pendingHandledAt = {
2484
+ ...(state.pendingHandledAt && typeof state.pendingHandledAt === "object" ? state.pendingHandledAt : {}),
2485
+ [questionPending.id]: now
2486
+ };
2487
+ addEvent({
2488
+ sessionId,
2489
+ summary: "Question answered from phone message.",
2490
+ category: "ACTION",
2491
+ timestamp: now
2492
+ });
2493
+ }
2494
+
2495
+ turn = appendChatTurn({
2496
+ sessionId,
2497
+ role: "USER",
2498
+ kind: "MESSAGE",
2499
+ text,
2500
+ createdAt: now,
2501
+ runId: targetRun?.id || null,
2502
+ source: "MESSAGE_API"
2503
+ });
2504
+ addEvent({
2505
+ sessionId,
2506
+ summary: delivered
2507
+ ? `User message sent to running agent: ${text.slice(0, 180)}`
2508
+ : `User message queued for session: ${text.slice(0, 180)}`,
2509
+ category: "ACTION",
2510
+ timestamp: now
2511
+ });
2512
+ });
2513
+
2514
+ return res.status(201).json({ ok: true, delivered, turn });
2515
+ });
2516
+
2517
+ app.post("/api/sessions/:id/approvals/:approvalId/action", (req, res) => {
2518
+ let actionType = req.body?.type ?? req.body?.action;
2519
+ if (!actionType && typeof req.body?.approved === "boolean") {
2520
+ actionType = req.body.approved ? "APPROVE" : "REJECT";
2521
+ }
2522
+
2523
+ const result = applyPendingAction({
2524
+ pendingInputId: req.params.approvalId,
2525
+ sessionId: req.params.id,
2526
+ type: actionType,
2527
+ text: req.body?.text
2528
+ });
2529
+
2530
+ return res.status(result.statusCode).json(result.body);
2531
+ });
2532
+
2533
+ app.post("/api/sessions/upsert", (req, res) => {
2534
+ if (!req.body?.id) {
2535
+ return res.status(400).json({ ok: false, error: "id is required" });
2536
+ }
2537
+
2538
+ withPersist(() => {
2539
+ upsertSession(req.body);
2540
+ });
2541
+
2542
+ return res.json({ ok: true, session: findSession(req.body.id) });
2543
+ });
2544
+
2545
+ app.post("/api/events/add", (req, res) => {
2546
+ withPersist(() => {
2547
+ addEvent(req.body);
2548
+ });
2549
+
2550
+ return res.json({ ok: true });
2551
+ });
2552
+
2553
+ app.post("/api/pending/add", (req, res) => {
2554
+ let pending = null;
2555
+
2556
+ withPersist(() => {
2557
+ pending = addPendingInput(req.body);
2558
+
2559
+ if (pending) {
2560
+ addEvent({
2561
+ sessionId: pending.sessionId,
2562
+ summary: `Input requested: ${pending.prompt}`,
2563
+ category: "INPUT",
2564
+ timestamp: pending.requestedAt
2565
+ });
2566
+ }
2567
+ });
2568
+
2569
+ if (!pending) {
2570
+ return res.status(400).json({ ok: false, error: "sessionId and prompt are required" });
2571
+ }
2572
+
2573
+ return res.json({ ok: true, pending });
2574
+ });
2575
+
2576
+ app.post("/api/actions", (req, res) => {
2577
+ const result = applyPendingAction(req.body || {});
2578
+ return res.status(result.statusCode).json(result.body);
2579
+ });
2580
+
2581
+ app.use("/api/launcher", requireBridgeToken);
2582
+
2583
+ app.get("/api/launcher/config", (_req, res) => {
2584
+ return res.json({
2585
+ ok: true,
2586
+ bridgeUrl: BRIDGE_URL,
2587
+ tokenRequired: Boolean(BRIDGE_TOKEN),
2588
+ allowAnyWorkspace: ALLOW_ANY_WORKSPACE,
2589
+ workspaceRoots: WORKSPACE_ROOTS,
2590
+ defaultWorkspaceRoot: getWorkspaceRootSetting()
2591
+ });
2592
+ });
2593
+
2594
+ app.get("/api/launcher/workspaces", (req, res) => {
2595
+ const limit = Math.max(1, Math.min(200, safeInt(req.query?.limit, 80)));
2596
+ const workspaces = listWorkspaceCandidates(limit);
2597
+ return res.json({
2598
+ ok: true,
2599
+ allowAnyWorkspace: ALLOW_ANY_WORKSPACE,
2600
+ workspaceRoots: WORKSPACE_ROOTS,
2601
+ defaultWorkspaceRoot: getWorkspaceRootSetting(),
2602
+ workspaces
2603
+ });
2604
+ });
2605
+
2606
+ app.post("/api/launcher/workspaces/create", (req, res) => {
2607
+ const result = createWorkspaceFolder(req.body || {});
2608
+ if (!result.workspace) {
2609
+ return res.status(result.statusCode || 400).json({
2610
+ ok: false,
2611
+ error: result.error || "unable to create workspace",
2612
+ workspaceRoots: result.workspaceRoots || WORKSPACE_ROOTS
2613
+ });
2614
+ }
2615
+
2616
+ return res.status(result.statusCode || 201).json({
2617
+ ok: true,
2618
+ created: result.created,
2619
+ workspace: result.workspace,
2620
+ parentPath: result.parentPath
2621
+ });
2622
+ });
2623
+
2624
+ app.get("/api/launcher/runs", (_req, res) => {
2625
+ const runs = [...launcherRuns.values()]
2626
+ .sort((a, b) => b.createdAt - a.createdAt)
2627
+ .map((run) => serializeRun(run));
2628
+
2629
+ return res.json({ ok: true, runs });
2630
+ });
2631
+
2632
+ app.get("/api/launcher/services", (_req, res) => {
2633
+ const services = [...managedServices.values()]
2634
+ .map((service) => serializeManagedService(service))
2635
+ .sort((a, b) => safeNumber(b.createdAt, 0) - safeNumber(a.createdAt, 0));
2636
+
2637
+ return res.json({ ok: true, services });
2638
+ });
2639
+
2640
+ app.post("/api/launcher/services/start", (req, res) => {
2641
+ const started = startManagedService(req.body || {});
2642
+ if (!started.service) {
2643
+ return res.status(started.statusCode || 400).json({
2644
+ ok: false,
2645
+ error: started.error || "unable to start background service",
2646
+ details: started
2647
+ });
2648
+ }
2649
+
2650
+ return res.status(started.statusCode || 201).json({
2651
+ ok: true,
2652
+ service: serializeManagedService(started.service),
2653
+ hint: started.service.localhostUrl
2654
+ ? `Service started. Open ${started.service.localhostUrl} (or use relay preview from phone).`
2655
+ : "Service started."
2656
+ });
2657
+ });
2658
+
2659
+ app.get("/api/launcher/runs/:runId", (req, res) => {
2660
+ const run = launcherRuns.get(req.params.runId);
2661
+ if (!run) {
2662
+ return res.status(404).json({ ok: false, error: "run not found" });
2663
+ }
2664
+ return res.json({ ok: true, run: serializeRun(run) });
2665
+ });
2666
+
2667
+ app.post("/api/launcher/start", (req, res) => {
2668
+ const started = createLauncherRun(req.body || {});
2669
+ if (!started.run) {
2670
+ return res.status(400).json({ ok: false, error: started.error || "unable to start task", details: started });
2671
+ }
2672
+
2673
+ return res.status(201).json({
2674
+ ok: true,
2675
+ run: serializeRun(started.run),
2676
+ launchHint:
2677
+ "Session started on laptop. Poll /api/launcher/runs/{id} or /api/bootstrap for status updates."
2678
+ });
2679
+ });
2680
+
2681
+ app.post("/api/launcher/runs/:runId/stop", (req, res) => {
2682
+ const run = launcherRuns.get(req.params.runId);
2683
+ if (!run) {
2684
+ return res.status(404).json({ ok: false, error: "run not found" });
2685
+ }
2686
+
2687
+ if (!run.child || run.status !== "RUNNING") {
2688
+ return res.status(409).json({ ok: false, error: "run is not currently active", run: serializeRun(run) });
2689
+ }
2690
+
2691
+ run.stopRequested = true;
2692
+ const signal = String(req.body?.signal || "SIGTERM");
2693
+ run.child.kill(signal);
2694
+
2695
+ setTimeout(() => {
2696
+ if (run.child) {
2697
+ run.child.kill("SIGKILL");
2698
+ }
2699
+ }, 1200);
2700
+
2701
+ withPersist(() => {
2702
+ addEvent({
2703
+ sessionId: run.sessionId,
2704
+ summary: `Stop requested from phone (${signal}).`,
2705
+ category: "ACTION",
2706
+ timestamp: Date.now()
2707
+ });
2708
+ });
2709
+
2710
+ return res.json({ ok: true, run: serializeRun(run) });
2711
+ });
2712
+
2713
+ app.post("/api/launcher/services/:serviceId/stop", (req, res) => {
2714
+ const service = managedServices.get(req.params.serviceId);
2715
+ const stopped = stopManagedService(service, req.body?.signal);
2716
+ if (!stopped.service) {
2717
+ return res.status(stopped.statusCode || 400).json({
2718
+ ok: false,
2719
+ error: stopped.error || "unable to stop service"
2720
+ });
2721
+ }
2722
+
2723
+ return res.status(stopped.statusCode || 200).json({
2724
+ ok: true,
2725
+ service: serializeManagedService(stopped.service)
2726
+ });
2727
+ });
2728
+
2729
+ app.post("/api/settings/update", (req, res) => {
2730
+ const payload = req.body && typeof req.body === "object" ? req.body : {};
2731
+ const wantsWorkspaceRoot = Object.prototype.hasOwnProperty.call(payload, "workspaceRoot");
2732
+ const workspaceRootRaw = safeTrimmedText(payload.workspaceRoot, 1000);
2733
+ let nextWorkspaceRoot = getWorkspaceRootSetting();
2734
+
2735
+ if (wantsWorkspaceRoot) {
2736
+ if (!workspaceRootRaw) {
2737
+ nextWorkspaceRoot = DEFAULT_WORKSPACE_ROOT;
2738
+ } else {
2739
+ const normalizedRoot = normalizeExistingDirectoryPath(workspaceRootRaw);
2740
+ if (!normalizedRoot) {
2741
+ return res.status(400).json({ ok: false, error: "workspaceRoot must be an existing local directory" });
2742
+ }
2743
+ if (!ALLOW_ANY_WORKSPACE && !WORKSPACE_ROOTS.some((root) => isPathInside(root, normalizedRoot))) {
2744
+ return res.status(403).json({
2745
+ ok: false,
2746
+ error: "workspaceRoot is outside allowed roots",
2747
+ workspaceRoots: WORKSPACE_ROOTS
2748
+ });
2749
+ }
2750
+ nextWorkspaceRoot = normalizedRoot;
2751
+ }
2752
+ }
2753
+
2754
+ withPersist(() => {
2755
+ state.settings = {
2756
+ ...state.settings,
2757
+ ...payload,
2758
+ workspaceRoot: nextWorkspaceRoot,
2759
+ darkLocked: true
2760
+ };
2761
+ });
2762
+
2763
+ return res.json({ ok: true, settings: state.settings });
2764
+ });
2765
+
2766
+ app.post("/api/reset", (_req, res) => {
2767
+ for (const run of launcherRuns.values()) {
2768
+ if (run.child) {
2769
+ run.stopRequested = true;
2770
+ run.child.kill("SIGTERM");
2771
+ }
2772
+ }
2773
+ launcherRuns.clear();
2774
+
2775
+ for (const service of managedServices.values()) {
2776
+ service.stopRequested = true;
2777
+ const pid = safeInt(service.pid, 0);
2778
+ try {
2779
+ if (pid > 0) {
2780
+ process.kill(pid, "SIGTERM");
2781
+ } else if (service.child) {
2782
+ service.child.kill("SIGTERM");
2783
+ }
2784
+ } catch {
2785
+ // ignore stop errors during reset
2786
+ }
2787
+ }
2788
+ managedServices.clear();
2789
+
2790
+ withPersist(() => {
2791
+ state = buildDefaultState();
2792
+ });
2793
+
2794
+ return res.json({ ok: true });
2795
+ });
2796
+
2797
+ app.post("/api/import/snapshot", (req, res) => {
2798
+ withPersist(() => {
2799
+ mergeDirectSnapshot(req.body);
2800
+ });
2801
+
2802
+ return res.json({ ok: true });
2803
+ });
2804
+
2805
+ app.listen(PORT, () => {
2806
+ setInterval(() => {
2807
+ withPersist(() => {
2808
+ const snapshot = collectDirectSnapshot();
2809
+ mergeDirectSnapshot(snapshot);
2810
+ });
2811
+ }, DIRECT_SNAPSHOT_POLL_INTERVAL_MS);
2812
+ });