ftown-bridge 0.19.1 → 0.19.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/dist/agent-commands.d.ts +3 -17
  2. package/dist/agent-commands.js +8 -94
  3. package/dist/agent-commands.js.map +1 -1
  4. package/dist/bridge-auth.d.ts +21 -0
  5. package/dist/bridge-auth.js +48 -0
  6. package/dist/bridge-auth.js.map +1 -0
  7. package/dist/centrifugo-client.js +39 -0
  8. package/dist/centrifugo-client.js.map +1 -1
  9. package/dist/command-rpc.d.ts +15 -0
  10. package/dist/command-rpc.js +249 -0
  11. package/dist/command-rpc.js.map +1 -0
  12. package/dist/create-ftown-session.d.ts +43 -0
  13. package/dist/create-ftown-session.js +115 -9
  14. package/dist/create-ftown-session.js.map +1 -1
  15. package/dist/ftown-sessions-cli.js +74 -4
  16. package/dist/ftown-sessions-cli.js.map +1 -1
  17. package/dist/harness-cli.js +1 -13
  18. package/dist/harness-cli.js.map +1 -1
  19. package/dist/harness-installer.js +4 -2
  20. package/dist/harness-installer.js.map +1 -1
  21. package/dist/harness-registry.d.ts +189 -0
  22. package/dist/harness-registry.js +218 -0
  23. package/dist/harness-registry.js.map +1 -0
  24. package/dist/index.js +71 -721
  25. package/dist/index.js.map +1 -1
  26. package/dist/install-ftown-cli.js +12 -3
  27. package/dist/install-ftown-cli.js.map +1 -1
  28. package/dist/local-api-server.d.ts +14 -13
  29. package/dist/local-api-server.js +180 -424
  30. package/dist/local-api-server.js.map +1 -1
  31. package/dist/loop-controller.d.ts +87 -0
  32. package/dist/loop-controller.js +133 -0
  33. package/dist/loop-controller.js.map +1 -0
  34. package/dist/loop-run-store.js +2 -8
  35. package/dist/loop-run-store.js.map +1 -1
  36. package/dist/loop-validation.d.ts +1 -0
  37. package/dist/loop-validation.js +3 -8
  38. package/dist/loop-validation.js.map +1 -1
  39. package/dist/mail-delivery.d.ts +114 -0
  40. package/dist/mail-delivery.js +315 -0
  41. package/dist/mail-delivery.js.map +1 -0
  42. package/dist/session-controller.d.ts +107 -0
  43. package/dist/session-controller.js +141 -0
  44. package/dist/session-controller.js.map +1 -0
  45. package/dist/session-ids.d.ts +19 -0
  46. package/dist/session-ids.js +72 -0
  47. package/dist/session-ids.js.map +1 -0
  48. package/dist/session-resurrection.d.ts +24 -0
  49. package/dist/session-resurrection.js +128 -0
  50. package/dist/session-resurrection.js.map +1 -1
  51. package/dist/spawn-stagger.d.ts +22 -0
  52. package/dist/spawn-stagger.js +34 -0
  53. package/dist/spawn-stagger.js.map +1 -0
  54. package/dist/terminal-pump.d.ts +61 -0
  55. package/dist/terminal-pump.js +193 -0
  56. package/dist/terminal-pump.js.map +1 -0
  57. package/dist/types.d.ts +29 -4
  58. package/dist/usage-collector.d.ts +42 -0
  59. package/dist/usage-collector.js +317 -0
  60. package/dist/usage-collector.js.map +1 -0
  61. package/dist/wire-types.d.ts +124 -0
  62. package/dist/wire-types.js +28 -0
  63. package/dist/wire-types.js.map +1 -0
  64. package/dist/workflow-runner-cli.js +2 -1
  65. package/dist/workflow-runner-cli.js.map +1 -1
  66. package/dist/workflow-runner.d.ts +2 -1
  67. package/dist/workflow-runner.js.map +1 -1
  68. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,8 +4,6 @@ import { v4 as uuidv4 } from 'uuid';
4
4
  import { resolve, dirname, join } from 'node:path';
5
5
  import { homedir, hostname as osHostname } from 'node:os';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { exec } from 'node:child_process';
8
- import { promisify } from 'node:util';
9
7
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, unlinkSync } from 'node:fs';
10
8
  import { randomBytes } from 'node:crypto';
11
9
  import { cleanup as ndcCleanup } from 'node-datachannel';
@@ -31,67 +29,22 @@ import { installFtownWorkflowsCli } from './install-ftown-workflows-cli.js';
31
29
  import { installFtownEnvCli } from './install-ftown-env-cli.js';
32
30
  import { installFtownCommandCli } from './install-ftown-command-cli.js';
33
31
  import { ensureFtownOnPath } from './ensure-ftown-path.js';
34
- import { registerSessionWorkspace, unregisterSession } from './session-registry.js';
35
- import { createFtownSession, findMissingProviderAuth, WorkingDirMissingError } from './create-ftown-session.js';
36
- import { loadProviderEnv } from './provider-env-store.js';
32
+ import { unregisterSession } from './session-registry.js';
33
+ import { createFtownSession } from './create-ftown-session.js';
34
+ import { createCommandHandler } from './command-rpc.js';
37
35
  import { removeFtownSession } from './remove-ftown-session.js';
