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.
- package/dist/agent-commands.d.ts +3 -17
- package/dist/agent-commands.js +8 -94
- package/dist/agent-commands.js.map +1 -1
- package/dist/bridge-auth.d.ts +21 -0
- package/dist/bridge-auth.js +48 -0
- package/dist/bridge-auth.js.map +1 -0
- package/dist/centrifugo-client.js +39 -0
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/command-rpc.d.ts +15 -0
- package/dist/command-rpc.js +249 -0
- package/dist/command-rpc.js.map +1 -0
- package/dist/create-ftown-session.d.ts +43 -0
- package/dist/create-ftown-session.js +115 -9
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/ftown-sessions-cli.js +74 -4
- package/dist/ftown-sessions-cli.js.map +1 -1
- package/dist/harness-cli.js +1 -13
- package/dist/harness-cli.js.map +1 -1
- package/dist/harness-installer.js +4 -2
- package/dist/harness-installer.js.map +1 -1
- package/dist/harness-registry.d.ts +189 -0
- package/dist/harness-registry.js +218 -0
- package/dist/harness-registry.js.map +1 -0
- package/dist/index.js +71 -721
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-cli.js +12 -3
- package/dist/install-ftown-cli.js.map +1 -1
- package/dist/local-api-server.d.ts +14 -13
- package/dist/local-api-server.js +180 -424
- package/dist/local-api-server.js.map +1 -1
- package/dist/loop-controller.d.ts +87 -0
- package/dist/loop-controller.js +133 -0
- package/dist/loop-controller.js.map +1 -0
- package/dist/loop-run-store.js +2 -8
- package/dist/loop-run-store.js.map +1 -1
- package/dist/loop-validation.d.ts +1 -0
- package/dist/loop-validation.js +3 -8
- package/dist/loop-validation.js.map +1 -1
- package/dist/mail-delivery.d.ts +114 -0
- package/dist/mail-delivery.js +315 -0
- package/dist/mail-delivery.js.map +1 -0
- package/dist/session-controller.d.ts +107 -0
- package/dist/session-controller.js +141 -0
- package/dist/session-controller.js.map +1 -0
- package/dist/session-ids.d.ts +19 -0
- package/dist/session-ids.js +72 -0
- package/dist/session-ids.js.map +1 -0
- package/dist/session-resurrection.d.ts +24 -0
- package/dist/session-resurrection.js +128 -0
- package/dist/session-resurrection.js.map +1 -1
- package/dist/spawn-stagger.d.ts +22 -0
- package/dist/spawn-stagger.js +34 -0
- package/dist/spawn-stagger.js.map +1 -0
- package/dist/terminal-pump.d.ts +61 -0
- package/dist/terminal-pump.js +193 -0
- package/dist/terminal-pump.js.map +1 -0
- package/dist/types.d.ts +29 -4
- package/dist/usage-collector.d.ts +42 -0
- package/dist/usage-collector.js +317 -0
- package/dist/usage-collector.js.map +1 -0
- package/dist/wire-types.d.ts +124 -0
- package/dist/wire-types.js +28 -0
- package/dist/wire-types.js.map +1 -0
- package/dist/workflow-runner-cli.js +2 -1
- package/dist/workflow-runner-cli.js.map +1 -1
- package/dist/workflow-runner.d.ts +2 -1
- package/dist/workflow-runner.js.map +1 -1
- 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 {
|
|
35
|
-
import { createFtownSession
|
|
36
|
-
import {
|
|
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 {
|
|
39
|
-
import {
|
|
40
|
-
import {
|
|
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 {
|
|
43
|
-
import {
|
|
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, () =>
|
|
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
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
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);
|