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
@@ -1,14 +1,13 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { EventEmitter } from 'node:events';
3
3
  import { timingSafeEqual } from 'node:crypto';
4
- import { createMailMessage } from './mail-store.js';
5
- import { buildSessionCommand } from './agent-commands.js';
6
- import { createLoop, deleteLoop, getLoop, listLoops, mutateLoopRuntime, updateLoop } from './loop-store.js';
7
- import { deleteLoopRunRecords, listLoopRunRecordsWithFallback } from './loop-run-store.js';
8
- import { validateLoopDraft, validateLoopPatch } from './loop-validation.js';
4
+ import { MailDeliveryService, sanitizeMessageText } from './mail-delivery.js';
5
+ import { LoopController } from './loop-controller.js';
6
+ import { SessionController } from './session-controller.js';
9
7
  import { registerSessionConversation, resolveSessionIdByConversation, resolveSessionIdFromHookPayload, } from './session-registry.js';
10
- import { createFtownSession, parseCreateSessionBody, ProviderAuthMissingError, WorkingDirMissingError, } from './create-ftown-session.js';
8
+ import { createFtownSession, deriveRelaunchCommand, parseCreateSessionBody, ProviderAuthMissingError, WorkingDirMissingError, } from './create-ftown-session.js';
11
9
  import { removeFtownSession } from './remove-ftown-session.js';
10
+ import { collectSessionUsage } from './usage-collector.js';
12
11
  import { toWireSession } from './session-wire.js';