38
- import { shouldResurrectStoredSession } from './session-resurrection.js';
39
- import { createLoop, deleteLoop, getLoop, listLoops, mutateLoopRuntime, updateLoop } from './loop-store.js';
40
- import { deleteLoopRunRecords, listLoopRunRecordsWithFallback } from './loop-run-store.js';
36
+ import { SessionResurrection } from './session-resurrection.js';
37
+ import { TerminalPump } from './terminal-pump.js';
38
+ import { collectSessionUsage } from './usage-collector.js';
39
+ import { AgentSessionIdPersister } from './session-ids.js';
40
+ import { fetchBridgeToken, refreshBridgeToken } from './bridge-auth.js';
41
+ import { listLoops } from './loop-store.js';
41
42
  import { LoopScheduler, LOOP_TICK_INTERVAL_MS } from './loop-scheduler.js';
42
- import { validateLoopDraft, validateLoopPatch } from './loop-validation.js';
43
- import { buildSessionCommand } from './agent-commands.js';
44
- import { isTmuxAvailable, killTmuxSession, listFtownTmuxSessions } from './tmux.js';
43
+ import { LoopController } from './loop-controller.js';
44
+ import { SessionController } from './session-controller.js';
45
45
  import { runPairing } from './pairing-client.js';
46
- const execAsync = promisify(exec);
47
46
  const __filename = fileURLToPath(import.meta.url);
48
47
  const __dirname = dirname(__filename);
