ftown-bridge 0.19.1 → 0.19.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-commands.d.ts +3 -17
- package/dist/agent-commands.js +8 -94
- package/dist/agent-commands.js.map +1 -1
- package/dist/bridge-auth.d.ts +21 -0
- package/dist/bridge-auth.js +48 -0
- package/dist/bridge-auth.js.map +1 -0
- package/dist/centrifugo-client.js +39 -0
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/command-rpc.d.ts +15 -0
- package/dist/command-rpc.js +249 -0
- package/dist/command-rpc.js.map +1 -0
- package/dist/create-ftown-session.d.ts +43 -0
- package/dist/create-ftown-session.js +115 -9
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/ftown-sessions-cli.js +74 -4
- package/dist/ftown-sessions-cli.js.map +1 -1
- package/dist/harness-cli.js +1 -13
- package/dist/harness-cli.js.map +1 -1
- package/dist/harness-installer.js +4 -2
- package/dist/harness-installer.js.map +1 -1
- package/dist/harness-registry.d.ts +189 -0
- package/dist/harness-registry.js +218 -0
- package/dist/harness-registry.js.map +1 -0
- package/dist/index.js +71 -721
- package/dist/index.js.map +1 -1
- package/dist/install-ftown-cli.js +12 -3
- package/dist/install-ftown-cli.js.map +1 -1
- package/dist/local-api-server.d.ts +14 -13
- package/dist/local-api-server.js +180 -424
- package/dist/local-api-server.js.map +1 -1
- package/dist/loop-controller.d.ts +87 -0
- package/dist/loop-controller.js +133 -0
- package/dist/loop-controller.js.map +1 -0
- package/dist/loop-run-store.js +2 -8
- package/dist/loop-run-store.js.map +1 -1
- package/dist/loop-validation.d.ts +1 -0
- package/dist/loop-validation.js +3 -8
- package/dist/loop-validation.js.map +1 -1
- package/dist/mail-delivery.d.ts +114 -0
- package/dist/mail-delivery.js +315 -0
- package/dist/mail-delivery.js.map +1 -0
- package/dist/session-controller.d.ts +107 -0
- package/dist/session-controller.js +141 -0
- package/dist/session-controller.js.map +1 -0
- package/dist/session-ids.d.ts +19 -0
- package/dist/session-ids.js +72 -0
- package/dist/session-ids.js.map +1 -0
- package/dist/session-resurrection.d.ts +24 -0
- package/dist/session-resurrection.js +128 -0
- package/dist/session-resurrection.js.map +1 -1
- package/dist/spawn-stagger.d.ts +22 -0
- package/dist/spawn-stagger.js +34 -0
- package/dist/spawn-stagger.js.map +1 -0
- package/dist/terminal-pump.d.ts +61 -0
- package/dist/terminal-pump.js +193 -0
- package/dist/terminal-pump.js.map +1 -0
- package/dist/types.d.ts +29 -4
- package/dist/usage-collector.d.ts +42 -0
- package/dist/usage-collector.js +317 -0
- package/dist/usage-collector.js.map +1 -0
- package/dist/wire-types.d.ts +124 -0
- package/dist/wire-types.js +28 -0
- package/dist/wire-types.js.map +1 -0
- package/dist/workflow-runner-cli.js +2 -1
- package/dist/workflow-runner-cli.js.map +1 -1
- package/dist/workflow-runner.d.ts +2 -1
- package/dist/workflow-runner.js.map +1 -1
- package/package.json +1 -1
package/dist/local-api-server.js
CHANGED
|
@@ -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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
372
|
-
if (
|
|
373
|
-
const
|
|
374
|
-
if (!
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
//
|
|
448
|
-
|
|
449
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
548
|
-
// tombstone's agent session id is
|
|
549
|
-
//
|
|
550
|
-
const
|
|
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
|
|
614
|
-
if (!
|
|
615
|
-
jsonResponse(res,
|
|
539
|
+
const controller = this.getSessionController();
|
|
540
|
+
if (!controller) {
|
|
541
|
+
jsonResponse(res, 503, { error: 'Server not ready' });
|
|
616
542
|
return;
|
|
617
543
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
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.
|
|
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
|
|
887
|
-
if (
|
|
888
|
-
jsonResponse(res, 400, { error:
|
|
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
|
-
|
|
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.
|
|
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
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
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.
|
|
845
|
+
this.mail.markAgentBusy(ftownSessionId);
|
|
1090
846
|
}
|
|
1091
847
|
else if (hookEventName === 'Stop' || hookEventName === 'SessionEnd') {
|
|
1092
|
-
this.
|
|
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
|