13
12
  function jsonResponse(res, status, data) {
14
13
  res.writeHead(status, { 'Content-Type': 'application/json' });
@@ -84,38 +83,6 @@ function extractBearer(req) {
84
83
  return null;
85
84
  }
86
85
  const MAX_MESSAGE_LENGTH = 2000;
87
- const MAX_MAIL_BODY_LENGTH = 64 * 1024;
88
- const MAX_MAIL_WAIT_SECONDS = 30;
89
- const MAIL_NUDGE_DELAY_MS = 5_000;
90
- // Hooked agents (claude/codex and claude flavors) get mail through their Stop
91
- // pump; the nudge is only a safety net for them, so wait much longer before
92
- // typing into their pane — fast nudges race the pump and queue stale prompts.
93
- const MAIL_NUDGE_DELAY_HOOKED_MS = 60_000;
94
- const HOOKED_SHELL_TYPES = new Set([
95
- 'claude', 'codex', 'zai', 'kimi', 'deepseek', 'fireworks',
96
- ]);
97
- const MAIL_NUDGE_MIN_INTERVAL_MS = 30_000;
98
- // While an agent is mid-turn its Stop hook pump delivers mail at turn end, so
99
- // nudging would only queue a stale prompt; re-check periodically in case the
100
- // turn never reaches Stop, and ignore busy markers old enough to be a crash.
101
- const MAIL_NUDGE_BUSY_RECHECK_MS = 60_000;
102
- const AGENT_BUSY_STALE_MS = 30 * 60_000;
103
- const MAIL_TYPES = ['message', 'task', 'result', 'escalation'];
104
- function delay(ms) {
105
- return new Promise((resolve) => setTimeout(resolve, ms));
106
- }
107
- // Messages are sanitized to a single line, so a delayed plain CR submits everywhere.
108
- // (ESC+CR reads as Alt+Enter — insert newline — on current Claude Code.)
109
- function submitSuffixFor(_shellType) {
110
- return '\r';
111
- }
112
- // Strip control chars and collapse whitespace so a message cannot inject keystrokes.
113
- function sanitizeMessageText(text) {
114
- return text
115
- .replace(/[\u0000-\u001f\u007f]/g, ' ')
116
- .replace(/\s+/g, ' ')
117
- .trim();
118
- }
119
86
  function getQueryInt(url, name, defaultValue) {
120
87
  const val = url.searchParams.get(name);
121
88
  if (!val)
@@ -130,21 +97,18 @@ export class LocalApiServer extends EventEmitter {
130
97
  centrifugo = null;
131
98
  terminalManager = null;
132
99
  sessionDeps = null;
133
- mailStore = null;
134
- mailWaiters = new Map();
135
- nudgeTimers = new Map();
136
- pendingNudgeFrom = new Map();
137
- lastNudgeAt = new Map();
138
- agentBusySince = new Map();
100
+ mail = new MailDeliveryService();
139
101
  loopApi = null;
140
102
  userId = '';
141
103
  authToken = '';
142
104
  port = 0;
105
+ loopController = null;
106
+ sessionController = null;
143
107
  setAuthToken(token) {
144
108
  this.authToken = token;
145
109
  }
146
110
  setMailStore(mailStore) {
147
- this.mailStore = mailStore;
111
+ this.mail.setMailStore(mailStore);
148
112
  }
149
113
  setDependencies(store, runner, centrifugo, userId, terminalManager) {
150
114
  this.store = store;
@@ -152,12 +116,62 @@ export class LocalApiServer extends EventEmitter {
152
116
  this.centrifugo = centrifugo;
153
117
  this.userId = userId;
154
118
  this.terminalManager = terminalManager ?? null;
119
+ this.mail.setStore(store);
120
+ this.mail.setRunner(runner);
121
+ this.invalidateControllers();
155
122
  }
156
123
  setSessionFactory(deps) {
157
124
  this.sessionDeps = deps;
125
+ this.invalidateControllers();
158
126
  }
159
127
  setLoopApi(deps) {
160
128
  this.loopApi = deps;
129
+ this.invalidateControllers();
130
+ }
131
+ /** Controllers are lazy snapshots of the injected deps; rebuild on re-wiring. */
132
+ invalidateControllers() {
133
+ this.loopController = null;
134
+ this.sessionController = null;
135
+ }
136
+ /**
137
+ * Transport-agnostic loop operations (shared with the Centrifugo RPC switch
138
+ * in index.ts). Null until setDependencies + setLoopApi have both run —
139
+ * index.ts wires them in one synchronous block, so no request can observe a
140
+ * half-wired server.
141
+ */
142
+ getLoopController() {
143
+ if (this.loopController)
144
+ return this.loopController;
145
+ const { store, runner, centrifugo, userId, loopApi } = this;
146
+ if (!store || !runner || !centrifugo || !userId || !loopApi)
147
+ return null;
148
+ this.loopController = new LoopController({
149
+ bridgeId: loopApi.bridgeId,
150
+ scheduler: loopApi.scheduler,
151
+ isSessionRunning: (sid) => runner.isRunning(sid),
152
+ publishLoopUpdate: (loop) => centrifugo.publishLoopUpdate(userId, loop),
153
+ publishLoopRemoved: (loopId) => centrifugo.publishLoopRemoved(userId, loopId),
154
+ listWireSessions: async () => (await store.listSessions()).map(toWireSession),
155
+ loadTerminalLog: (sid) => store.loadTerminalLog(sid),
156
+ });
157
+ return this.loopController;
158
+ }
159
+ /** Transport-agnostic session operations (shared with the RPC switch). */
160
+ getSessionController() {
161
+ if (this.sessionController)
162
+ return this.sessionController;
163
+ const { store, runner, centrifugo, userId } = this;
164
+ if (!store || !runner || !centrifugo || !userId)
165
+ return null;
166
+ this.sessionController = new SessionController({
167
+ store,
168
+ runner,
169
+ publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
170
+ removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
171
+ collectUsage: (session) => collectSessionUsage(session),
172
+ ...(this.sessionDeps ? { sessionFactory: this.sessionDeps } : {}),
173
+ });
174
+ return this.sessionController;
161
175
  }
162
176
  async start() {
163
177
  return new Promise((resolve, reject) => {
@@ -189,12 +203,7 @@ export class LocalApiServer extends EventEmitter {
189
203
  return this.server;
190
204
  }
191
205
  stop() {
192
- for (const timer of this.nudgeTimers.values())
193
- clearTimeout(timer);
194
- this.nudgeTimers.clear();
195
- for (const waiter of [...this.mailWaiters.values()])
196
- waiter.deliver([]);
197
- this.mailWaiters.clear();
206
+ this.mail.stop();
198
207
  if (this.server) {
199
208
  this.server.close();
200
209
  this.server = null;
@@ -245,7 +254,7 @@ export class LocalApiServer extends EventEmitter {
245
254
  }
246
255
  // GET /api/sessions
247
256
  if (path === '/api/sessions' && req.method === 'GET') {
248
- const sessions = await this.store.listSessions();
257
+ const sessions = await (this.getSessionController()?.list() ?? this.store.listSessions());
249
258
  jsonResponse(res, 200, { sessions: sessions.map(toWireSession) });
250
259
  return;
251
260
  }
@@ -271,8 +280,13 @@ export class LocalApiServer extends EventEmitter {
271
280
  });
272
281
  return;
273
282
  }
283
+ const controller = this.getSessionController();
284
+ if (!controller) {
285
+ jsonResponse(res, 503, { error: 'Session factory not ready' });
286
+ return;
287
+ }
274
288
  try {
275
- const session = await createFtownSession(this.sessionDeps, input);
289
+ const session = await controller.create(input);
276
290
  jsonResponse(res, 201, { session: toWireSession(session) });
277
291
  }
278
292
  catch (err) {
@@ -320,154 +334,80 @@ export class LocalApiServer extends EventEmitter {
320
334
  jsonResponse(res, 200, { sessionId });
321
335
  return;
322
336
  }
323
- // GET /api/loops — local CLI/skill parity with list_loops RPC.
324
- if (path === '/api/loops' && req.method === 'GET') {
325
- jsonResponse(res, 200, { loops: listLoops() });
326
- return;
327
- }
328
- // POST /api/loops — create a loop owned by this bridge.
329
- if (path === '/api/loops' && req.method === 'POST') {
330
- if (!this.loopApi || !this.centrifugo || !this.userId) {
331
- jsonResponse(res, 503, { error: 'Loop API not ready' });
332
- return;
333
- }
334
- const body = await parseBody(req);
335
- const payload = { ...body, bridgeId: this.loopApi.bridgeId };
336
- const error = validateLoopDraft(payload);
337
- if (error) {
338
- jsonResponse(res, 400, { error });
339
- return;
340
- }
341
- const draft = {
342
- name: payload.name.trim(),
343
- bridgeId: this.loopApi.bridgeId,
344
- schedule: payload.schedule,
345
- harness: payload.harness,
346
- workdir: payload.workdir,
347
- task: payload.task,
348
- model: payload.model,
349
- enabled: payload.enabled,
350
- overlapPolicy: payload.overlapPolicy,
351
- retention: payload.retention,
352
- preflight: payload.preflight,
353
- postflight: payload.postflight,
354
- maxRuntimeMs: payload.maxRuntimeMs,
355
- group: payload.group,
356
- };
357
- try {
358
- const loop = createLoop(draft);
359
- await this.centrifugo.publishLoopUpdate(this.userId, loop);
360
- jsonResponse(res, 201, { loop });
361
- }
362
- catch (err) {
363
- const message = err instanceof Error ? err.message : String(err);
364
- jsonResponse(res, 400, { error: message });
365
- }
366
- return;
367
- }
368
337
  const loopMatch = path.match(/^\/api\/loops\/([^/]+)$/);
369
338
  const loopRunNowMatch = path.match(/^\/api\/loops\/([^/]+)\/run-now$/);
370
339
  const loopRunsMatch = path.match(/^\/api\/loops\/([^/]+)\/runs$/);
371
- // GET /api/loops/:id
372
- if (loopMatch && req.method === 'GET') {
373
- const loop = getLoop(loopMatch[1]);
374
- if (!loop) {
375
- jsonResponse(res, 404, { error: 'Loop not found' });
376
- return;
377
- }
378
- jsonResponse(res, 200, { loop });
379
- return;
380
- }
381
- // PATCH /api/loops/:id
382
- if (loopMatch && req.method === 'PATCH') {
383
- if (!this.centrifugo || !this.userId) {
340
+ const isLoopRoute = path === '/api/loops' || Boolean(loopMatch) || Boolean(loopRunNowMatch) || Boolean(loopRunsMatch);
341
+ if (isLoopRoute) {
342
+ const loops = this.getLoopController();
343
+ if (!loops) {
384
344
  jsonResponse(res, 503, { error: 'Loop API not ready' });
385
345
  return;
386
346
  }
387
- const loopId = loopMatch[1];
388
- const body = await parseBody(req);
389
- const patch = body;
390
- const error = validateLoopPatch(patch);
391
- if (error) {
392
- jsonResponse(res, 400, { error });
347
+ // GET /api/loops — local CLI/skill parity with list_loops RPC.
348
+ if (path === '/api/loops' && req.method === 'GET') {
349
+ jsonResponse(res, 200, { loops: loops.list() });
393
350
  return;
394
351
  }
395
- try {
396
- const loop = updateLoop(loopId, patch);
397
- if (!loop) {
398
- jsonResponse(res, 404, { error: 'Loop not found' });
352
+ // POST /api/loops — create a loop owned by this bridge.
353
+ if (path === '/api/loops' && req.method === 'POST') {
354
+ const body = await parseBody(req);
355
+ const result = await loops.create(body);
356
+ if (!result.ok) {
357
+ jsonResponse(res, 400, { error: result.message });
399
358
  return;
400
359
  }
401
- await this.centrifugo.publishLoopUpdate(this.userId, loop);
402
- jsonResponse(res, 200, { loop });
403
- }
404
- catch (err) {
405
- const message = err instanceof Error ? err.message : String(err);
406
- jsonResponse(res, 400, { error: message });
407
- }
408
- return;
409
- }
410
- // DELETE /api/loops/:id
411
- if (loopMatch && req.method === 'DELETE') {
412
- if (!this.loopApi || !this.centrifugo || !this.userId) {
413
- jsonResponse(res, 503, { error: 'Loop API not ready' });
360
+ jsonResponse(res, 201, { loop: result.loop });
414
361
  return;
415
362
  }
416
- const loopId = loopMatch[1];
417
- const existingLoop = getLoop(loopId);
418
- const removed = deleteLoop(loopId);
419
- if (removed) {
420
- if (existingLoop)
421
- this.loopApi.scheduler.onLoopDeleted(existingLoop);
422
- deleteLoopRunRecords(loopId);
423
- await this.centrifugo.publishLoopRemoved(this.userId, loopId);
363
+ // GET /api/loops/:id
364
+ if (loopMatch && req.method === 'GET') {
365
+ const result = loops.get(loopMatch[1]);
366
+ if (!result.ok) {
367
+ jsonResponse(res, 404, { error: result.message });
368
+ return;
369
+ }
370
+ jsonResponse(res, 200, { loop: result.loop });
371
+ return;
424
372
  }
425
- jsonResponse(res, 200, { removed, loopId });
426
- return;
427
- }
428
- // POST /api/loops/:id/run-now
429
- if (loopRunNowMatch && req.method === 'POST') {
430
- if (!this.loopApi || !this.runner || !this.centrifugo || !this.userId) {
431
- jsonResponse(res, 503, { error: 'Loop API not ready' });
373
+ // PATCH /api/loops/:id
374
+ if (loopMatch && req.method === 'PATCH') {
375
+ const body = await parseBody(req);
376
+ const result = await loops.update(loopMatch[1], body);
377
+ if (!result.ok) {
378
+ jsonResponse(res, result.code === 'not_found' ? 404 : 400, { error: result.message });
379
+ return;
380
+ }
381
+ jsonResponse(res, 200, { loop: result.loop });
432
382
  return;
433
383
  }
434
- const loopId = loopRunNowMatch[1];
435
- const loop = getLoop(loopId);
436
- if (!loop) {
437
- jsonResponse(res, 404, { error: 'Loop not found', fired: false, reason: 'not_found' });
384
+ // DELETE /api/loops/:id
385
+ if (loopMatch && req.method === 'DELETE') {
386
+ const loopId = loopMatch[1];
387
+ const { removed } = await loops.delete(loopId);
388
+ jsonResponse(res, 200, { removed, loopId });
438
389
  return;
439
390
  }
440
- if (loop.overlapPolicy === 'skip' &&
441
- loop.lastStatus === 'running' &&
442
- loop.lastSessionId &&
443
- this.runner.isRunning(loop.lastSessionId)) {
444
- jsonResponse(res, 200, { fired: false, reason: 'overlap' });
391
+ // POST /api/loops/:id/run-now
392
+ if (loopRunNowMatch && req.method === 'POST') {
393
+ const outcome = await loops.runNow(loopRunNowMatch[1]);
394
+ if (!outcome.fired) {
395
+ if (outcome.reason === 'not_found') {
396
+ jsonResponse(res, 404, { error: 'Loop not found', fired: false, reason: 'not_found' });
397
+ return;
398
+ }
399
+ jsonResponse(res, 200, { fired: false, reason: 'overlap' });
400
+ return;
401
+ }
402
+ jsonResponse(res, 200, { fired: true, loop: outcome.loop });
445
403
  return;
446
404
  }
447
- // Reload-check-write via mutateLoopRuntime: if the loop was deleted
448
- // between the getLoop() above and this write, this returns null and
449
- // nothing is written/published a stale in-memory snapshot must never
450
- // be upserted back, or a deleted loop resurrects.
451
- const updated = mutateLoopRuntime(loopId, (l) => {
452
- l.runNowRequested = true;
453
- l.updatedAt = new Date().toISOString();
454
- });
455
- if (!updated) {
456
- jsonResponse(res, 404, { error: 'Loop not found', fired: false, reason: 'not_found' });
405
+ // GET /api/loops/:id/runs
406
+ if (loopRunsMatch && req.method === 'GET') {
407
+ jsonResponse(res, 200, { runs: await loops.runs(loopRunsMatch[1]) });
457
408
  return;
458
409
  }
459
- await this.centrifugo.publishLoopUpdate(this.userId, updated);
460
- this.loopApi.scheduler.kick();
461
- jsonResponse(res, 200, { fired: true, loop: updated });
462
- return;
463
- }
464
- // GET /api/loops/:id/runs
465
- if (loopRunsMatch && req.method === 'GET') {
466
- const loopId = loopRunsMatch[1];
467
- const store = this.store;
468
- const sessions = (await store.listSessions()).map(toWireSession);
469
- const runs = await listLoopRunRecordsWithFallback(loopId, sessions, (sessionId) => store.loadTerminalLog(sessionId));
470
- jsonResponse(res, 200, { runs });
410
+ jsonResponse(res, 404, { error: 'Not found' });
471
411
  return;
472
412
  }
473
413
  const sessionMatch = path.match(/^\/api\/sessions\/([^/]+)$/);
@@ -479,10 +419,11 @@ export class LocalApiServer extends EventEmitter {
479
419
  const sessionInboxMatch = path.match(/^\/api\/sessions\/([^/]+)\/inbox$/);
480
420
  const sessionResizeMatch = path.match(/^\/api\/sessions\/([^/]+)\/resize$/);
481
421
  const sessionRunningMatch = path.match(/^\/api\/sessions\/([^/]+)\/running$/);
422
+ const sessionUsageMatch = path.match(/^\/api\/sessions\/([^/]+)\/usage$/);
482
423
  // GET /api/sessions/:id
483
424
  if (sessionMatch && req.method === 'GET') {
484
425
  const sessionId = sessionMatch[1];
485
- const session = await this.store.loadSession(sessionId);
426
+ const session = await (this.getSessionController()?.get(sessionId) ?? this.store.loadSession(sessionId));
486
427
  if (!session) {
487
428
  jsonResponse(res, 404, { error: 'Session not found' });
488
429
  return;
@@ -493,16 +434,17 @@ export class LocalApiServer extends EventEmitter {
493
434
  // DELETE /api/sessions/:id — remove the session (archived as a tombstone)
494
435
  if (sessionMatch && req.method === 'DELETE') {
495
436
  const sessionId = sessionMatch[1];
496
- if (!this.runner || !this.centrifugo || !this.userId) {
437
+ const controller = this.getSessionController();
438
+ if (!controller) {
497
439
  jsonResponse(res, 503, { error: 'Server not ready' });
498
440
  return;
499
441
  }
500
- const session = await this.store.loadSession(sessionId);
442
+ const session = await controller.get(sessionId);
501
443
  if (!session) {
502
444
  jsonResponse(res, 404, { error: 'Session not found' });
503
445
  return;
504
446
  }
505
- await removeFtownSession({ store: this.store, runner: this.runner, centrifugo: this.centrifugo, userId: this.userId }, sessionId);
447
+ await controller.remove(sessionId);
506
448
  jsonResponse(res, 200, { removed: true, sessionId });
507
449
  return;
508
450
  }
@@ -542,27 +484,12 @@ export class LocalApiServer extends EventEmitter {
542
484
  const parentSessionId = tombstone.parentSessionId
543
485
  ? (await this.store.loadSession(tombstone.parentSessionId)) ? tombstone.parentSessionId : undefined
544
486
  : undefined;
545
- // Custom-command sessions (command override at create time) must rerun
487
+ // Custom-command tombstones (command override at create time) must rerun
546
488
  // their command verbatim — the builder would silently swap in a stock
547
- // claude session. Builder-generated commands are rebuilt instead so the
548
- // tombstone's agent session id is injected as --resume (matching
549
- // resurrectSession's heuristic).
550
- const builderDefault = buildSessionCommand({
551
- shellType: tombstone.shellType,
552
- workingDir: tombstone.workingDir,
553
- model: tombstone.model,
554
- });
555
- const builderResume = buildSessionCommand({
556
- shellType: tombstone.shellType,
557
- workingDir: tombstone.workingDir,
558
- model: tombstone.model,
559
- claudeSessionId: tombstone.claudeSessionId,
560
- cursorSessionId: tombstone.cursorSessionId,
561
- codexSessionId: tombstone.codexSessionId,
562
- });
563
- const isCustomCommand = Boolean(tombstone.command)
564
- && tombstone.command !== builderDefault
565
- && tombstone.command !== builderResume;
489
+ // claude session. Builder-generated commands are rebuilt by
490
+ // createFtownSession instead, so the tombstone's agent session id is
491
+ // injected as --resume. The classification lives in the session module.
492
+ const { isCustom: isCustomCommand } = deriveRelaunchCommand(tombstone);
566
493
  try {
567
494
  const session = await createFtownSession(this.sessionDeps, {
568
495
  command: isCustomCommand ? tombstone.command : undefined,
@@ -599,56 +526,30 @@ export class LocalApiServer extends EventEmitter {
599
526
  }
600
527
  return;
601
528
  }
602
- // PATCH /api/sessions/:id
529
+ // PATCH /api/sessions/:id — rename and/or reparent
603
530
  if (sessionMatch && req.method === 'PATCH') {
604
531
  const sessionId = sessionMatch[1];
605
532
  const body = await parseBody(req);
606
533
  const name = body.name;
607
534
  const hasParentField = Object.prototype.hasOwnProperty.call(body, 'parentSessionId');
608
- const rawParent = body.parentSessionId;
609
535
  if (name === undefined && !hasParentField) {
610
536
  jsonResponse(res, 400, { error: 'Nothing to update' });
611
537
  return;
612
538
  }
613
- const session = await this.store.loadSession(sessionId);
614
- if (!session) {
615
- jsonResponse(res, 404, { error: 'Session not found' });
539
+ const controller = this.getSessionController();
540
+ if (!controller) {
541
+ jsonResponse(res, 503, { error: 'Server not ready' });
616
542
  return;
617
543
  }
618
- if (name !== undefined) {
619
- if (typeof name !== 'string' || !name) {
620
- jsonResponse(res, 400, { error: 'Invalid name' });
621
- return;
622
- }
623
- session.name = name;
624
- }
625
- if (hasParentField) {
626
- if (rawParent === null || rawParent === '' || rawParent === undefined) {
627
- session.parentSessionId = undefined;
628
- }
629
- else if (typeof rawParent !== 'string') {
630
- jsonResponse(res, 400, { error: 'Invalid parentSessionId' });
631
- return;
632
- }
633
- else if (rawParent === sessionId) {
634
- jsonResponse(res, 400, { error: 'Session cannot be its own parent' });
635
- return;
636
- }
637
- else {
638
- const proposed = await this.store.loadSession(rawParent);
639
- if (!proposed) {
640
- jsonResponse(res, 400, { error: 'Parent session not found' });
641
- return;
642
- }
643
- session.parentSessionId = proposed.parentSessionId ?? proposed.id;
644
- }
645
- }
646
- session.updatedAt = new Date().toISOString();
647
- await this.store.saveSession(session);
648
- if (this.centrifugo && this.userId) {
649
- await this.centrifugo.publishSessionUpdate(this.userId, session);
544
+ const result = await controller.update(sessionId, {
545
+ ...(name === undefined ? {} : { name }),
546
+ ...(hasParentField ? { parent: { value: body.parentSessionId } } : {}),
547
+ });
548
+ if (!result.ok) {
549
+ jsonResponse(res, result.code === 'not_found' ? 404 : 400, { error: result.message });
550
+ return;
650
551
  }
651
- jsonResponse(res, 200, { session: toWireSession(session) });
552
+ jsonResponse(res, 200, { session: toWireSession(result.session) });
652
553
  return;
653
554
  }
654
555
  // GET /api/sessions/:id/screen
@@ -801,7 +702,7 @@ export class LocalApiServer extends EventEmitter {
801
702
  if (fromSession)
802
703
  sender = fromSession.name || from.slice(0, 8);
803
704
  }
804
- const wrote = await this.injectPtyLine(session, sender, text);
705
+ const wrote = await this.mail.injectPtyLine(session, sender, text);
805
706
  if (!wrote) {
806
707
  jsonResponse(res, 409, { error: 'Session not running' });
807
708
  return;
@@ -841,6 +742,21 @@ export class LocalApiServer extends EventEmitter {
841
742
  jsonResponse(res, 200, { resized: true });
842
743
  return;
843
744
  }
745
+ // GET /api/sessions/:id/usage — per-session token/cost usage
746
+ if (sessionUsageMatch && req.method === 'GET') {
747
+ const controller = this.getSessionController();
748
+ if (!controller) {
749
+ jsonResponse(res, 503, { error: 'Server not ready' });
750
+ return;
751
+ }
752
+ const result = await controller.usage(sessionUsageMatch[1]);
753
+ if (!result.ok) {
754
+ jsonResponse(res, 404, { error: result.message });
755
+ return;
756
+ }
757
+ jsonResponse(res, 200, { usage: result.usage });
758
+ return;
759
+ }
844
760
  // GET /api/sessions/:id/running
845
761
  if (sessionRunningMatch && req.method === 'GET') {
846
762
  const sessionId = sessionRunningMatch[1];
@@ -855,25 +771,8 @@ export class LocalApiServer extends EventEmitter {
855
771
  }
856
772
  jsonResponse(res, 404, { error: 'Not found' });
857
773
  }
858
- // Plain shells would execute the message as a command; deliver it as a quoted no-op
859
- // (`: '...'`) — interactive zsh has no '#' comments by default.
860
- async injectPtyLine(session, sender, text) {
861
- if (!this.runner)
862
- return false;
863
- const isAgent = session.shellType && session.shellType !== 'shell';
864
- const line = isAgent
865
- ? `[ftown msg from ${sender}] ${text}`
866
- : `: '[ftown msg from ${sender}] ${text.replace(/'/g, '')}'`;
867
- if (!this.runner.write(session.id, line))
868
- return false;
869
- // Composer TUIs detect pastes by input arrival rate; the submit CR must come well
870
- // after that window or it is treated as a pasted newline.
871
- await delay(600);
872
- this.runner.write(session.id, submitSuffixFor(session.shellType));
873
- return true;
874
- }
875
774
  async handleInboxPost(req, res, sessionId) {
876
- if (!this.store || !this.mailStore) {
775
+ if (!this.store || !this.mail.isReady()) {
877
776
  jsonResponse(res, 503, { error: 'Mail store not ready' });
878
777
  return;
879
778
  }
@@ -883,182 +782,39 @@ export class LocalApiServer extends EventEmitter {
883
782
  return;
884
783
  }
885
784
  const body = await parseBody(req);
886
- const text = body.body;
887
- if (typeof text !== 'string' || !text.trim()) {
888
- jsonResponse(res, 400, { error: 'Missing body' });
785
+ const result = await this.mail.acceptMail(session, body);
786
+ if (!result.ok) {
787
+ jsonResponse(res, 400, { error: result.error });
889
788
  return;
890
789
  }
891
- if (text.length > MAX_MAIL_BODY_LENGTH) {
892
- jsonResponse(res, 400, { error: `Body too long (max ${MAX_MAIL_BODY_LENGTH} chars)` });
893
- return;
894
- }
895
- let type = 'message';
896
- if (body.type !== undefined) {
897
- if (typeof body.type !== 'string' || !MAIL_TYPES.includes(body.type)) {
898
- jsonResponse(res, 400, { error: `Invalid type (expected one of: ${MAIL_TYPES.join(', ')})` });
899
- return;
900
- }
901
- type = body.type;
902
- }
903
- const from = typeof body.from === 'string' && body.from.trim() ? body.from.trim() : 'external';
904
- const fromName = typeof body.fromName === 'string' && body.fromName.trim() ? body.fromName.trim() : undefined;
905
- const threadId = typeof body.threadId === 'string' && body.threadId.trim() ? body.threadId.trim() : undefined;
906
- const msg = createMailMessage({ from, fromName, to: session.id, type, threadId, body: text });
907
- await this.mailStore.append(msg);
908
- const waiter = this.mailWaiters.get(session.id);
909
- if (waiter) {
910
- const undelivered = await this.mailStore.listUndelivered(session.id);
911
- const marked = await this.mailStore.markDelivered(session.id, undelivered.map((m) => m.id), 'poll');
912
- waiter.deliver(marked);
913
- }
914
- else {
915
- const nudgeDelay = HOOKED_SHELL_TYPES.has(session.shellType ?? 'claude')
916
- ? MAIL_NUDGE_DELAY_HOOKED_MS
917
- : MAIL_NUDGE_DELAY_MS;
918
- this.scheduleMailNudge(session.id, fromName ?? from, nudgeDelay);
919
- }
920
- jsonResponse(res, 201, { id: msg.id });
790
+ jsonResponse(res, 201, { id: result.id });
921
791
  }
922
792
  async handleInboxGet(res, sessionId, url) {
923
- if (!this.store || !this.mailStore) {
793
+ if (!this.store || !this.mail.isReady()) {
924
794
  jsonResponse(res, 503, { error: 'Mail store not ready' });
925
795
  return;
926
796
  }
927
- const mailStore = this.mailStore;
928
797
  const session = await this.store.loadSession(sessionId);
929
798
  if (!session) {
930
799
  jsonResponse(res, 404, { error: 'Session not found' });
931
800
  return;
932
801
  }
933
- const wait = Math.min(getQueryInt(url, 'wait', 0), MAX_MAIL_WAIT_SECONDS);
934
- const peek = getQueryInt(url, 'peek', 0) === 1;
935
- const all = url.searchParams.get('all') === '1';
936
- const limit = getQueryInt(url, 'limit', 50);
937
- if (peek) {
938
- const messages = all
939
- ? await mailStore.listAll(session.id, limit)
940
- : await mailStore.listUndelivered(session.id);
941
- jsonResponse(res, 200, { messages });
942
- return;
943
- }
944
- // The listen window is earned, not universal: a session with no mail
945
- // relationships (no parent, no children, not an orchestrator) gets an
946
- // instant stop instead of holding its Stop hook open for `wait` seconds.
947
- // Late mail for everyone is still covered by the idle nudge.
948
- let effectiveWait = wait;
949
- if (wait > 0 && !(await this.participatesInMail(session))) {
950
- effectiveWait = 0;
951
- }
952
- const undelivered = await mailStore.listUndelivered(session.id);
953
- if (undelivered.length > 0 || effectiveWait <= 0) {
954
- const marked = await mailStore.markDelivered(session.id, undelivered.map((m) => m.id), effectiveWait > 0 ? 'poll' : 'drain');
955
- jsonResponse(res, 200, { messages: marked });
956
- return;
957
- }
958
- // Long poll: hold until mail arrives or `wait` seconds elapse. One waiter
959
- // per session — a newer waiter resolves the previous one with [].
960
- const existing = this.mailWaiters.get(session.id);
961
- if (existing)
962
- existing.deliver([]);
963
- let settled = false;
964
- const waiter = {
965
- deliver: (messages) => {
966
- if (settled)
967
- return;
968
- settled = true;
969
- clearTimeout(timer);
970
- if (this.mailWaiters.get(session.id) === waiter) {
971
- this.mailWaiters.delete(session.id);
972
- }
973
- jsonResponse(res, 200, { messages });
974
- },
975
- };
976
- const timer = setTimeout(() => {
977
- // Guard each async step on settled: once the client disconnects (or a new
978
- // waiter replaced this one) we must not mark messages delivered — they
979
- // would be lost without ever reaching a session.
980
- if (settled || this.mailWaiters.get(session.id) !== waiter)
981
- return;
982
- mailStore
983
- .listUndelivered(session.id)
984
- .then((pending) => {
985
- if (settled || pending.length === 0)
986
- return [];
987
- return mailStore.markDelivered(session.id, pending.map((m) => m.id), 'poll');
988
- })
989
- .then((marked) => waiter.deliver(marked))
990
- .catch(() => waiter.deliver([]));
991
- }, effectiveWait * 1000);
992
- this.mailWaiters.set(session.id, waiter);
993
- res.on('close', () => {
994
- if (this.mailWaiters.get(session.id) !== waiter)
995
- return;
996
- settled = true;
997
- clearTimeout(timer);
998
- this.mailWaiters.delete(session.id);
802
+ const result = await this.mail.readMail(session, {
803
+ wait: getQueryInt(url, 'wait', 0),
804
+ peek: getQueryInt(url, 'peek', 0) === 1,
805
+ all: url.searchParams.get('all') === '1',
806
+ limit: getQueryInt(url, 'limit', 50),
999
807
  });
1000
- }
1001
- // Debounced wake-up for mail that no long-poll consumed: after 5s, if it is still
1002
- // undelivered and the session is running, inject a one-line pointer to the
1003
- // harness mail command. At most one nudge per session per 30s; senders coalesce.
1004
- scheduleMailNudge(sessionId, fromLabel, delayMs = MAIL_NUDGE_DELAY_MS) {
1005
- this.pendingNudgeFrom.set(sessionId, fromLabel);
1006
- if (this.nudgeTimers.has(sessionId))
1007
- return;
1008
- const timer = setTimeout(() => {
1009
- this.nudgeTimers.delete(sessionId);
1010
- this.fireMailNudge(sessionId).catch((err) => {
1011
- console.error('[LocalApiServer] Mail nudge failed:', err instanceof Error ? err.message : String(err));
1012
- });
1013
- }, delayMs);
1014
- timer.unref();
1015
- this.nudgeTimers.set(sessionId, timer);
1016
- }
1017
- /** A session earns the Stop-hook listen window only if mail can plausibly
1018
- * arrive: it has a parent, has children, was marked an orchestrator, or has
1019
- * exchanged mail before. */
1020
- async participatesInMail(session) {
1021
- if (session.parentSessionId)
1022
- return true;
1023
- if (session.env?.FTOWN_ORCHESTRATOR === '1')
1024
- return true;
1025
- if (!this.store || !this.mailStore)
1026
- return false;
1027
- const sessions = await this.store.listSessions();
1028
- if (sessions.some((s) => s.parentSessionId === session.id))
1029
- return true;
1030
- const history = await this.mailStore.listAll(session.id, 1);
1031
- return history.length > 0;
1032
- }
1033
- async fireMailNudge(sessionId) {
1034
- if (!this.store || !this.mailStore || !this.runner)
1035
- return;
1036
- const fromLabel = this.pendingNudgeFrom.get(sessionId) ?? 'ftown';
1037
- const undelivered = await this.mailStore.listUndelivered(sessionId);
1038
- if (undelivered.length === 0) {
1039
- this.pendingNudgeFrom.delete(sessionId);
808
+ if (result.kind === 'immediate') {
809
+ jsonResponse(res, 200, { messages: result.messages });
1040
810
  return;
1041
811
  }
1042
- const busySince = this.agentBusySince.get(sessionId);
1043
- if (busySince !== undefined && Date.now() - busySince < AGENT_BUSY_STALE_MS) {
1044
- this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
1045
- return;
1046
- }
1047
- const sinceLast = Date.now() - (this.lastNudgeAt.get(sessionId) ?? 0);
1048
- if (sinceLast < MAIL_NUDGE_MIN_INTERVAL_MS) {
1049
- // Rate-limited: retry once the window reopens (mail may still be unread).
1050
- this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_MIN_INTERVAL_MS - sinceLast);
1051
- return;
1052
- }
1053
- this.pendingNudgeFrom.delete(sessionId);
1054
- if (!this.runner.isRunning(sessionId))
1055
- return;
1056
- const session = await this.store.loadSession(sessionId);
1057
- if (!session)
1058
- return;
1059
- this.lastNudgeAt.set(sessionId, Date.now());
1060
- const text = sanitizeMessageText(`You have new ftown mail from ${fromLabel} — run \`~/.ftown/bin/ftown-harness mail read\` to read it.`);
1061
- await this.injectPtyLine(session, 'ftown-mail', text);
812
+ // Long poll: the promise resolves when mail arrives or the wait elapses.
813
+ // On client disconnect abandon the waiter the promise then never resolves
814
+ // and no response is written (the socket is gone anyway).
815
+ res.on('close', result.abandon);
816
+ const messages = await result.messages;
817
+ jsonResponse(res, 200, { messages });
1062
818
  }
1063
819
  handleHook(req, res) {
1064
820
  const chunks = [];
@@ -1086,10 +842,10 @@ export class LocalApiServer extends EventEmitter {
1086
842
  return;
1087
843
  }
1088
844
  if (hookEventName === 'UserPromptSubmit' || hookEventName === 'PreToolUse' || hookEventName === 'PostToolUse') {
1089
- this.agentBusySince.set(ftownSessionId, Date.now());
845
+ this.mail.markAgentBusy(ftownSessionId);
1090
846
  }
1091
847
  else if (hookEventName === 'Stop' || hookEventName === 'SessionEnd') {
1092
- this.agentBusySince.delete(ftownSessionId);
848
+ this.mail.markAgentIdle(ftownSessionId);
1093
849
  }
1094
850
  const conversationId = payload.conversation_id;
1095
851
  // Workspace attribution may belong to a foreign agent running in the