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
@@ -0,0 +1,141 @@
1
+ import { createFtownSession, relaunchFtownSession, } from './create-ftown-session.js';
2
+ export class SessionController {
3
+ deps;
4
+ constructor(deps) {
5
+ this.deps = deps;
6
+ }
7
+ require(dep, name) {
8
+ if (dep === undefined) {
9
+ throw new Error(`SessionController: ${name} dependency not wired`);
10
+ }
11
+ return dep;
12
+ }
13
+ /** Create + launch a session. Throws (see class doc) — adapters map errors. */
14
+ async create(input) {
15
+ const factory = this.require(this.deps.sessionFactory, 'sessionFactory');
16
+ return createFtownSession(factory, input);
17
+ }
18
+ /** Stop a running session and persist/publish its completed state. */
19
+ async stop(sessionId) {
20
+ const stopped = this.deps.runner.stop(sessionId);
21
+ if (stopped) {
22
+ this.require(this.deps.publishSyntheticStop, 'publishSyntheticStop')(sessionId, 'stopped');
23
+ await this.require(this.deps.withSessionWrite, 'withSessionWrite')(sessionId, async () => {
24
+ const session = await this.deps.store.loadSession(sessionId);
25
+ if (session) {
26
+ session.status = 'completed';
27
+ session.updatedAt = new Date().toISOString();
28
+ await this.deps.store.saveSession(session);
29
+ await this.deps.publishSessionUpdate(session);
30
+ }
31
+ });
32
+ this.require(this.deps.unregisterSession, 'unregisterSession')(sessionId);
33
+ }
34
+ return { stopped };
35
+ }
36
+ list() {
37
+ return this.deps.store.listSessions();
38
+ }
39
+ get(sessionId) {
40
+ return this.deps.store.loadSession(sessionId);
41
+ }
42
+ /**
43
+ * Per-session token/cost usage. Returns the persisted snapshot when
44
+ * present; otherwise collects on demand (covers live sessions and sessions
45
+ * that finished before usage collection existed). An on-demand result is
46
+ * persisted + published only when the session is terminal — a live
47
+ * session's numbers are still moving, so caching them would go stale.
48
+ */
49
+ async usage(sessionId) {
50
+ const session = await this.deps.store.loadSession(sessionId);
51
+ if (!session) {
52
+ return { ok: false, code: 'not_found', message: 'Session not found' };
53
+ }
54
+ if (session.usage) {
55
+ return { ok: true, usage: session.usage };
56
+ }
57
+ const collect = this.deps.collectUsage;
58
+ if (!collect) {
59
+ return { ok: true, usage: null };
60
+ }
61
+ const usage = await collect(session);
62
+ if (usage && (session.status === 'completed' || session.status === 'error')) {
63
+ session.usage = usage;
64
+ session.updatedAt = new Date().toISOString();
65
+ await this.deps.store.saveSession(session);
66
+ await this.deps.publishSessionUpdate(session);
67
+ }
68
+ return { ok: true, usage };
69
+ }
70
+ /** Re-run a finished/dead session's stored command verbatim. */
71
+ async retry(sessionId) {
72
+ const factory = this.require(this.deps.sessionFactory, 'sessionFactory');
73
+ const existingSession = await this.deps.store.loadSession(sessionId);
74
+ if (!existingSession) {
75
+ return { ok: false, code: 'not_found', message: 'Session not found' };
76
+ }
77
+ if (existingSession.status === 'running') {
78
+ return { ok: false, code: 'conflict', message: 'Session is already running' };
79
+ }
80
+ if (!existingSession.command) {
81
+ return { ok: false, code: 'invalid', message: 'Session has no command (created before v0.2.0)' };
82
+ }
83
+ await relaunchFtownSession(factory, existingSession, 'retry');
84
+ return { ok: true, session: existingSession };
85
+ }
86
+ /**
87
+ * Rename and/or reparent in ONE load-mutate-save-publish pass (the HTTP
88
+ * PATCH route updates both fields atomically; the RPC adapter calls with a
89
+ * single field). Reparenting flattens to the proposed parent's root so
90
+ * session trees stay one level deep.
91
+ */
92
+ async update(sessionId, input) {
93
+ const session = await this.deps.store.loadSession(sessionId);
94
+ if (!session) {
95
+ return { ok: false, code: 'not_found', message: 'Session not found' };
96
+ }
97
+ if (input.name !== undefined) {
98
+ if (typeof input.name !== 'string' || !input.name) {
99
+ return { ok: false, code: 'invalid', message: 'Invalid name' };
100
+ }
101
+ session.name = input.name;
102
+ }
103
+ if (input.parent) {
104
+ const raw = input.parent.value;
105
+ if (raw === null || raw === undefined || raw === '') {
106
+ session.parentSessionId = undefined;
107
+ }
108
+ else if (typeof raw !== 'string') {
109
+ return { ok: false, code: 'invalid', message: 'Invalid parentSessionId' };
110
+ }
111
+ else if (raw === session.id) {
112
+ return { ok: false, code: 'invalid', message: 'Session cannot be its own parent' };
113
+ }
114
+ else {
115
+ const proposed = await this.deps.store.loadSession(raw);
116
+ if (!proposed) {
117
+ return { ok: false, code: 'invalid', message: 'Parent session not found' };
118
+ }
119
+ session.parentSessionId = proposed.parentSessionId ?? proposed.id;
120
+ }
121
+ }
122
+ session.updatedAt = new Date().toISOString();
123
+ await this.deps.store.saveSession(session);
124
+ await this.deps.publishSessionUpdate(session);
125
+ return { ok: true, session };
126
+ }
127
+ /** Remove (archive + delete + publish removed). removed=false when nothing
128
+ * was removed — unknown id, or the onlyIfFinished guard rejected it. */
129
+ async remove(sessionId, options) {
130
+ const removed = await this.deps.removeSession(sessionId, options);
131
+ return { removed: removed !== null };
132
+ }
133
+ /** Flush pending output, wipe the persisted log, drop the live buffer. */
134
+ async clearTerminal(sessionId) {
135
+ this.require(this.deps.flushTerminalBuffer, 'flushTerminalBuffer')(sessionId);
136
+ await this.deps.store.clearTerminalLog(sessionId);
137
+ this.require(this.deps.destroyTerminal, 'destroyTerminal')(sessionId);
138
+ return { cleared: true };
139
+ }
140
+ }
141
+ //# sourceMappingURL=session-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-controller.js","sourceRoot":"","sources":["../src/session-controller.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,19 @@
1
+ import type { HookEvent } from './local-api-server.js';
2
+ import type { SessionStore } from './session-store.js';
3
+ import type { Session } from './types.js';
4
+ export interface AgentSessionIdPersisterDeps {
5
+ store: Pick<SessionStore, 'loadSession' | 'saveSession'>;
6
+ publishSessionUpdate: (session: Session) => Promise<void>;
7
+ }
8
+ /**
9
+ * Persists agent-native session ids (Claude/Codex session_id, Cursor
10
+ * conversation_id) from hook events onto the stored session record, with an
11
+ * in-memory cache of the last persisted ids to skip disk reads on the hot
12
+ * hook path.
13
+ */
14
+ export declare class AgentSessionIdPersister {
15
+ private readonly deps;
16
+ private readonly cache;
17
+ constructor(deps: AgentSessionIdPersisterDeps);
18
+ persist(hookEvent: HookEvent): Promise<void>;
19
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Persists agent-native session ids (Claude/Codex session_id, Cursor
3
+ * conversation_id) from hook events onto the stored session record, with an
4
+ * in-memory cache of the last persisted ids to skip disk reads on the hot
5
+ * hook path.
6
+ */
7
+ export class AgentSessionIdPersister {
8
+ deps;
9
+ cache = new Map();
10
+ constructor(deps) {
11
+ this.deps = deps;
12
+ }
13
+ async persist(hookEvent) {
14
+ // Workspace-fallback attribution may come from a foreign agent the user
15
+ // ran manually in the same directory; never persist its ids.
16
+ if (hookEvent.source === 'workspace')
17
+ return;
18
+ const rawAgentId = hookEvent.data['session_id'];
19
+ const rawCursorId = hookEvent.data['conversation_id'];
20
+ // Claude Code AND Codex hooks carry session_id (which field it lands in
21
+ // depends on the session's shellType); Cursor hooks carry conversation_id.
22
+ const agentId = typeof rawAgentId === 'string' && rawAgentId ? rawAgentId : undefined;
23
+ const cursorId = typeof rawCursorId === 'string' && rawCursorId ? rawCursorId : undefined;
24
+ if (!agentId && !cursorId)
25
+ return;
26
+ const cached = this.cache.get(hookEvent.sessionId);
27
+ if (cached
28
+ && (!agentId || (cached.isCodex ? cached.codex === agentId : cached.claude === agentId))
29
+ && (!cursorId || cached.cursor === cursorId)) {
30
+ return;
31
+ }
32
+ const session = await this.deps.store.loadSession(hookEvent.sessionId);
33
+ if (!session)
34
+ return;
35
+ const isCodex = session.shellType === 'codex';
36
+ let changed = false;
37
+ if (agentId) {
38
+ if (isCodex && session.codexSessionId !== agentId) {
39
+ session.codexSessionId = agentId;
40
+ changed = true;
41
+ }
42
+ else if (!isCodex && session.claudeSessionId !== agentId) {
43
+ session.claudeSessionId = agentId;
44
+ changed = true;
45
+ }
46
+ }
47
+ if (cursorId && session.cursorSessionId !== cursorId) {
48
+ session.cursorSessionId = cursorId;
49
+ changed = true;
50
+ }
51
+ if (!changed) {
52
+ this.cache.set(hookEvent.sessionId, {
53
+ claude: session.claudeSessionId,
54
+ cursor: session.cursorSessionId,
55
+ codex: session.codexSessionId,
56
+ isCodex,
57
+ });
58
+ return;
59
+ }
60
+ session.updatedAt = new Date().toISOString();
61
+ await this.deps.store.saveSession(session);
62
+ // Cache only after a successful save, so a failed persist is retried.
63
+ this.cache.set(hookEvent.sessionId, {
64
+ claude: session.claudeSessionId,
65
+ cursor: session.cursorSessionId,
66
+ codex: session.codexSessionId,
67
+ isCodex,
68
+ });
69
+ await this.deps.publishSessionUpdate(session);
70
+ }
71
+ }
72
+ //# sourceMappingURL=session-ids.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-ids.js","sourceRoot":"","sources":["../src/session-ids.ts"],"names":[],"mappings":"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"}
@@ -1,2 +1,26 @@
1
1
  import type { Session } from './types.js';
2
+ import type { SessionStore } from './session-store.js';
3
+ import type { ProcessRunner } from './claude-runner.js';
4
+ import { type CreateFtownSessionDeps } from './create-ftown-session.js';
2
5
  export declare function shouldResurrectStoredSession(session: Session): boolean;
6
+ export interface SessionResurrectionDeps {
7
+ store: SessionStore;
8
+ runner: ProcessRunner;
9
+ bridgeId: string;
10
+ sessionFactoryDeps: CreateFtownSessionDeps;
11
+ publishSessionUpdate: (session: Session) => Promise<void>;
12
+ wireTerminalInput: (sessionId: string) => void;
13
+ }
14
+ /**
15
+ * Resurrection engine: on bridge start, sessions left in a live state by a
16
+ * previous bridge are reattached (tmux), respawned (resume), or marked as
17
+ * error. Loop-run sessions are deferred to the loop scheduler.
18
+ */
19
+ export declare class SessionResurrection {
20
+ private readonly deps;
21
+ private started;
22
+ constructor(deps: SessionResurrectionDeps);
23
+ private markSessionDead;
24
+ private resurrectSession;
25
+ resurrectSessions(): Promise<void>;
26
+ }
@@ -1,6 +1,134 @@
1
+ import { canResumeStoredSession, findMissingProviderAuth, relaunchFtownSession, } from './create-ftown-session.js';
2
+ import { loadProviderEnv } from './provider-env-store.js';
3
+ import { registerSessionWorkspace } from './session-registry.js';
4
+ import { isTmuxAvailable, killTmuxSession, listFtownTmuxSessions } from './tmux.js';
1
5
  export function shouldResurrectStoredSession(session) {
2
6
  if (session.status !== 'running' && session.status !== 'pending')
3
7
  return false;
4
8
  return !session.loopId;
5
9
  }
10
+ /**
11
+ * Resurrection engine: on bridge start, sessions left in a live state by a
12
+ * previous bridge are reattached (tmux), respawned (resume), or marked as
13
+ * error. Loop-run sessions are deferred to the loop scheduler.
14
+ */
15
+ export class SessionResurrection {
16
+ deps;
17
+ started = false;
18
+ constructor(deps) {
19
+ this.deps = deps;
20
+ }
21
+ async markSessionDead(session, reason) {
22
+ session.status = 'error';
23
+ session.errorReason = reason;
24
+ session.updatedAt = new Date().toISOString();
25
+ await this.deps.store.saveSession(session);
26
+ await this.deps.publishSessionUpdate(session);
27
+ console.log(`[Bridge] Marked stale session ${session.id} as error`);
28
+ }
29
+ async resurrectSession(sessionId) {
30
+ const { store, runner, bridgeId, sessionFactoryDeps } = this.deps;
31
+ // Commands run concurrently with the resurrection loop; act on fresh
32
+ // state so a session stopped or removed mid-loop is not respawned.
33
+ const session = await store.loadSession(sessionId);
34
+ if (!session || (session.status !== 'running' && session.status !== 'pending'))
35
+ return;
36
+ if (runner.isRunning(session.id))
37
+ return;
38
+ if (runner.reattach(session.id, {
39
+ workingDir: session.workingDir,
40
+ parentSessionId: session.parentSessionId,
41
+ })) {
42
+ session.status = 'running';
43
+ session.bridgeId = bridgeId;
44
+ session.errorReason = undefined;
45
+ session.updatedAt = new Date().toISOString();
46
+ await store.saveSession(session);
47
+ await this.deps.publishSessionUpdate(session);
48
+ this.deps.wireTerminalInput(session.id);
49
+ registerSessionWorkspace(session.id, session.workingDir);
50
+ console.log(`[Bridge] Resurrected session ${session.id} via tmux reattach`);
51
+ return;
52
+ }
53
+ if (canResumeStoredSession(session)) {
54
+ const miss = findMissingProviderAuth(session.shellType, {
55
+ processEnv: process.env,
56
+ storeEnv: loadProviderEnv(),
57
+ });
58
+ if (miss) {
59
+ await this.markSessionDead(session, miss.message);
60
+ return;
61
+ }
62
+ // The custom-vs-rebuilt resume-command decision lives in
63
+ // deriveRelaunchCommand, inside relaunchFtownSession.
64
+ const resumeCommand = await relaunchFtownSession(sessionFactoryDeps, session, 'resume');
65
+ console.log(`[Bridge] Resurrected session ${session.id} via resume respawn: ${resumeCommand}`);
66
+ return;
67
+ }
68
+ await this.markSessionDead(session);
69
+ }
70
+ async resurrectSessions() {
71
+ if (this.started)
72
+ return;
73
+ this.started = true;
74
+ const { store } = this.deps;
75
+ const sessions = await store.listSessions();
76
+ // Reap tmux sessions for OUR dead store records (removed records, failed
77
+ // kills) before resurrection re-creates any. Archived tombstones count as
78
+ // dead too: remove_session deletes the store record, so a failed tmux
79
+ // kill would otherwise leave a permanently invisible live agent. Tmux
80
+ // sessions with ids we have no record of are left alone — they may
81
+ // belong to another bridge running on this machine.
82
+ if (isTmuxAvailable()) {
83
+ const archived = await store.listArchived();
84
+ const deadIds = new Set([
85
+ ...sessions
86
+ .filter((s) => s.status !== 'running' && s.status !== 'pending')
87
+ .map((s) => s.id),
88
+ ...archived.map((a) => a.id),
89
+ ]);
90
+ // A live store record outranks any tombstone for the same id (crash
91
+ // between archive and delete leaves both): never reap resurrectables.
92
+ for (const s of sessions) {
93
+ if (s.status === 'running' || s.status === 'pending') {
94
+ deadIds.delete(s.id);
95
+ }
96
+ }
97
+ for (const tmuxId of listFtownTmuxSessions()) {
98
+ if (deadIds.has(tmuxId)) {
99
+ console.log(`[Bridge] Killing tmux session for dead session ${tmuxId}`);
100
+ await killTmuxSession(tmuxId);
101
+ }
102
+ }
103
+ }
104
+ let deferredLoopRuns = 0;
105
+ for (const session of sessions) {
106
+ if (session.loopId) {
107
+ if (session.status !== 'running' && session.status !== 'pending')
108
+ continue;
109
+ deferredLoopRuns += 1;
110
+ continue;
111
+ }
112
+ if (!shouldResurrectStoredSession(session))
113
+ continue;
114
+ try {
115
+ await this.resurrectSession(session.id);
116
+ }
117
+ catch (err) {
118
+ console.error(`[Bridge] Failed to resurrect session ${session.id}:`, err);
119
+ try {
120
+ const current = await store.loadSession(session.id);
121
+ if (current)
122
+ await this.markSessionDead(current);
123
+ }
124
+ catch (markErr) {
125
+ console.error(`[Bridge] Failed to mark session ${session.id} as error:`, markErr);
126
+ }
127
+ }
128
+ }
129
+ if (deferredLoopRuns > 0) {
130
+ console.log(`[Bridge] Deferred ${deferredLoopRuns} loop-run session(s) to the loop scheduler`);
131
+ }
132
+ }
133
+ }
6
134
  //# sourceMappingURL=session-resurrection.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"session-resurrection.js","sourceRoot":"","sources":["../src/session-resurrection.ts"],"names":[],"mappings":"AAEA,MAAM,UAAU,4BAA4B,CAAC,OAAgB;IAC3D,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS,IAAI,OAAO,CAAC,MAAM,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC/E,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC;AACzB,CAAC"}
1
+ {"version":3,"file":"session-resurrection.js","sourceRoot":"","sources":["../src/session-resurrection.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Per-key spawn stagger: serializes concurrent callers of the same key so
3
+ * their spawns land at least `minGapMs` apart.
4
+ *
5
+ * Why: some harness CLIs (cursor-agent) refresh an auth token on startup with
6
+ * a read-modify-write against the macOS Keychain. Two or more concurrent
7
+ * startups race that write (pure last-writer-wins) and corrupt the stored
8
+ * credential. Spacing spawns by a small gap fully eliminates the race.
9
+ *
10
+ * Algorithm: a monotonic slot reservation per key. Each caller SYNCHRONOUSLY
11
+ * (before any await — this is what makes it lock-free under the single-threaded
12
+ * event loop) reserves `slot = max(now, lastSlot + minGapMs)`, records it as
13
+ * the new lastSlot, then sleeps until its slot. N concurrent callers therefore
14
+ * serialize at exactly `minGapMs` intervals; a caller arriving after the queue
15
+ * has drained (now >= lastSlot + minGapMs) gets slot = now and no delay.
16
+ */
17
+ export declare function staggerSpawn(key: string, minGapMs: number, opts?: {
18
+ now?: () => number;
19
+ sleep?: (ms: number) => Promise<void>;
20
+ }): Promise<void>;
21
+ /** Clears all reserved slots. Test seam only. */
22
+ export declare function resetSpawnStaggerForTest(): void;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Per-key spawn stagger: serializes concurrent callers of the same key so
3
+ * their spawns land at least `minGapMs` apart.
4
+ *
5
+ * Why: some harness CLIs (cursor-agent) refresh an auth token on startup with
6
+ * a read-modify-write against the macOS Keychain. Two or more concurrent
7
+ * startups race that write (pure last-writer-wins) and corrupt the stored
8
+ * credential. Spacing spawns by a small gap fully eliminates the race.
9
+ *
10
+ * Algorithm: a monotonic slot reservation per key. Each caller SYNCHRONOUSLY
11
+ * (before any await — this is what makes it lock-free under the single-threaded
12
+ * event loop) reserves `slot = max(now, lastSlot + minGapMs)`, records it as
13
+ * the new lastSlot, then sleeps until its slot. N concurrent callers therefore
14
+ * serialize at exactly `minGapMs` intervals; a caller arriving after the queue
15
+ * has drained (now >= lastSlot + minGapMs) gets slot = now and no delay.
16
+ */
17
+ const lastSlotByKey = new Map();
18
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
19
+ export function staggerSpawn(key, minGapMs, opts) {
20
+ const now = opts?.now ?? Date.now;
21
+ const sleep = opts?.sleep ?? defaultSleep;
22
+ // Reserve the slot synchronously — no await may happen before this point.
23
+ const arrival = now();
24
+ const lastSlot = lastSlotByKey.get(key);
25
+ const slot = lastSlot === undefined ? arrival : Math.max(arrival, lastSlot + minGapMs);
26
+ lastSlotByKey.set(key, slot);
27
+ const waitMs = slot - arrival;
28
+ return waitMs > 0 ? sleep(waitMs) : Promise.resolve();
29
+ }
30
+ /** Clears all reserved slots. Test seam only. */
31
+ export function resetSpawnStaggerForTest() {
32
+ lastSlotByKey.clear();
33
+ }
34
+ //# sourceMappingURL=spawn-stagger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"spawn-stagger.js","sourceRoot":"","sources":["../src/spawn-stagger.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,MAAM,aAAa,GAAG,IAAI,GAAG,EAAkB,CAAC;AAEhD,MAAM,YAAY,GAAG,CAAC,EAAU,EAAiB,EAAE,CACjD,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,UAAU,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC,CAAC;AAEpD,MAAM,UAAU,YAAY,CAC1B,GAAW,EACX,QAAgB,EAChB,IAAoE;IAEpE,MAAM,GAAG,GAAG,IAAI,EAAE,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IAClC,MAAM,KAAK,GAAG,IAAI,EAAE,KAAK,IAAI,YAAY,CAAC;IAE1C,0EAA0E;IAC1E,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC;IACtB,MAAM,QAAQ,GAAG,aAAa,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACxC,MAAM,IAAI,GAAG,QAAQ,KAAK,SAAS,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,EAAE,QAAQ,GAAG,QAAQ,CAAC,CAAC;IACvF,aAAa,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAE7B,MAAM,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC;IAC9B,OAAO,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,EAAE,CAAC;AACxD,CAAC;AAED,iDAAiD;AACjD,MAAM,UAAU,wBAAwB;IACtC,aAAa,CAAC,KAAK,EAAE,CAAC;AACxB,CAAC"}
@@ -0,0 +1,61 @@
1
+ import type { ProcessRunner } from './claude-runner.js';
2
+ import type { SessionStore } from './session-store.js';
3
+ import type { TerminalManager } from './terminal-manager.js';
4
+ import type { Session, SessionUsage } from './types.js';
5
+ export type SyntheticStopReason = 'complete' | 'error' | 'stopped';
6
+ export interface TerminalPumpDeps {
7
+ store: Pick<SessionStore, 'appendTerminalData' | 'loadSession' | 'saveSession' | 'loadTerminalLog'>;
8
+ terminalManager: Pick<TerminalManager, 'write' | 'destroy'>;
9
+ publishTerminalData: (sessionId: string, data: string) => void;
10
+ publishSessionUpdate: (session: Session) => Promise<void>;
11
+ publishHookEvent: (sessionId: string, event: Record<string, unknown>) => Promise<void>;
12
+ unregisterSession: (sessionId: string) => void;
13
+ /**
14
+ * Optional (injectable for tests): extract token/cost usage from the
15
+ * session's harness-native transcript. Fired-and-forgotten after the
16
+ * terminal status persist on complete/error; a null result never clobbers
17
+ * previously persisted usage.
18
+ */
19
+ collectUsage?: (session: Session) => Promise<SessionUsage | null>;
20
+ /** Test seams; production uses the defaults. */
21
+ flushIntervalMs?: number;
22
+ maxBufferBytes?: number;
23
+ }
24
+ /**
25
+ * Terminal output pump: buffers runner PTY output per session (coalescing into
26
+ * ≤16ms / ≤32KB flushes to the store + transports), and owns the runner
27
+ * lifecycle handlers ('complete'/'error') that persist final loop-run/session
28
+ * statuses — the loop scheduler reads the statuses this module produces.
29
+ */
30
+ export declare class TerminalPump {
31
+ private readonly deps;
32
+ private readonly outputBuffers;
33
+ private readonly flushTimers;
34
+ private readonly sessionWrites;
35
+ private readonly flushIntervalMs;
36
+ private readonly maxBufferBytes;
37
+ constructor(deps: TerminalPumpDeps);
38
+ /** Wire the pump onto the runner's data/lifecycle events. */
39
+ attach(runner: Pick<ProcessRunner, 'on'>): void;
40
+ handleData(sessionId: string, data: string): void;
41
+ flush(sessionId: string): void;
42
+ withSessionWrite(sessionId: string, task: () => Promise<void>): Promise<void>;
43
+ publishSyntheticStop(sessionId: string, reason: SyntheticStopReason): void;
44
+ /**
45
+ * Fire-and-forget usage collection after a terminal status persist. Runs
46
+ * OUTSIDE the caller's write (the collect can take seconds on huge
47
+ * transcripts); the persist itself is serialized via withSessionWrite.
48
+ * Guard: a null collection result never clobbers previously saved usage.
49
+ */
50
+ private collectAndPersistUsage;
51
+ private handleComplete;
52
+ /**
53
+ * `<error message> — <recent terminal tail>` (ANSI-stripped, whitespace
54
+ * collapsed, capped at ~400 chars) so a startup crash's cause is readable
55
+ * straight off the session record. The tail comes from the flushed terminal
56
+ * log; flush() fires its append without awaiting, so the chunk buffered at
57
+ * error time is re-appended if it hasn't landed on disk yet.
58
+ */
59
+ private buildErrorReason;
60
+ private handleError;
61
+ }