ftown-bridge 0.19.0 → 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 (80) hide show
  1. package/dist/agent-commands.d.ts +3 -17
  2. package/dist/agent-commands.js +8 -90
  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 +72 -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
  69. package/skills/factory/SKILL.md +167 -0
  70. package/skills/factory/factory-template/bin/dispatch.py +472 -0
  71. package/skills/factory/factory-template/factory.yaml +36 -0
  72. package/skills/factory/factory-template/skills/_protocol.md +85 -0
  73. package/skills/factory/factory-template/skills/design.md +259 -0
  74. package/skills/factory/factory-template/skills/digest.md +78 -0
  75. package/skills/factory/factory-template/skills/groom.md +243 -0
  76. package/skills/factory/factory-template/skills/implement.md +316 -0
  77. package/skills/factory/factory-template/skills/pr.md +220 -0
  78. package/skills/factory/factory-template/skills/qa.md +226 -0
  79. package/skills/factory/factory-template/skills/review.md +231 -0
  80. package/skills/factory/factory-template/skills/triage.md +157 -0
@@ -0,0 +1,114 @@
1
+ import type { MailMessage, Session } from './types.js';
2
+ export declare const MAX_MAIL_BODY_LENGTH: number;
3
+ export declare const MAX_MAIL_WAIT_SECONDS = 30;
4
+ export declare const MAIL_NUDGE_DELAY_MS = 5000;
5
+ export declare const MAIL_NUDGE_DELAY_HOOKED_MS = 60000;
6
+ export declare const MAIL_NUDGE_MIN_INTERVAL_MS = 30000;
7
+ export declare const MAIL_NUDGE_BUSY_RECHECK_MS = 60000;
8
+ export declare const AGENT_BUSY_STALE_MS: number;
9
+ export declare const MAIL_TYPES: ReadonlyArray<MailMessage['type']>;
10
+ export declare function sanitizeMessageText(text: string): string;
11
+ /** The slice of SessionStore the mail engine needs. */
12
+ export interface MailDeliverySessionStore {
13
+ loadSession(sessionId: string): Promise<Session | null>;
14
+ listSessions(): Promise<Session[]>;
15
+ }
16
+ /** The slice of MailStore the mail engine needs. */
17
+ export interface MailDeliveryMailStore {
18
+ append(msg: MailMessage): Promise<void>;
19
+ listUndelivered(sessionId: string): Promise<MailMessage[]>;
20
+ listAll(sessionId: string, limit: number): Promise<MailMessage[]>;
21
+ markDelivered(sessionId: string, ids: string[], via: NonNullable<MailMessage['deliveredVia']>): Promise<MailMessage[]>;
22
+ }
23
+ /** The slice of ProcessRunner the mail engine needs. */
24
+ export interface MailDeliveryRunner {
25
+ isRunning(sessionId: string): boolean;
26
+ write(sessionId: string, data: string): boolean;
27
+ }
28
+ /** Timer handle as returned by the injected setTimeout (Node's supports unref). */
29
+ export type MailTimerHandle = {
30
+ unref?: () => void;
31
+ };
32
+ export interface MailDeliveryOptions {
33
+ store?: MailDeliverySessionStore | null;
34
+ mailStore?: MailDeliveryMailStore | null;
35
+ runner?: MailDeliveryRunner | null;
36
+ /** Clock seam — defaults to the real Date.now/setTimeout/clearTimeout. */
37
+ now?: () => number;
38
+ setTimeout?: (fn: () => void, ms: number) => MailTimerHandle;
39
+ clearTimeout?: (handle: MailTimerHandle) => void;
40
+ }
41
+ export type AcceptMailResult = {
42
+ ok: true;
43
+ id: string;
44
+ } | {
45
+ ok: false;
46
+ error: string;
47
+ };
48
+ export type InboxReadResult = {
49
+ kind: 'immediate';
50
+ messages: MailMessage[];
51
+ } | {
52
+ kind: 'longpoll';
53
+ messages: Promise<MailMessage[]>;
54
+ abandon: () => void;
55
+ };
56
+ /**
57
+ * The bridge's mail-delivery engine, extracted from the HTTP router so its
58
+ * timing logic (long-poll waiters, nudge debounce/rate-limit/busy-recheck,
59
+ * composer-paste injection) is unit-testable with a controlled clock.
60
+ *
61
+ * Owns ALL mail state: one long-poll waiter per session, nudge timers,
62
+ * pending-nudge sender labels, last-nudge timestamps, and agent busy markers.
63
+ * The HTTP server keeps route parsing/response writing and delegates here.
64
+ */
65
+ export declare class MailDeliveryService {
66
+ private store;
67
+ private mailStore;
68
+ private runner;
69
+ private readonly now;
70
+ private readonly setTimeoutFn;
71
+ private readonly clearTimeoutFn;
72
+ private mailWaiters;
73
+ private nudgeTimers;
74
+ private pendingNudgeFrom;
75
+ private lastNudgeAt;
76
+ private agentBusySince;
77
+ constructor(options?: MailDeliveryOptions);
78
+ setStore(store: MailDeliverySessionStore): void;
79
+ setMailStore(mailStore: MailDeliveryMailStore): void;
80
+ setRunner(runner: MailDeliveryRunner): void;
81
+ /** Both the session store and the mail store are wired. */
82
+ isReady(): boolean;
83
+ /** Clear all timers and resolve every pending long poll with []. */
84
+ stop(): void;
85
+ /** Hook activity (UserPromptSubmit/PreToolUse/PostToolUse): the agent is mid-turn. */
86
+ markAgentBusy(sessionId: string): void;
87
+ /** Hook Stop/SessionEnd: the agent's turn ended. */
88
+ markAgentIdle(sessionId: string): void;
89
+ injectPtyLine(session: Session, sender: string, text: string): Promise<boolean>;
90
+ /**
91
+ * Store incoming mail for a session: validate, append, then either kick the
92
+ * session's long-poll waiter or schedule an idle nudge.
93
+ */
94
+ acceptMail(session: Session, body: Record<string, unknown>): Promise<AcceptMailResult>;
95
+ /**
96
+ * Read a session's inbox: peek, drain, or long poll. A long poll returns a
97
+ * promise that resolves when mail arrives (or the wait elapses) plus an
98
+ * `abandon` callback the caller must invoke if the client disconnects —
99
+ * abandoning never marks messages delivered, so no mail is lost.
100
+ */
101
+ readMail(session: Session, query: {
102
+ wait: number;
103
+ peek: boolean;
104
+ all: boolean;
105
+ limit: number;
106
+ }): Promise<InboxReadResult>;
107
+ /** A session earns the Stop-hook listen window only if mail can plausibly
108
+ * arrive: it has a parent, has children, was marked an orchestrator, or has
109
+ * exchanged mail before. */
110
+ participatesInMail(session: Session): Promise<boolean>;
111
+ private scheduleMailNudge;
112
+ private fireMailNudge;
113
+ private delay;
114
+ }
@@ -0,0 +1,315 @@
1
+ import { createMailMessage } from './mail-store.js';
2
+ import { HOOKED_SHELL_TYPES } from './harness-registry.js';
3
+ export const MAX_MAIL_BODY_LENGTH = 64 * 1024;
4
+ export const MAX_MAIL_WAIT_SECONDS = 30;
5
+ export const MAIL_NUDGE_DELAY_MS = 5_000;
6
+ // Hooked agents (claude/codex and claude flavors) get mail through their Stop
7
+ // pump; the nudge is only a safety net for them, so wait much longer before
8
+ // typing into their pane — fast nudges race the pump and queue stale prompts.
9
+ export const MAIL_NUDGE_DELAY_HOOKED_MS = 60_000;
10
+ export const MAIL_NUDGE_MIN_INTERVAL_MS = 30_000;
11
+ // While an agent is mid-turn its Stop hook pump delivers mail at turn end, so
12
+ // nudging would only queue a stale prompt; re-check periodically in case the
13
+ // turn never reaches Stop, and ignore busy markers old enough to be a crash.
14
+ export const MAIL_NUDGE_BUSY_RECHECK_MS = 60_000;
15
+ export const AGENT_BUSY_STALE_MS = 30 * 60_000;
16
+ export const MAIL_TYPES = ['message', 'task', 'result', 'escalation'];
17
+ // Composer TUIs detect pastes by input arrival rate; the submit CR must come well
18
+ // after that window or it is treated as a pasted newline.
19
+ const COMPOSER_PASTE_SETTLE_MS = 600;
20
+ // Messages are sanitized to a single line, so a delayed plain CR submits everywhere.
21
+ // (ESC+CR reads as Alt+Enter — insert newline — on current Claude Code.)
22
+ function submitSuffixFor(_shellType) {
23
+ return '\r';
24
+ }
25
+ // Strip control chars and collapse whitespace so a message cannot inject keystrokes.
26
+ export function sanitizeMessageText(text) {
27
+ return text
28
+ .replace(/[\u0000-\u001f\u007f]/g, ' ')
29
+ .replace(/\s+/g, ' ')
30
+ .trim();
31
+ }
32
+ /**
33
+ * The bridge's mail-delivery engine, extracted from the HTTP router so its
34
+ * timing logic (long-poll waiters, nudge debounce/rate-limit/busy-recheck,
35
+ * composer-paste injection) is unit-testable with a controlled clock.
36
+ *
37
+ * Owns ALL mail state: one long-poll waiter per session, nudge timers,
38
+ * pending-nudge sender labels, last-nudge timestamps, and agent busy markers.
39
+ * The HTTP server keeps route parsing/response writing and delegates here.
40
+ */
41
+ export class MailDeliveryService {
42
+ store;
43
+ mailStore;
44
+ runner;
45
+ now;
46
+ setTimeoutFn;
47
+ clearTimeoutFn;
48
+ mailWaiters = new Map();
49
+ nudgeTimers = new Map();
50
+ pendingNudgeFrom = new Map();
51
+ lastNudgeAt = new Map();
52
+ agentBusySince = new Map();
53
+ constructor(options = {}) {
54
+ this.store = options.store ?? null;
55
+ this.mailStore = options.mailStore ?? null;
56
+ this.runner = options.runner ?? null;
57
+ this.now = options.now ?? (() => Date.now());
58
+ this.setTimeoutFn = options.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
59
+ this.clearTimeoutFn = options.clearTimeout ?? ((handle) => clearTimeout(handle));
60
+ }
61
+ setStore(store) {
62
+ this.store = store;
63
+ }
64
+ setMailStore(mailStore) {
65
+ this.mailStore = mailStore;
66
+ }
67
+ setRunner(runner) {
68
+ this.runner = runner;
69
+ }
70
+ /** Both the session store and the mail store are wired. */
71
+ isReady() {
72
+ return this.store !== null && this.mailStore !== null;
73
+ }
74
+ /** Clear all timers and resolve every pending long poll with []. */
75
+ stop() {
76
+ for (const timer of this.nudgeTimers.values())
77
+ this.clearTimeoutFn(timer);
78
+ this.nudgeTimers.clear();
79
+ for (const waiter of [...this.mailWaiters.values()])
80
+ waiter.deliver([]);
81
+ this.mailWaiters.clear();
82
+ }
83
+ /** Hook activity (UserPromptSubmit/PreToolUse/PostToolUse): the agent is mid-turn. */
84
+ markAgentBusy(sessionId) {
85
+ this.agentBusySince.set(sessionId, this.now());
86
+ }
87
+ /** Hook Stop/SessionEnd: the agent's turn ended. */
88
+ markAgentIdle(sessionId) {
89
+ this.agentBusySince.delete(sessionId);
90
+ }
91
+ // Plain shells would execute the message as a command; deliver it as a quoted no-op
92
+ // (`: '...'`) — interactive zsh has no '#' comments by default.
93
+ async injectPtyLine(session, sender, text) {
94
+ if (!this.runner)
95
+ return false;
96
+ const isAgent = session.shellType && session.shellType !== 'shell';
97
+ const line = isAgent
98
+ ? `[ftown msg from ${sender}] ${text}`
99
+ : `: '[ftown msg from ${sender}] ${text.replace(/'/g, '')}'`;
100
+ if (!this.runner.write(session.id, line))
101
+ return false;
102
+ // Composer TUIs detect pastes by input arrival rate; the submit CR must come well
103
+ // after that window or it is treated as a pasted newline.
104
+ await this.delay(COMPOSER_PASTE_SETTLE_MS);
105
+ this.runner.write(session.id, submitSuffixFor(session.shellType));
106
+ return true;
107
+ }
108
+ /**
109
+ * Store incoming mail for a session: validate, append, then either kick the
110
+ * session's long-poll waiter or schedule an idle nudge.
111
+ */
112
+ async acceptMail(session, body) {
113
+ if (!this.mailStore) {
114
+ return { ok: false, error: 'Mail store not ready' };
115
+ }
116
+ const text = body.body;
117
+ if (typeof text !== 'string' || !text.trim()) {
118
+ return { ok: false, error: 'Missing body' };
119
+ }
120
+ if (text.length > MAX_MAIL_BODY_LENGTH) {
121
+ return { ok: false, error: `Body too long (max ${MAX_MAIL_BODY_LENGTH} chars)` };
122
+ }
123
+ let type = 'message';
124
+ if (body.type !== undefined) {
125
+ if (typeof body.type !== 'string' || !MAIL_TYPES.includes(body.type)) {
126
+ return { ok: false, error: `Invalid type (expected one of: ${MAIL_TYPES.join(', ')})` };
127
+ }
128
+ type = body.type;
129
+ }
130
+ const from = typeof body.from === 'string' && body.from.trim() ? body.from.trim() : 'external';
131
+ const fromName = typeof body.fromName === 'string' && body.fromName.trim() ? body.fromName.trim() : undefined;
132
+ const threadId = typeof body.threadId === 'string' && body.threadId.trim() ? body.threadId.trim() : undefined;
133
+ const msg = createMailMessage({ from, fromName, to: session.id, type, threadId, body: text });
134
+ await this.mailStore.append(msg);
135
+ const waiter = this.mailWaiters.get(session.id);
136
+ if (waiter) {
137
+ const undelivered = await this.mailStore.listUndelivered(session.id);
138
+ // Mirror the timeout path's settled-guard: the waiter may have died
139
+ // (client disconnected, or a newer waiter replaced it) while we awaited
140
+ // above. Only mark messages delivered if it is still the live waiter —
141
+ // otherwise leave them undelivered so the next reader gets them instead
142
+ // of writing to a dead socket and losing them.
143
+ if (this.mailWaiters.get(session.id) === waiter && undelivered.length > 0) {
144
+ const marked = await this.mailStore.markDelivered(session.id, undelivered.map((m) => m.id), 'poll');
145
+ waiter.deliver(marked);
146
+ }
147
+ }
148
+ else {
149
+ const nudgeDelay = HOOKED_SHELL_TYPES.has(session.shellType ?? 'claude')
150
+ ? MAIL_NUDGE_DELAY_HOOKED_MS
151
+ : MAIL_NUDGE_DELAY_MS;
152
+ this.scheduleMailNudge(session.id, fromName ?? from, nudgeDelay);
153
+ }
154
+ return { ok: true, id: msg.id };
155
+ }
156
+ /**
157
+ * Read a session's inbox: peek, drain, or long poll. A long poll returns a
158
+ * promise that resolves when mail arrives (or the wait elapses) plus an
159
+ * `abandon` callback the caller must invoke if the client disconnects —
160
+ * abandoning never marks messages delivered, so no mail is lost.
161
+ */
162
+ async readMail(session, query) {
163
+ const mailStore = this.mailStore;
164
+ if (!mailStore) {
165
+ return { kind: 'immediate', messages: [] };
166
+ }
167
+ const wait = Math.min(query.wait, MAX_MAIL_WAIT_SECONDS);
168
+ if (query.peek) {
169
+ const messages = query.all
170
+ ? await mailStore.listAll(session.id, query.limit)
171
+ : await mailStore.listUndelivered(session.id);
172
+ return { kind: 'immediate', messages };
173
+ }
174
+ // The listen window is earned, not universal: a session with no mail
175
+ // relationships (no parent, no children, not an orchestrator) gets an
176
+ // instant stop instead of holding its Stop hook open for `wait` seconds.
177
+ // Late mail for everyone is still covered by the idle nudge.
178
+ let effectiveWait = wait;
179
+ if (wait > 0 && !(await this.participatesInMail(session))) {
180
+ effectiveWait = 0;
181
+ }
182
+ const undelivered = await mailStore.listUndelivered(session.id);
183
+ if (undelivered.length > 0 || effectiveWait <= 0) {
184
+ const marked = await mailStore.markDelivered(session.id, undelivered.map((m) => m.id), effectiveWait > 0 ? 'poll' : 'drain');
185
+ return { kind: 'immediate', messages: marked };
186
+ }
187
+ // Long poll: hold until mail arrives or `wait` seconds elapse. One waiter
188
+ // per session — a newer waiter resolves the previous one with [].
189
+ const existing = this.mailWaiters.get(session.id);
190
+ if (existing)
191
+ existing.deliver([]);
192
+ let settled = false;
193
+ let resolveMessages;
194
+ const messages = new Promise((resolve) => {
195
+ resolveMessages = resolve;
196
+ });
197
+ const waiter = {
198
+ deliver: (delivered) => {
199
+ if (settled)
200
+ return;
201
+ settled = true;
202
+ this.clearTimeoutFn(timer);
203
+ if (this.mailWaiters.get(session.id) === waiter) {
204
+ this.mailWaiters.delete(session.id);
205
+ }
206
+ resolveMessages(delivered);
207
+ },
208
+ };
209
+ const timer = this.setTimeoutFn(() => {
210
+ // Guard each async step on settled: once the client disconnects (or a new
211
+ // waiter replaced this one) we must not mark messages delivered — they
212
+ // would be lost without ever reaching a session.
213
+ if (settled || this.mailWaiters.get(session.id) !== waiter)
214
+ return;
215
+ mailStore
216
+ .listUndelivered(session.id)
217
+ .then((pending) => {
218
+ if (settled || pending.length === 0)
219
+ return [];
220
+ return mailStore.markDelivered(session.id, pending.map((m) => m.id), 'poll');
221
+ })
222
+ .then((marked) => waiter.deliver(marked))
223
+ .catch(() => waiter.deliver([]));
224
+ }, effectiveWait * 1000);
225
+ this.mailWaiters.set(session.id, waiter);
226
+ const abandon = () => {
227
+ if (this.mailWaiters.get(session.id) !== waiter)
228
+ return;
229
+ settled = true;
230
+ this.clearTimeoutFn(timer);
231
+ this.mailWaiters.delete(session.id);
232
+ };
233
+ return { kind: 'longpoll', messages, abandon };
234
+ }
235
+ /** A session earns the Stop-hook listen window only if mail can plausibly
236
+ * arrive: it has a parent, has children, was marked an orchestrator, or has
237
+ * exchanged mail before. */
238
+ async participatesInMail(session) {
239
+ if (session.parentSessionId)
240
+ return true;
241
+ if (session.env?.FTOWN_ORCHESTRATOR === '1')
242
+ return true;
243
+ if (!this.store || !this.mailStore)
244
+ return false;
245
+ const sessions = await this.store.listSessions();
246
+ if (sessions.some((s) => s.parentSessionId === session.id))
247
+ return true;
248
+ const history = await this.mailStore.listAll(session.id, 1);
249
+ return history.length > 0;
250
+ }
251
+ // Debounced wake-up for mail that no long-poll consumed: after 5s, if it is still
252
+ // undelivered and the session is running, inject a one-line pointer to the
253
+ // harness mail command. At most one nudge per session per 30s; senders coalesce.
254
+ scheduleMailNudge(sessionId, fromLabel, delayMs = MAIL_NUDGE_DELAY_MS) {
255
+ this.pendingNudgeFrom.set(sessionId, fromLabel);
256
+ if (this.nudgeTimers.has(sessionId))
257
+ return;
258
+ const timer = this.setTimeoutFn(() => {
259
+ this.nudgeTimers.delete(sessionId);
260
+ this.fireMailNudge(sessionId).catch((err) => {
261
+ console.error('[MailDelivery] Mail nudge failed:', err instanceof Error ? err.message : String(err));
262
+ });
263
+ }, delayMs);
264
+ timer.unref?.();
265
+ this.nudgeTimers.set(sessionId, timer);
266
+ }
267
+ async fireMailNudge(sessionId) {
268
+ if (!this.store || !this.mailStore || !this.runner)
269
+ return;
270
+ const fromLabel = this.pendingNudgeFrom.get(sessionId) ?? 'ftown';
271
+ const undelivered = await this.mailStore.listUndelivered(sessionId);
272
+ if (undelivered.length === 0) {
273
+ this.pendingNudgeFrom.delete(sessionId);
274
+ return;
275
+ }
276
+ const busySince = this.agentBusySince.get(sessionId);
277
+ if (busySince !== undefined && this.now() - busySince < AGENT_BUSY_STALE_MS) {
278
+ this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
279
+ return;
280
+ }
281
+ // "Never nudged" must be its own state, not `lastNudgeAt ?? 0` — a clock
282
+ // that starts near zero (tests, or a freshly booted process) would make
283
+ // `now() - 0` look like it's already inside the rate-limit window.
284
+ const lastNudgeAt = this.lastNudgeAt.get(sessionId);
285
+ if (lastNudgeAt !== undefined) {
286
+ const sinceLast = this.now() - lastNudgeAt;
287
+ if (sinceLast < MAIL_NUDGE_MIN_INTERVAL_MS) {
288
+ // Rate-limited: retry once the window reopens (mail may still be unread).
289
+ this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_MIN_INTERVAL_MS - sinceLast);
290
+ return;
291
+ }
292
+ }
293
+ // The session isn't running (crashed/restarting): don't clear the pending
294
+ // marker. No timer is armed for this session at this point (the caller
295
+ // already removed it before invoking us), so clearing it here would lose
296
+ // the sender label until unrelated new mail happens to call
297
+ // scheduleMailNudge again. Keeping it means the next opportunity to
298
+ // re-check this session (new mail via acceptMail, or an external
299
+ // restart/idle hook re-invoking this nudge check) still knows who to
300
+ // credit.
301
+ if (!this.runner.isRunning(sessionId))
302
+ return;
303
+ const session = await this.store.loadSession(sessionId);
304
+ if (!session)
305
+ return;
306
+ this.pendingNudgeFrom.delete(sessionId);
307
+ this.lastNudgeAt.set(sessionId, this.now());
308
+ const text = sanitizeMessageText(`You have new ftown mail from ${fromLabel} — run \`~/.ftown/bin/ftown-harness mail read\` to read it.`);
309
+ await this.injectPtyLine(session, 'ftown-mail', text);
310
+ }
311
+ delay(ms) {
312
+ return new Promise((resolve) => this.setTimeoutFn(resolve, ms));
313
+ }
314
+ }
315
+ //# sourceMappingURL=mail-delivery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mail-delivery.js","sourceRoot":"","sources":["../src/mail-delivery.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,107 @@
1
+ import { type CreateFtownSessionDeps, type CreateFtownSessionInput } from './create-ftown-session.js';
2
+ import type { RemoveFtownSessionOptions } from './remove-ftown-session.js';
3
+ import type { Session, SessionUsage } from './types.js';
4
+ /**
5
+ * Transport-agnostic session operations, defined ONCE and shared by the two
6
+ * dispatch surfaces (the Centrifugo RPC switch in index.ts and the local HTTP
7
+ * router in local-api-server.ts). Methods take already-parsed typed input and
8
+ * return typed results/errors; the adapters own all wire concerns (command
9
+ * envelopes, HTTP statuses, toWireSession payload shaping).
10
+ *
11
+ * Error-propagation contract (matches the pre-refactor adapters byte for byte):
12
+ * - `create` and `retry` let ProviderAuthMissingError / WorkingDirMissingError /
13
+ * generic Errors THROW — each adapter already owns a per-error-class mapping
14
+ * (RPC outer catch with the working-dir data envelope; HTTP 422/400/500).
15
+ * - State/lookup failures return typed `{ ok: false, code, message }` results.
16
+ */
17
+ export type SessionErrorCode = 'not_found' | 'invalid' | 'conflict';
18
+ export interface SessionControllerError {
19
+ ok: false;
20
+ code: SessionErrorCode;
21
+ message: string;
22
+ }
23
+ export type SessionControllerResult<T> = ({
24
+ ok: true;
25
+ } & T) | SessionControllerError;
26
+ /** Combined rename/reparent input. `parent` PRESENT means "reparent"; a value
27
+ * of null/undefined/'' clears the parent (both dispatchers treat all three as
28
+ * clear). `value` is unknown so adapters can hand over unvalidated wire input. */
29
+ export interface SessionUpdateInput {
30
+ name?: string;
31
+ parent?: {
32
+ value: unknown;
33
+ };
34
+ }
35
+ /** Narrow structural slice of SessionStore so tests can use in-memory fakes. */
36
+ export interface SessionStoreLike {
37
+ loadSession(sessionId: string): Promise<Session | null>;
38
+ saveSession(session: Session): Promise<void>;
39
+ listSessions(): Promise<Session[]>;
40
+ clearTerminalLog(sessionId: string): Promise<void>;
41
+ }
42
+ export interface SessionControllerDeps {
43
+ store: SessionStoreLike;
44
+ runner: {
45
+ stop(sessionId: string): boolean;
46
+ };
47
+ publishSessionUpdate(session: Session): Promise<void>;
48
+ /** removeFtownSession closed over its dep bundle. Returns the removed
49
+ * session, or null when nothing was removed (unknown id, or the
50
+ * onlyIfFinished guard rejected it). */
51
+ removeSession(sessionId: string, options?: RemoveFtownSessionOptions): Promise<Session | null>;
52
+ /** Full factory bundle, required by create/retry only. */
53
+ sessionFactory?: CreateFtownSessionDeps;
54
+ /** Optional: extract token/cost usage from harness-native session files.
55
+ * Required only by usage(); omitted in adapters that never call it. */
56
+ collectUsage?(session: Session): Promise<SessionUsage | null>;
57
+ publishSyntheticStop?(sessionId: string, reason: 'complete' | 'error' | 'stopped'): void;
58
+ withSessionWrite?(sessionId: string, task: () => Promise<void>): Promise<void>;
59
+ unregisterSession?(sessionId: string): void;
60
+ flushTerminalBuffer?(sessionId: string): void;
61
+ destroyTerminal?(sessionId: string): void;
62
+ }
63
+ export declare class SessionController {
64
+ private readonly deps;
65
+ constructor(deps: SessionControllerDeps);
66
+ private require;
67
+ /** Create + launch a session. Throws (see class doc) — adapters map errors. */
68
+ create(input: CreateFtownSessionInput): Promise<Session>;
69
+ /** Stop a running session and persist/publish its completed state. */
70
+ stop(sessionId: string): Promise<{
71
+ stopped: boolean;
72
+ }>;
73
+ list(): Promise<Session[]>;
74
+ get(sessionId: string): Promise<Session | null>;
75
+ /**
76
+ * Per-session token/cost usage. Returns the persisted snapshot when
77
+ * present; otherwise collects on demand (covers live sessions and sessions
78
+ * that finished before usage collection existed). An on-demand result is
79
+ * persisted + published only when the session is terminal — a live
80
+ * session's numbers are still moving, so caching them would go stale.
81
+ */
82
+ usage(sessionId: string): Promise<SessionControllerResult<{
83
+ usage: SessionUsage | null;
84
+ }>>;
85
+ /** Re-run a finished/dead session's stored command verbatim. */
86
+ retry(sessionId: string): Promise<SessionControllerResult<{
87
+ session: Session;
88
+ }>>;
89
+ /**
90
+ * Rename and/or reparent in ONE load-mutate-save-publish pass (the HTTP
91
+ * PATCH route updates both fields atomically; the RPC adapter calls with a
92
+ * single field). Reparenting flattens to the proposed parent's root so
93
+ * session trees stay one level deep.
94
+ */
95
+ update(sessionId: string, input: SessionUpdateInput): Promise<SessionControllerResult<{
96
+ session: Session;
97
+ }>>;
98
+ /** Remove (archive + delete + publish removed). removed=false when nothing
99
+ * was removed — unknown id, or the onlyIfFinished guard rejected it. */
100
+ remove(sessionId: string, options?: RemoveFtownSessionOptions): Promise<{
101
+ removed: boolean;
102
+ }>;
103
+ /** Flush pending output, wipe the persisted log, drop the live buffer. */
104
+ clearTerminal(sessionId: string): Promise<{
105
+ cleared: true;
106
+ }>;
107
+ }