49
- async function fetchBridgeToken(apiUrl, authToken, bridgeId, local) {
50
- const res = await fetch(`${apiUrl}/api/auth/bridge`, {
51
- method: 'POST',
52
- headers: { 'Content-Type': 'application/json' },
53
- body: JSON.stringify({
54
- token: authToken,
55
- bridgeId,
56
- hostname: osHostname(),
57
- // Embedded in the Centrifugo connection JWT `info` claim so the owning
58
- // user's clients discover the loopback WS rung via presence (L2).
59
- localPort: local.localPort,
60
- localNonce: local.localNonce,
61
- }),
62
- });
63
- if (!res.ok) {
64
- const body = await res.text();
65
- throw new Error(`Bridge auth failed (${res.status}): ${body}`);
66
- }
67
- return res.json();
68
- }
69
- /**
70
- * Exchange a (rotating) refresh token for a fresh Centrifugo connect token.
71
- *
72
- * The server rotates the refresh token on every use (audit finding F3): the
73
- * response carries a NEW refreshToken that supersedes the one just sent, so the
74
- * caller must persist it and send the newest value next time. Reusing an old
75
- * refresh token is rejected.
76
- */
77
- async function refreshBridgeToken(apiUrl, refreshToken, bridgeId, local) {
78
- const res = await fetch(`${apiUrl}/api/auth/bridge/refresh`, {
79
- method: 'POST',
80
- headers: { 'Content-Type': 'application/json' },
81
- body: JSON.stringify({
82
- refreshToken,
83
- bridgeId,
84
- hostname: osHostname(),
85
- localPort: local.localPort,
86
- localNonce: local.localNonce,
87
- }),
88
- });
89
- if (!res.ok) {
90
- const body = await res.text();
91
- throw new Error(`Token refresh failed (${res.status}): ${body}`);
92
- }
93
- return res.json();
94
- }
95
48
  /**
96
49
  * Default data dir is ~/.ftown/data (machine-stable, like the rest of ~/.ftown).
97
50
  * Older bridges defaulted to ./data relative to the launch cwd — if that legacy
@@ -338,6 +291,58 @@ program
338
291
  removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
339
292
  });
340
293
  localApiServer.setLoopApi({ bridgeId, scheduler });
294
+ // Terminal output pump: buffers runner PTY output and owns the runner
295
+ // 'complete'/'error' lifecycle handlers (loop-run status transitions the
296
+ // loop scheduler depends on).
297
+ const pump = new TerminalPump({
298
+ store,
299
+ terminalManager,
300
+ publishTerminalData: (sid, data) => publishRouter.publishTerminalData(sid, data),
301
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
302
+ publishHookEvent: (sid, event) => centrifugo.publishHookEvent(userId, sid, event),
303
+ unregisterSession: (sid) => unregisterSession(sid),
304
+ collectUsage: (session) => collectSessionUsage(session),
305
+ });
306
+ pump.attach(runner);
307
+ // Resurrection engine for sessions left live by a previous bridge process.
308
+ const resurrection = new SessionResurrection({
309
+ store,
310
+ runner,
311
+ bridgeId,
312
+ sessionFactoryDeps,
313
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
314
+ wireTerminalInput,
315
+ });
316
+ // Persists Claude/Cursor/Codex-native session ids from hook events.
317
+ const agentIdPersister = new AgentSessionIdPersister({
318
+ store,
319
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
320
+ });
321
+ // Transport-agnostic controllers: each loop/session operation is defined
322
+ // once here; the RPC switch below and the local HTTP router are thin
323
+ // adapters that only marshal wire formats around these calls.
324
+ const loopController = new LoopController({
325
+ bridgeId,
326
+ scheduler,
327
+ isSessionRunning: (sid) => runner.isRunning(sid),
328
+ publishLoopUpdate: (loop) => centrifugo.publishLoopUpdate(userId, loop),
329
+ publishLoopRemoved: (loopId) => centrifugo.publishLoopRemoved(userId, loopId),
330
+ listWireSessions: async () => (await store.listSessions()).map(toWireSession),
331
+ loadTerminalLog: (sid) => store.loadTerminalLog(sid),
332
+ });
333
+ const sessionController = new SessionController({
334
+ store,
335
+ runner,
336
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
337
+ removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
338
+ sessionFactory: sessionFactoryDeps,
339
+ collectUsage: (session) => collectSessionUsage(session),
340
+ publishSyntheticStop: (sid, reason) => pump.publishSyntheticStop(sid, reason),
341
+ withSessionWrite: (sid, task) => pump.withSessionWrite(sid, task),
342
+ unregisterSession: (sid) => unregisterSession(sid),
343
+ flushTerminalBuffer: (sid) => pump.flush(sid),
344
+ destroyTerminal: (sid) => terminalManager.destroy(sid),
345
+ });
341
346
  // Compiled sibling of this module (running from dist), else the sibling
342
347
  // dist/ directory (running from src via `tsx watch` in dev). If neither
343
348
  // exists — dev mode without a build — skip installation instead of
@@ -454,162 +459,6 @@ program
454
459
  runner.resize(sid, cols, rows);
455
460
  terminalManager.resize(sid, cols, rows);
456
461
  }
457
- const outputBuffers = new Map();
458
- const flushTimers = new Map();
459
- const FLUSH_INTERVAL_MS = 16;
460
- const MAX_BUFFER_BYTES = 32_000;
461
- function flushBuffer(sessionId) {
462
- const buf = outputBuffers.get(sessionId);
463
- if (!buf)
464
- return;
465
- outputBuffers.delete(sessionId);
466
- const timer = flushTimers.get(sessionId);
467
- if (timer)
468
- clearTimeout(timer);
469
- flushTimers.delete(sessionId);
470
- store.appendTerminalData(sessionId, buf).catch((err) => {
471
- console.error(`[Bridge] Failed to store terminal data for ${sessionId}:`, err);
472
- });
473
- publishRouter.publishTerminalData(sessionId, buf);
474
- }
475
- runner.on('data', (sessionId, data) => {
476
- terminalManager.write(sessionId, data);
477
- const existing = outputBuffers.get(sessionId) ?? '';
478
- outputBuffers.set(sessionId, existing + data);
479
- if ((existing.length + data.length) >= MAX_BUFFER_BYTES) {
480
- flushBuffer(sessionId);
481
- }
482
- else if (!flushTimers.has(sessionId)) {
483
- flushTimers.set(sessionId, setTimeout(() => flushBuffer(sessionId), FLUSH_INTERVAL_MS));
484
- }
485
- });
486
- // Per-session write queue so concurrent load/modify/save flows (hooks,
487
- // runner exit handlers, stop_session) cannot interleave and resurrect a
488
- // stale status.
489
- const sessionWrites = new Map();
490
- function withSessionWrite(sessionId, task) {
491
- const prev = sessionWrites.get(sessionId) ?? Promise.resolve();
492
- const run = prev.then(task);
493
- const settled = run.catch(() => undefined);
494
- sessionWrites.set(sessionId, settled);
495
- void settled.finally(() => {
496
- if (sessionWrites.get(sessionId) === settled) {
497
- sessionWrites.delete(sessionId);
498
- }
499
- });
500
- return run;
501
- }
502
- // Synthetic activity reset: some stop paths never produce a real Stop/stop
503
- // hook (Claude's Stop hook doesn't fire on user interrupt, SessionEnd may be
504
- // absent, runner exits/crashes). Publishing a synthetic Stop event guarantees
505
- // every dashboard clears its "thinking"/"tool_use" indicator. Idle is
506
- // idempotent, so an extra synthetic Stop after a real one is harmless.
507
- function publishSyntheticStop(sessionId, reason) {
508
- centrifugo.publishHookEvent(userId, sessionId, {
509
- type: 'hook_event',
510
- eventName: 'Stop',
511
- data: { synthetic: true, reason },
512
- }).catch((err) => {
513
- console.error(`[Bridge] Failed to publish synthetic stop for ${sessionId}:`, err);
514
- });
515
- }
516
- runner.on('complete', (sessionId) => {
517
- flushBuffer(sessionId);
518
- publishSyntheticStop(sessionId, 'complete');
519
- withSessionWrite(sessionId, async () => {
520
- const session = await store.loadSession(sessionId);
521
- if (session) {
522
- session.status = 'completed';
523
- session.updatedAt = new Date().toISOString();
524
- await store.saveSession(session);
525
- await centrifugo.publishSessionUpdate(userId, session);
526
- }
527
- console.log(`[Bridge] Session ${sessionId} completed`);
528
- }).catch((err) => {
529
- console.error(`[Bridge] Failed to handle completion for session ${sessionId}:`, err);
530
- }).finally(() => {
531
- unregisterSession(sessionId);
532
- });
533
- });
534
- runner.on('error', (sessionId, error) => {
535
- flushBuffer(sessionId);
536
- publishSyntheticStop(sessionId, 'error');
537
- withSessionWrite(sessionId, async () => {
538
- const session = await store.loadSession(sessionId);
539
- if (session) {
540
- session.status = 'error';
541
- session.updatedAt = new Date().toISOString();
542
- await store.saveSession(session);
543
- await centrifugo.publishSessionUpdate(userId, session);
544
- }
545
- console.error(`[Bridge] Session ${sessionId} error:`, error.message);
546
- }).catch((err) => {
547
- console.error(`[Bridge] Failed to handle error for session ${sessionId}:`, err);
548
- }).finally(() => {
549
- unregisterSession(sessionId);
550
- terminalManager.destroy(sessionId);
551
- });
552
- });
553
- // Last persisted agent session ids, to skip disk reads on the hot hook path.
554
- const agentIdCache = new Map();
555
- async function persistAgentSessionIds(hookEvent) {
556
- // Workspace-fallback attribution may come from a foreign agent the user
557
- // ran manually in the same directory; never persist its ids.
558
- if (hookEvent.source === 'workspace')
559
- return;
560
- const rawAgentId = hookEvent.data['session_id'];
561
- const rawCursorId = hookEvent.data['conversation_id'];
562
- // Claude Code AND Codex hooks carry session_id (which field it lands in
563
- // depends on the session's shellType); Cursor hooks carry conversation_id.
564
- const agentId = typeof rawAgentId === 'string' && rawAgentId ? rawAgentId : undefined;
565
- const cursorId = typeof rawCursorId === 'string' && rawCursorId ? rawCursorId : undefined;
566
- if (!agentId && !cursorId)
567
- return;
568
- const cached = agentIdCache.get(hookEvent.sessionId);
569
- if (cached
570
- && (!agentId || (cached.isCodex ? cached.codex === agentId : cached.claude === agentId))
571
- && (!cursorId || cached.cursor === cursorId)) {
572
- return;
573
- }
574
- const session = await store.loadSession(hookEvent.sessionId);
575
- if (!session)
576
- return;
577
- const isCodex = session.shellType === 'codex';
578
- let changed = false;
579
- if (agentId) {
580
- if (isCodex && session.codexSessionId !== agentId) {
581
- session.codexSessionId = agentId;
582
- changed = true;
583
- }
584
- else if (!isCodex && session.claudeSessionId !== agentId) {
585
- session.claudeSessionId = agentId;
586
- changed = true;
587
- }
588
- }
589
- if (cursorId && session.cursorSessionId !== cursorId) {
590
- session.cursorSessionId = cursorId;
591
- changed = true;
592
- }
593
- if (!changed) {
594
- agentIdCache.set(hookEvent.sessionId, {
595
- claude: session.claudeSessionId,
596
- cursor: session.cursorSessionId,
597
- codex: session.codexSessionId,
598
- isCodex,
599
- });
600
- return;
601
- }
602
- session.updatedAt = new Date().toISOString();
603
- await store.saveSession(session);
604
- // Cache only after a successful save, so a failed persist is retried.
605
- agentIdCache.set(hookEvent.sessionId, {
606
- claude: session.claudeSessionId,
607
- cursor: session.cursorSessionId,
608
- codex: session.codexSessionId,
609
- isCodex,
610
- });
611
- await centrifugo.publishSessionUpdate(userId, session);
612
- }
613
462
  localApiServer.on('event', (hookEvent) => {
614
463
  centrifugo.publishHookEvent(userId, hookEvent.sessionId, {
615
464
  type: 'hook_event',
@@ -618,515 +467,16 @@ program
618
467
  }).catch((err) => {
619
468
  console.error('[Bridge] Failed to handle hook event:', err);
620
469
  });
621
- withSessionWrite(hookEvent.sessionId, () => persistAgentSessionIds(hookEvent)).catch((err) => {
470
+ pump.withSessionWrite(hookEvent.sessionId, () => agentIdPersister.persist(hookEvent)).catch((err) => {
622
471
  console.error(`[Bridge] Failed to persist agent session id for ${hookEvent.sessionId}:`, err);
623
472
  });
624
473
  });
625
- async function handleCommand(command) {
626
- console.log(`[Bridge] Received command: ${command.type} (requestId: ${command.requestId})`);
627
- const payloadBridgeId = command.payload.bridgeId;
628
- if (payloadBridgeId && payloadBridgeId !== bridgeId) {
629
- return;
630
- }
631
- let response;
632
- try {
633
- switch (command.type) {
634
- case 'create_session': {
635
- const payload = command.payload;
636
- const session = await createFtownSession({
637
- store,
638
- runner,
639
- centrifugo,
640
- userId,
641
- bridgeId,
642
- hookPort,
643
- hookToken: apiToken,
644
- notifyScriptPath,
645
- wireTerminalInput,
646
- }, {
647
- command: payload.command,
648
- prompt: payload.prompt,
649
- name: payload.name,
650
- workingDir: payload.workingDir,
651
- shellType: payload.shellType,
652
- model: payload.model,
653
- claudeSessionId: payload.claudeSessionId,
654
- cursorSessionId: payload.cursorSessionId,
655
- codexSessionId: payload.codexSessionId,
656
- env: payload.env,
657
- parentSessionId: payload.parentSessionId,
658
- initialInput: payload.initialInput,
659
- initialInputDelay: payload.initialInputDelay,
660
- orchestrator: payload.orchestrator,
661
- suppressBriefing: payload.suppressBriefing,
662
- createMissingWorkingDir: payload.createMissingWorkingDir,
663
- });
664
- response = { requestId: command.requestId, success: true, data: { session: toWireSession(session) } };
665
- break;
666
- }
667
- case 'stop_session': {
668
- const payload = command.payload;
669
- if (!payload.sessionId) {
670
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
671
- break;
672
- }
673
- const stopped = runner.stop(payload.sessionId);
674
- if (stopped) {
675
- publishSyntheticStop(payload.sessionId, 'stopped');
676
- await withSessionWrite(payload.sessionId, async () => {
677
- const session = await store.loadSession(payload.sessionId);
678
- if (session) {
679
- session.status = 'completed';
680
- session.updatedAt = new Date().toISOString();
681
- await store.saveSession(session);
682
- await centrifugo.publishSessionUpdate(userId, session);
683
- }
684
- });
685
- unregisterSession(payload.sessionId);
686
- }
687
- response = { requestId: command.requestId, success: true, data: { stopped } };
688
- break;
689
- }
690
- case 'list_sessions': {
691
- const sessions = await store.listSessions();
692
- response = { requestId: command.requestId, success: true, data: { sessions: sessions.map(toWireSession) } };
693
- break;
694
- }
695
- case 'get_history': {
696
- const payload = command.payload;
697
- if (!payload.sessionId) {
698
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
699
- break;
700
- }
701
- const session = await store.loadSession(payload.sessionId);
702
- response = { requestId: command.requestId, success: true, data: { session: session ? toWireSession(session) : session } };
703
- break;
704
- }
705
- case 'retry_session': {
706
- const payload = command.payload;
707
- if (!payload.sessionId) {
708
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
709
- break;
710
- }
711
- const existingSession = await store.loadSession(payload.sessionId);
712
- if (!existingSession) {
713
- response = { requestId: command.requestId, success: false, error: 'Session not found' };
714
- break;
715
- }
716
- if (existingSession.status === 'running') {
717
- response = { requestId: command.requestId, success: false, error: 'Session is already running' };
718
- break;
719
- }
720
- if (!existingSession.command) {
721
- response = { requestId: command.requestId, success: false, error: 'Session has no command (created before v0.2.0)' };
722
- break;
723
- }
724
- existingSession.status = 'running';
725
- existingSession.bridgeId = bridgeId;
726
- existingSession.updatedAt = new Date().toISOString();
727
- await store.saveSession(existingSession);
728
- await centrifugo.publishSessionUpdate(userId, existingSession);
729
- runner.run(existingSession.id, existingSession.command, {
730
- workingDir: existingSession.workingDir,
731
- env: existingSession.env,
732
- hookPort,
733
- hookToken: apiToken,
734
- parentSessionId: existingSession.parentSessionId,
735
- });
736
- centrifugo.subscribeToTerminalInput(userId, existingSession.id, (sid, data) => { runner.write(sid, data); }, (sid, cols, rows) => { handleClientResize(sid, cols, rows); }, (sid) => { publishScreenDump(sid); });
737
- response = { requestId: command.requestId, success: true, data: { session: toWireSession(existingSession) } };
738
- break;
739
- }
740
- case 'update_session_parent': {
741
- const payload = command.payload;
742
- if (!payload.sessionId) {
743
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
744
- break;
745
- }
746
- const target = await store.loadSession(payload.sessionId);
747
- if (!target) {
748
- response = { requestId: command.requestId, success: false, error: 'Session not found' };
749
- break;
750
- }
751
- if (payload.parentSessionId === null || payload.parentSessionId === undefined || payload.parentSessionId === '') {
752
- target.parentSessionId = undefined;
753
- }
754
- else if (payload.parentSessionId === target.id) {
755
- response = { requestId: command.requestId, success: false, error: 'Session cannot be its own parent' };
756
- break;
757
- }
758
- else {
759
- const proposed = await store.loadSession(payload.parentSessionId);
760
- if (!proposed) {
761
- response = { requestId: command.requestId, success: false, error: 'Parent session not found' };
762
- break;
763
- }
764
- target.parentSessionId = proposed.parentSessionId ?? proposed.id;
765
- }
766
- target.updatedAt = new Date().toISOString();
767
- await store.saveSession(target);
768
- await centrifugo.publishSessionUpdate(userId, target);
769
- response = { requestId: command.requestId, success: true, data: { session: toWireSession(target) } };
770
- break;
771
- }
772
- case 'rename_session': {
773
- const payload = command.payload;
774
- if (!payload.sessionId || !payload.name) {
775
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId or name' };
776
- break;
777
- }
778
- const sessionToRename = await store.loadSession(payload.sessionId);
779
- if (!sessionToRename) {
780
- response = { requestId: command.requestId, success: false, error: 'Session not found' };
781
- break;
782
- }
783
- sessionToRename.name = payload.name;
784
- sessionToRename.updatedAt = new Date().toISOString();
785
- await store.saveSession(sessionToRename);
786
- await centrifugo.publishSessionUpdate(userId, sessionToRename);
787
- response = { requestId: command.requestId, success: true, data: { session: toWireSession(sessionToRename) } };
788
- break;
789
- }
790
- case 'remove_session': {
791
- const payload = command.payload;
792
- if (!payload.sessionId) {
793
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
794
- break;
795
- }
796
- const removed = await removeFtownSession({ store, runner, centrifugo, userId }, payload.sessionId, { onlyIfFinished: payload.onlyIfFinished });
797
- response = { requestId: command.requestId, success: true, data: { removed: removed !== null } };
798
- break;
799
- }
800
- case 'bridge_exec': {
801
- const payload = command.payload;
802
- try {
803
- const { stdout, stderr } = await execAsync(payload.command, {
804
- cwd: payload.workingDir ?? process.cwd(),
805
- timeout: payload.timeout ?? 30000,
806
- maxBuffer: 1024 * 1024,
807
- });
808
- response = { requestId: command.requestId, success: true, data: { stdout, stderr, exitCode: 0 } };
809
- }
810
- catch (err) {
811
- const execErr = err;
812
- response = { requestId: command.requestId, success: true, data: { stdout: execErr.stdout, stderr: execErr.stderr, exitCode: execErr.code } };
813
- }
814
- break;
815
- }
816
- case 'clear_terminal': {
817
- const payload = command.payload;
818
- if (!payload.sessionId) {
819
- response = { requestId: command.requestId, success: false, error: 'Missing sessionId' };
820
- break;
821
- }
822
- flushBuffer(payload.sessionId);
823
- await store.clearTerminalLog(payload.sessionId);
824
- terminalManager.destroy(payload.sessionId);
825
- response = { requestId: command.requestId, success: true, data: { cleared: true } };
826
- break;
827
- }
828
- case 'create_loop': {
829
- const payload = command.payload;
830
- const error = validateLoopDraft(payload);
831
- if (error) {
832
- response = { requestId: command.requestId, success: false, error };
833
- break;
834
- }
835
- // bridgeId is forced to THIS bridge (the routing guard already proved
836
- // payload.bridgeId === bridgeId), so a loop is always owned by its runner.
837
- const draft = {
838
- name: payload.name.trim(),
839
- bridgeId,
840
- schedule: payload.schedule,
841
- harness: payload.harness,
842
- workdir: payload.workdir,
843
- task: payload.task,
844
- model: payload.model,
845
- enabled: payload.enabled,
846
- overlapPolicy: payload.overlapPolicy,
847
- retention: payload.retention,
848
- preflight: payload.preflight,
849
- postflight: payload.postflight,
850
- maxRuntimeMs: payload.maxRuntimeMs,
851
- group: payload.group,
852
- };
853
- const loop = createLoop(draft);
854
- await centrifugo.publishLoopUpdate(userId, loop);
855
- response = { requestId: command.requestId, success: true, data: { loop } };
856
- break;
857
- }
858
- case 'list_loops': {
859
- response = { requestId: command.requestId, success: true, data: { loops: listLoops() } };
860
- break;
861
- }
862
- case 'update_loop': {
863
- const payload = command.payload;
864
- if (!payload.loopId) {
865
- response = { requestId: command.requestId, success: false, error: 'Missing loopId' };
866
- break;
867
- }
868
- const patchError = validateLoopPatch(payload.patch);
869
- if (patchError) {
870
- response = { requestId: command.requestId, success: false, error: patchError };
871
- break;
872
- }
873
- const loop = updateLoop(payload.loopId, payload.patch);
874
- if (!loop) {
875
- response = { requestId: command.requestId, success: false, error: 'Loop not found' };
876
- break;
877
- }
878
- await centrifugo.publishLoopUpdate(userId, loop);
879
- response = { requestId: command.requestId, success: true, data: { loop } };
880
- break;
881
- }
882
- case 'delete_loop': {
883
- const payload = command.payload;
884
- if (!payload.loopId) {
885
- response = { requestId: command.requestId, success: false, error: 'Missing loopId' };
886
- break;
887
- }
888
- const existingLoop = getLoop(payload.loopId);
889
- const removed = deleteLoop(payload.loopId);
890
- if (removed) {
891
- // Stop any in-flight run and drop scheduler tracking so a
892
- // just-deleted loop never leaves a live AI session with nothing
893
- // left to finalize/prune it.
894
- if (existingLoop)
895
- scheduler.onLoopDeleted(existingLoop);
896
- deleteLoopRunRecords(payload.loopId);
897
- await centrifugo.publishLoopRemoved(userId, payload.loopId);
898
- }
899
- response = { requestId: command.requestId, success: true, data: { removed } };
900
- break;
901
- }
902
- case 'run_loop_now': {
903
- const payload = command.payload;
904
- if (!payload.loopId) {
905
- response = { requestId: command.requestId, success: false, error: 'Missing loopId' };
906
- break;
907
- }
908
- const loop = getLoop(payload.loopId);
909
- if (!loop) {
910
- response = { requestId: command.requestId, success: true, data: { fired: false, reason: 'not_found' } };
911
- break;
912
- }
913
- // A skip-policy loop with a live run cannot be manually fired either —
914
- // report overlap synchronously (the async tick would otherwise swallow it).
915
- if (loop.overlapPolicy === 'skip' &&
916
- loop.lastStatus === 'running' &&
917
- loop.lastSessionId &&
918
- runner.isRunning(loop.lastSessionId)) {
919
- response = { requestId: command.requestId, success: true, data: { fired: false, reason: 'overlap' } };
920
- break;
921
- }
922
- // Reload-check-write via mutateLoopRuntime: if the loop was deleted
923
- // between the getLoop() above and this write, this returns null and
924
- // nothing is written/published — a stale in-memory snapshot must
925
- // never be upserted back, or a deleted loop resurrects.
926
- const updated = mutateLoopRuntime(payload.loopId, (l) => {
927
- l.runNowRequested = true;
928
- l.updatedAt = new Date().toISOString();
929
- });
930
- if (!updated) {
931
- response = { requestId: command.requestId, success: true, data: { fired: false, reason: 'not_found' } };
932
- break;
933
- }
934
- await centrifugo.publishLoopUpdate(userId, updated);
935
- scheduler.kick();
936
- response = { requestId: command.requestId, success: true, data: { fired: true } };
937
- break;
938
- }
939
- case 'get_loop_runs': {
940
- const payload = command.payload;
941
- if (!payload.loopId) {
942
- response = { requestId: command.requestId, success: false, error: 'Missing loopId' };
943
- break;
944
- }
945
- const sessions = (await store.listSessions()).map(toWireSession);
946
- const runs = await listLoopRunRecordsWithFallback(payload.loopId, sessions, (sessionId) => store.loadTerminalLog(sessionId));
947
- response = { requestId: command.requestId, success: true, data: { runs } };
948
- break;
949
- }
950
- default: {
951
- response = {
952
- requestId: command.requestId,
953
- success: false,
954
- error: `Unknown command type: ${command.type}`,
955
- };
956
- }
957
- }
958
- }
959
- catch (err) {
960
- const errorMessage = err instanceof Error ? err.message : String(err);
961
- response = err instanceof WorkingDirMissingError
962
- ? {
963
- requestId: command.requestId,
964
- success: false,
965
- error: errorMessage,
966
- data: {
967
- code: err.code,
968
- workingDir: err.workingDir,
969
- canCreate: true,
970
- },
971
- }
972
- : { requestId: command.requestId, success: false, error: errorMessage };
973
- }
974
- try {
975
- await centrifugo.publishCommandResponse(userId, response);
976
- }
977
- catch (err) {
978
- console.error(`[Bridge] Failed to publish command response:`, err);
979
- }
980
- }
981
- async function markSessionDead(session, reason) {
982
- session.status = 'error';
983
- session.errorReason = reason;
984
- session.updatedAt = new Date().toISOString();
985
- await store.saveSession(session);
986
- await centrifugo.publishSessionUpdate(userId, session);
987
- console.log(`[Bridge] Marked stale session ${session.id} as error`);
988
- }
989
- async function resurrectSession(sessionId) {
990
- // Commands run concurrently with the resurrection loop; act on fresh
991
- // state so a session stopped or removed mid-loop is not respawned.
992
- const session = await store.loadSession(sessionId);
993
- if (!session || (session.status !== 'running' && session.status !== 'pending'))
994
- return;
995
- if (runner.isRunning(session.id))
996
- return;
997
- if (runner.reattach(session.id, {
998
- workingDir: session.workingDir,
999
- parentSessionId: session.parentSessionId,
1000
- })) {
1001
- session.status = 'running';
1002
- session.bridgeId = bridgeId;
1003
- session.errorReason = undefined;
1004
- session.updatedAt = new Date().toISOString();
1005
- await store.saveSession(session);
1006
- await centrifugo.publishSessionUpdate(userId, session);
1007
- wireTerminalInput(session.id);
1008
- registerSessionWorkspace(session.id, session.workingDir);
1009
- console.log(`[Bridge] Resurrected session ${session.id} via tmux reattach`);
1010
- return;
1011
- }
1012
- const shellType = session.shellType ?? 'claude';
1013
- const canResume = shellType === 'cursor'
1014
- ? Boolean(session.cursorSessionId?.trim())
1015
- : shellType === 'codex'
1016
- ? Boolean(session.codexSessionId?.trim())
1017
- : shellType !== 'shell' && shellType !== 'opencode' && Boolean(session.claudeSessionId?.trim());
1018
- if (canResume) {
1019
- // Sessions created with a custom command rerun it verbatim (matching
1020
- // retry_session): injecting --resume into arbitrary commands could
1021
- // break wrappers, and rebuilding would drop their flags.
1022
- const defaultCommand = buildSessionCommand({
1023
- shellType: session.shellType,
1024
- workingDir: session.workingDir,
1025
- model: session.model,
1026
- });
1027
- const isCustomCommand = Boolean(session.command) && session.command !== defaultCommand;
1028
- const resumeCommand = isCustomCommand && session.command
1029
- ? session.command
1030
- : buildSessionCommand({
1031
- shellType: session.shellType,
1032
- workingDir: session.workingDir,
1033
- model: session.model,
1034
- claudeSessionId: session.claudeSessionId,
1035
- cursorSessionId: session.cursorSessionId,
1036
- codexSessionId: session.codexSessionId,
1037
- });
1038
- const miss = findMissingProviderAuth(session.shellType, {
1039
- processEnv: process.env,
1040
- storeEnv: loadProviderEnv(),
1041
- });
1042
- if (miss) {
1043
- await markSessionDead(session, miss.message);
1044
- return;
1045
- }
1046
- session.status = 'running';
1047
- session.bridgeId = bridgeId;
1048
- session.runtime = runner.getPreferredRuntime();
1049
- session.errorReason = undefined;
1050
- session.updatedAt = new Date().toISOString();
1051
- await store.saveSession(session);
1052
- await centrifugo.publishSessionUpdate(userId, session);
1053
- runner.run(session.id, resumeCommand, {
1054
- workingDir: session.workingDir,
1055
- env: session.env,
1056
- hookPort,
1057
- hookToken: apiToken,
1058
- parentSessionId: session.parentSessionId,
1059
- });
1060
- wireTerminalInput(session.id);
1061
- registerSessionWorkspace(session.id, session.workingDir);
1062
- console.log(`[Bridge] Resurrected session ${session.id} via resume respawn: ${resumeCommand}`);
1063
- return;
1064
- }
1065
- await markSessionDead(session);
1066
- }
1067
- let resurrectionStarted = false;
1068
- async function resurrectSessions() {
1069
- if (resurrectionStarted)
1070
- return;
1071
- resurrectionStarted = true;
1072
- const sessions = await store.listSessions();
1073
- // Reap tmux sessions for OUR dead store records (removed records, failed
1074
- // kills) before resurrection re-creates any. Archived tombstones count as
1075
- // dead too: remove_session deletes the store record, so a failed tmux
1076
- // kill would otherwise leave a permanently invisible live agent. Tmux
1077
- // sessions with ids we have no record of are left alone — they may
1078
- // belong to another bridge running on this machine.
1079
- if (isTmuxAvailable()) {
1080
- const archived = await store.listArchived();
1081
- const deadIds = new Set([
1082
- ...sessions
1083
- .filter((s) => s.status !== 'running' && s.status !== 'pending')
1084
- .map((s) => s.id),
1085
- ...archived.map((a) => a.id),
1086
- ]);
1087
- // A live store record outranks any tombstone for the same id (crash
1088
- // between archive and delete leaves both): never reap resurrectables.
1089
- for (const s of sessions) {
1090
- if (s.status === 'running' || s.status === 'pending') {
1091
- deadIds.delete(s.id);
1092
- }
1093
- }
1094
- for (const tmuxId of listFtownTmuxSessions()) {
1095
- if (deadIds.has(tmuxId)) {
1096
- console.log(`[Bridge] Killing tmux session for dead session ${tmuxId}`);
1097
- await killTmuxSession(tmuxId);
1098
- }
1099
- }
1100
- }
1101
- let deferredLoopRuns = 0;
1102
- for (const session of sessions) {
1103
- if (session.loopId) {
1104
- if (session.status !== 'running' && session.status !== 'pending')
1105
- continue;
1106
- deferredLoopRuns += 1;
1107
- continue;
1108
- }
1109
- if (!shouldResurrectStoredSession(session))
1110
- continue;
1111
- try {
1112
- await resurrectSession(session.id);
1113
- }
1114
- catch (err) {
1115
- console.error(`[Bridge] Failed to resurrect session ${session.id}:`, err);
1116
- try {
1117
- const current = await store.loadSession(session.id);
1118
- if (current)
1119
- await markSessionDead(current);
1120
- }
1121
- catch (markErr) {
1122
- console.error(`[Bridge] Failed to mark session ${session.id} as error:`, markErr);
1123
- }
1124
- }
1125
- }
1126
- if (deferredLoopRuns > 0) {
1127
- console.log(`[Bridge] Deferred ${deferredLoopRuns} loop-run session(s) to the loop scheduler`);
1128
- }
1129
- }
474
+ const handleCommand = createCommandHandler({
475
+ bridgeId,
476
+ sessionController,
477
+ loopController,
478
+ publishCommandResponse: (response) => centrifugo.publishCommandResponse(userId, response),
479
+ });
1130
480
  centrifugo.connect();
1131
481
  centrifugo.joinBridgesChannel(userId, bridgeId);
1132
482
  centrifugo.subscribeToSessions(userId);
@@ -1146,7 +496,7 @@ program
1146
496
  ready = true;
1147
497
  console.log('[Bridge] Ready and listening for commands');
1148
498
  try {
1149
- await resurrectSessions();
499
+ await resurrection.resurrectSessions();
1150
500
  }
1151
501
  catch (err) {
1152
502
  console.error('[Bridge] Session resurrection failed:', err);