ftown 0.26.0 → 0.28.0
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/attention-cli.d.ts +32 -0
- package/dist/attention-cli.js +310 -0
- package/dist/attention-cli.js.map +1 -0
- package/dist/attention-events.d.ts +2 -1
- package/dist/attention-events.js.map +1 -1
- package/dist/attention-store.d.ts +6 -3
- package/dist/attention-store.js +60 -2
- package/dist/attention-store.js.map +1 -1
- package/dist/brief-cli.d.ts +10 -0
- package/dist/brief-cli.js +163 -0
- package/dist/brief-cli.js.map +1 -0
- package/dist/command-rpc.d.ts +11 -0
- package/dist/command-rpc.js +83 -16
- package/dist/command-rpc.js.map +1 -1
- package/dist/coordination/attention-routing.d.ts +37 -0
- package/dist/coordination/attention-routing.js +154 -0
- package/dist/coordination/attention-routing.js.map +1 -0
- package/dist/coordination/board.js +82 -3
- package/dist/coordination/board.js.map +1 -1
- package/dist/coordination/contract-b.d.ts +206 -0
- package/dist/coordination/contract-b.js +58 -0
- package/dist/coordination/contract-b.js.map +1 -0
- package/dist/coordination/contract-c.d.ts +117 -0
- package/dist/coordination/contract-c.js +47 -0
- package/dist/coordination/contract-c.js.map +1 -0
- package/dist/coordination/contract.d.ts +8 -1
- package/dist/coordination/contract.js +1 -1
- package/dist/coordination/contract.js.map +1 -1
- package/dist/coordination/format.js +51 -12
- package/dist/coordination/format.js.map +1 -1
- package/dist/coordination/plan.d.ts +74 -0
- package/dist/coordination/plan.js +534 -0
- package/dist/coordination/plan.js.map +1 -0
- package/dist/coordination/routes-brief.d.ts +10 -0
- package/dist/coordination/routes-brief.js +149 -0
- package/dist/coordination/routes-brief.js.map +1 -0
- package/dist/coordination/routes-plan.d.ts +13 -0
- package/dist/coordination/routes-plan.js +182 -0
- package/dist/coordination/routes-plan.js.map +1 -0
- package/dist/coordination/routes-worktrees.d.ts +29 -0
- package/dist/coordination/routes-worktrees.js +138 -0
- package/dist/coordination/routes-worktrees.js.map +1 -0
- package/dist/coordination/wiring.d.ts +17 -2
- package/dist/coordination/wiring.js +81 -11
- package/dist/coordination/wiring.js.map +1 -1
- package/dist/coordination-cli.d.ts +6 -1
- package/dist/coordination-cli.js +23 -7
- package/dist/coordination-cli.js.map +1 -1
- package/dist/create-ftown-session.d.ts +26 -1
- package/dist/create-ftown-session.js +208 -104
- package/dist/create-ftown-session.js.map +1 -1
- package/dist/ftown-cli.js +14 -2
- package/dist/ftown-cli.js.map +1 -1
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -1
- package/dist/local-api-server.d.ts +24 -0
- package/dist/local-api-server.js +473 -32
- package/dist/local-api-server.js.map +1 -1
- package/dist/mail-delivery.d.ts +18 -4
- package/dist/mail-delivery.js +50 -8
- package/dist/mail-delivery.js.map +1 -1
- package/dist/plan-cli.d.ts +52 -0
- package/dist/plan-cli.js +357 -0
- package/dist/plan-cli.js.map +1 -0
- package/dist/security/bridge/permissions.js +55 -5
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/session-controller.d.ts +17 -1
- package/dist/session-controller.js +93 -2
- package/dist/session-controller.js.map +1 -1
- package/dist/sessions-commands.js +75 -5
- package/dist/sessions-commands.js.map +1 -1
- package/dist/types.d.ts +9 -1
- package/dist/worktrees-cli.d.ts +39 -0
- package/dist/worktrees-cli.js +168 -0
- package/dist/worktrees-cli.js.map +1 -0
- package/dist/worktrees.d.ts +16 -0
- package/dist/worktrees.js +292 -0
- package/dist/worktrees.js.map +1 -0
- package/hooks/ftown-antigravity-hook.sh +12 -6
- package/package.json +2 -2
- package/skills/ftown/SKILL.md +88 -5
- package/skills/ftown/references/orchestrator.md +32 -29
- package/skills/ftown/references/sessions.md +155 -12
package/dist/local-api-server.js
CHANGED
|
@@ -15,18 +15,28 @@ import { MailDeliveryService, sanitizeMessageText, parseNativeWakeRegistration,
|
|
|
15
15
|
import { AUTOMATION_REMOVED_CODE, AUTOMATION_REMOVED_MESSAGE, isRetiredAutomationSession } from './retired-automation.js';
|
|
16
16
|
import { SessionController } from './session-controller.js';
|
|
17
17
|
import { registerSessionConversation, resolveSessionIdByConversation, resolveSessionIdFromHookPayload, } from './session-registry.js';
|
|
18
|
-
import { canResumeStoredSession, createFtownSession, deriveRelaunchCommand, parseCreateSessionBody, ProviderAuthMissingError, WorkingDirMissingError, } from './create-ftown-session.js';
|
|
18
|
+
import { canResumeStoredSession, createFtownSession, deriveRelaunchCommand, parseCreateSessionBody, PlanItemError, ProviderAuthMissingError, WorkingDirMissingError, } from './create-ftown-session.js';
|
|
19
19
|
import { removeFtownSession } from './remove-ftown-session.js';
|
|
20
20
|
import { collectSessionUsage } from './usage-collector.js';
|
|
21
21
|
import { toWireSession } from './session-wire.js';
|
|
22
22
|
import { CardValidationError } from './coordination/contract.js';
|
|
23
|
+
import { PARENT_UNAVAILABLE_NOTE, RouteError, WorktreeError, } from './coordination/contract-b.js';
|
|
24
|
+
import { decideRoute, deliverAttentionAnswer, formatParentQuestionMail, markArchivedWorktreeRetained, } from './coordination/attention-routing.js';
|
|
25
|
+
import { handleBriefRoutes } from './coordination/routes-brief.js';
|
|
26
|
+
import { createPlanRoutes } from './coordination/routes-plan.js';
|
|
27
|
+
import { createWorktreeRoutes } from './coordination/routes-worktrees.js';
|
|
23
28
|
import { createCoordination, rootOf } from './coordination/wiring.js';
|
|
29
|
+
import { GitWorktrees } from './worktrees.js';
|
|
24
30
|
function jsonResponse(res, status, data) {
|
|
25
31
|
res.writeHead(status, { 'Content-Type': 'application/json' });
|
|
26
32
|
res.end(JSON.stringify(data));
|
|
27
33
|
}
|
|
28
34
|
class BadRequestError extends Error {
|
|
29
35
|
}
|
|
36
|
+
/** PLAN_ROUTES paths: /api/sessions/:id/plan and /api/sessions/:id/plan/:itemId. */
|
|
37
|
+
function isPlanRoutePath(path) {
|
|
38
|
+
return /^\/api\/sessions\/[^/]+\/plan(?:\/[^/]+)?$/.test(path);
|
|
39
|
+
}
|
|
30
40
|
/**
|
|
31
41
|
* A blocked provider create/revive (mapped flavor with no machine token anywhere)
|
|
32
42
|
* is a client-fixable 422, not a 500. The body carries the provider, the
|
|
@@ -254,8 +264,10 @@ export class LocalApiServer extends EventEmitter {
|
|
|
254
264
|
});
|
|
255
265
|
await this.security.revalidate(message.principal);
|
|
256
266
|
void this.handleApiRoute(request, response, new URL(route.path, 'http://localhost').pathname, new URL(route.path, 'http://localhost')).catch(error => {
|
|
257
|
-
if (error instanceof BadRequestError)
|
|
267
|
+
if (error instanceof BadRequestError || error instanceof WorktreeError || error instanceof PlanItemError)
|
|
258
268
|
jsonResponse(response, 400, { error: error.message });
|
|
269
|
+
else if (error instanceof RouteError)
|
|
270
|
+
jsonResponse(response, error.status, { error: error.message });
|
|
259
271
|
else if (error instanceof AttentionError)
|
|
260
272
|
jsonResponse(response, error.status, { error: error.message, code: error.code });
|
|
261
273
|
else
|
|
@@ -269,10 +281,32 @@ export class LocalApiServer extends EventEmitter {
|
|
|
269
281
|
coordination = null;
|
|
270
282
|
defaultCoordination = null;
|
|
271
283
|
mailStore = null;
|
|
284
|
+
attentionPushHandler;
|
|
285
|
+
/** In-flight parent-routed creates. Pushes are held, then dropped only for that item id. */
|
|
286
|
+
parentPushAttempt = 0;
|
|
287
|
+
parentPushAttempts = new Map();
|
|
288
|
+
heldAttentionPushes = [];
|
|
272
289
|
recoveryEpoch = 0;
|
|
273
290
|
recoveryStartedAt = -1;
|
|
274
291
|
recoveryStopped = false;
|
|
275
292
|
runnerReadyListener = null;
|
|
293
|
+
worktreeRoutes = createWorktreeRoutes({
|
|
294
|
+
listLive: async () => (this.store ? this.store.listSessions() : []),
|
|
295
|
+
listArchived: async () => this.store
|
|
296
|
+
? (await this.store.listArchived()).map((record) => {
|
|
297
|
+
const sanitized = { ...record };
|
|
298
|
+
delete sanitized.env;
|
|
299
|
+
delete sanitized.previewSource;
|
|
300
|
+
return sanitized;
|
|
301
|
+
})
|
|
302
|
+
: [],
|
|
303
|
+
worktrees: new GitWorktrees(),
|
|
304
|
+
markRetained: async (sessionId, reason) => {
|
|
305
|
+
if (!this.store)
|
|
306
|
+
return;
|
|
307
|
+
await markArchivedWorktreeRetained((id) => this.store.sessionDir(id), sessionId, reason);
|
|
308
|
+
},
|
|
309
|
+
});
|
|
276
310
|
setBrowserAccess(access) {
|
|
277
311
|
this.browserAccess = access;
|
|
278
312
|
}
|
|
@@ -282,6 +316,311 @@ export class LocalApiServer extends EventEmitter {
|
|
|
282
316
|
setAttentionStore(store) {
|
|
283
317
|
this.attention = store;
|
|
284
318
|
this.defaultCoordination = null;
|
|
319
|
+
// onNotification is a private constructor parameter. It is still an own
|
|
320
|
+
// instance property at runtime; the cast is the only way to wrap it.
|
|
321
|
+
const internal = store;
|
|
322
|
+
const original = internal.onNotification;
|
|
323
|
+
this.attentionPushHandler = original;
|
|
324
|
+
if (original) {
|
|
325
|
+
internal.onNotification = (sessionId, notice) => {
|
|
326
|
+
const attempts = this.parentPushAttempts.get(sessionId);
|
|
327
|
+
if (attempts && attempts.size > 0) {
|
|
328
|
+
this.heldAttentionPushes.push({
|
|
329
|
+
sessionId,
|
|
330
|
+
notice,
|
|
331
|
+
owners: new Set(attempts),
|
|
332
|
+
dropped: false,
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
original(sessionId, notice);
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
/** Dashboard notice + push for an item that is reaching the human now. */
|
|
341
|
+
publishHumanAttention(sessionId, item) {
|
|
342
|
+
if (item.type === 'ask_user' || item.prompt?.channel === 'hook') {
|
|
343
|
+
this.emit('event', {
|
|
344
|
+
sessionId,
|
|
345
|
+
eventName: 'PreToolUse',
|
|
346
|
+
source: 'api',
|
|
347
|
+
data: {
|
|
348
|
+
hook_event_name: 'PreToolUse',
|
|
349
|
+
attentionId: item.id,
|
|
350
|
+
attentionSequence: item.sequence,
|
|
351
|
+
attentionCreatedAt: item.createdAt,
|
|
352
|
+
...(item.prompt ? { attentionPrompt: item.prompt } : {}),
|
|
353
|
+
},
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
else {
|
|
357
|
+
this.emit('event', {
|
|
358
|
+
sessionId,
|
|
359
|
+
eventName: 'Notification',
|
|
360
|
+
source: 'api',
|
|
361
|
+
data: {
|
|
362
|
+
notification_type: 'agent_needs_input',
|
|
363
|
+
title: item.title,
|
|
364
|
+
message: item.message,
|
|
365
|
+
notification_id: item.id,
|
|
366
|
+
attentionId: item.id,
|
|
367
|
+
attentionSequence: item.sequence,
|
|
368
|
+
attentionCreatedAt: item.createdAt,
|
|
369
|
+
...(item.prompt ? { attentionPrompt: item.prompt } : {}),
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
this.attentionPushHandler?.(sessionId, {
|
|
375
|
+
kind: 'question',
|
|
376
|
+
eventKey: `question:${item.id}`,
|
|
377
|
+
title: item.title,
|
|
378
|
+
message: item.message,
|
|
379
|
+
occurredAt: item.escalatedAt ?? item.createdAt,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
catch {
|
|
383
|
+
// Optional push delivery cannot fail escalation.
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
readCallerSessionId(req) {
|
|
387
|
+
const callerHeader = req.headers['x-ftown-session-id'];
|
|
388
|
+
return typeof callerHeader === 'string' && callerHeader.trim()
|
|
389
|
+
? callerHeader.trim()
|
|
390
|
+
: undefined;
|
|
391
|
+
}
|
|
392
|
+
readAttentionTarget(body) {
|
|
393
|
+
if (!Object.prototype.hasOwnProperty.call(body, 'to'))
|
|
394
|
+
return undefined;
|
|
395
|
+
if (body.to === 'human')
|
|
396
|
+
return 'human';
|
|
397
|
+
throw new BadRequestError("Invalid attention target");
|
|
398
|
+
}
|
|
399
|
+
async resolveAttentionRouting(child, to) {
|
|
400
|
+
if (!this.store || !child.parentSessionId) {
|
|
401
|
+
return { route: 'human', parent: null };
|
|
402
|
+
}
|
|
403
|
+
const parent = await this.store.loadSession(child.parentSessionId);
|
|
404
|
+
const parentRunning = !!parent &&
|
|
405
|
+
(typeof this.runner?.isRunning === 'function'
|
|
406
|
+
? this.runner.isRunning(parent.id)
|
|
407
|
+
: parent.status === 'running');
|
|
408
|
+
const route = decideRoute(child, parent, parentRunning, to);
|
|
409
|
+
return { route, parent };
|
|
410
|
+
}
|
|
411
|
+
/** Hold pushes for this session until the created item id is known. */
|
|
412
|
+
beginParentPushHold(sessionId) {
|
|
413
|
+
const attempt = ++this.parentPushAttempt;
|
|
414
|
+
let attempts = this.parentPushAttempts.get(sessionId);
|
|
415
|
+
if (!attempts) {
|
|
416
|
+
attempts = new Set();
|
|
417
|
+
this.parentPushAttempts.set(sessionId, attempts);
|
|
418
|
+
}
|
|
419
|
+
attempts.add(attempt);
|
|
420
|
+
return attempt;
|
|
421
|
+
}
|
|
422
|
+
/** Drop only `question:<suppressItemId>`. Every other held notice is delivered. */
|
|
423
|
+
finishParentPushHold(sessionId, attempt, suppressItemId) {
|
|
424
|
+
const attempts = this.parentPushAttempts.get(sessionId);
|
|
425
|
+
attempts?.delete(attempt);
|
|
426
|
+
if (attempts && attempts.size === 0)
|
|
427
|
+
this.parentPushAttempts.delete(sessionId);
|
|
428
|
+
const suppressKey = suppressItemId ? `question:${suppressItemId}` : undefined;
|
|
429
|
+
for (let index = this.heldAttentionPushes.length - 1; index >= 0; index--) {
|
|
430
|
+
const held = this.heldAttentionPushes[index];
|
|
431
|
+
if (!held.owners.has(attempt))
|
|
432
|
+
continue;
|
|
433
|
+
held.owners.delete(attempt);
|
|
434
|
+
if (suppressKey && held.notice.eventKey === suppressKey)
|
|
435
|
+
held.dropped = true;
|
|
436
|
+
if (held.owners.size === 0) {
|
|
437
|
+
this.heldAttentionPushes.splice(index, 1);
|
|
438
|
+
if (!held.dropped) {
|
|
439
|
+
try {
|
|
440
|
+
this.attentionPushHandler?.(held.sessionId, held.notice);
|
|
441
|
+
}
|
|
442
|
+
catch { /* Optional push delivery cannot fail the request. */ }
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
/** Apply a parent route only when the item was just created. Repeats keep their stored route. */
|
|
448
|
+
async applyParentRoute(session, item, created, route, parent) {
|
|
449
|
+
if (created && route === 'parent' && parent && this.attention) {
|
|
450
|
+
const routed = await this.attention.setRoute(session.id, item.id, {
|
|
451
|
+
route: 'parent',
|
|
452
|
+
routedTo: parent.id,
|
|
453
|
+
});
|
|
454
|
+
if (!routed)
|
|
455
|
+
return { item, skipNotice: false };
|
|
456
|
+
const fallback = await this.sendParentQuestionMail(session, parent, routed);
|
|
457
|
+
// The create push is the parent's to swallow. A mail failure publishes its own.
|
|
458
|
+
return { item: fallback ?? routed, skipNotice: true, suppressPushId: item.id };
|
|
459
|
+
}
|
|
460
|
+
if (!created && item.route === 'parent')
|
|
461
|
+
return { item, skipNotice: true };
|
|
462
|
+
return { item, skipNotice: false };
|
|
463
|
+
}
|
|
464
|
+
/** Mail the parent. On failure, route the item to the human and publish now. Returns that item. */
|
|
465
|
+
async sendParentQuestionMail(child, parent, item) {
|
|
466
|
+
let failed = false;
|
|
467
|
+
try {
|
|
468
|
+
const accepted = await this.acceptSessionMail(parent, {
|
|
469
|
+
from: child.id,
|
|
470
|
+
fromName: child.name,
|
|
471
|
+
type: 'escalation',
|
|
472
|
+
threadId: item.id,
|
|
473
|
+
body: formatParentQuestionMail(child, item),
|
|
474
|
+
});
|
|
475
|
+
if (!accepted.ok) {
|
|
476
|
+
console.error('[Bridge] Failed to mail parent question:', accepted.error);
|
|
477
|
+
failed = true;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
catch (err) {
|
|
481
|
+
console.error('[Bridge] Failed to mail parent question:', err instanceof Error ? err.message : err);
|
|
482
|
+
failed = true;
|
|
483
|
+
}
|
|
484
|
+
if (!failed || !this.attention)
|
|
485
|
+
return undefined;
|
|
486
|
+
const updated = await this.attention.setRoute(child.id, item.id, {
|
|
487
|
+
route: 'human',
|
|
488
|
+
escalatedAt: new Date().toISOString(),
|
|
489
|
+
escalationNote: PARENT_UNAVAILABLE_NOTE,
|
|
490
|
+
}, 'parent');
|
|
491
|
+
if (updated && !updated.resolvedAt && updated.route === 'human') {
|
|
492
|
+
this.publishHumanAttention(child.id, updated);
|
|
493
|
+
return updated;
|
|
494
|
+
}
|
|
495
|
+
try {
|
|
496
|
+
return await this.attention.waitForItem(child.id, item.id, 0);
|
|
497
|
+
}
|
|
498
|
+
catch {
|
|
499
|
+
return item;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
/** Caller must be the child's parent. Returns the parent session, or null after writing 403. */
|
|
503
|
+
async loadParentCaller(req, res, child) {
|
|
504
|
+
const caller = this.readCallerSessionId(req);
|
|
505
|
+
if (!this.store || !child.parentSessionId || caller !== child.parentSessionId) {
|
|
506
|
+
jsonResponse(res, 403, { error: 'Only the parent session can do this', code: 'forbidden' });
|
|
507
|
+
return null;
|
|
508
|
+
}
|
|
509
|
+
const parent = await this.store.loadSession(child.parentSessionId);
|
|
510
|
+
if (!parent || parent.id !== child.parentSessionId) {
|
|
511
|
+
jsonResponse(res, 403, { error: 'Only the parent session can do this', code: 'forbidden' });
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
return parent;
|
|
515
|
+
}
|
|
516
|
+
async dispatchCoordinationRoutes(req, res, path, url) {
|
|
517
|
+
if (!this.store)
|
|
518
|
+
return false;
|
|
519
|
+
const store = this.store;
|
|
520
|
+
let responded = false;
|
|
521
|
+
const ctx = {
|
|
522
|
+
req,
|
|
523
|
+
res,
|
|
524
|
+
method: req.method ?? 'GET',
|
|
525
|
+
path,
|
|
526
|
+
query: url.searchParams,
|
|
527
|
+
callerSessionId: this.readCallerSessionId(req),
|
|
528
|
+
parseBody: () => parseBody(req),
|
|
529
|
+
json: (status, body) => {
|
|
530
|
+
responded = true;
|
|
531
|
+
jsonResponse(res, status, body);
|
|
532
|
+
},
|
|
533
|
+
loadSession: (id) => store.loadSession(id),
|
|
534
|
+
saveSession: (session) => store.saveSession(session),
|
|
535
|
+
publishSession: (session) => {
|
|
536
|
+
if (this.centrifugo && this.userId) {
|
|
537
|
+
void this.centrifugo.publishSessionUpdate(this.userId, session);
|
|
538
|
+
}
|
|
539
|
+
},
|
|
540
|
+
};
|
|
541
|
+
try {
|
|
542
|
+
if (await handleBriefRoutes(ctx))
|
|
543
|
+
return true;
|
|
544
|
+
if (isPlanRoutePath(path)) {
|
|
545
|
+
const plan = this.getCoordination()?.plan;
|
|
546
|
+
if (plan && await createPlanRoutes(plan)(ctx))
|
|
547
|
+
return true;
|
|
548
|
+
}
|
|
549
|
+
if (await this.worktreeRoutes(ctx))
|
|
550
|
+
return true;
|
|
551
|
+
return false;
|
|
552
|
+
}
|
|
553
|
+
catch (error) {
|
|
554
|
+
if (error instanceof RouteError) {
|
|
555
|
+
if (!responded && !res.headersSent)
|
|
556
|
+
ctx.json(error.status, { error: error.message });
|
|
557
|
+
return true;
|
|
558
|
+
}
|
|
559
|
+
throw error;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
async handleParentAttentionAction(req, res, childId, eventId, action) {
|
|
563
|
+
if (req.method !== 'POST') {
|
|
564
|
+
jsonResponse(res, 405, { error: 'Method not allowed' });
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (!this.store) {
|
|
568
|
+
jsonResponse(res, 503, { error: 'Server not ready' });
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const child = await this.store.loadSession(childId);
|
|
572
|
+
if (!child) {
|
|
573
|
+
jsonResponse(res, 404, { error: 'Session not found', code: 'unknown_session' });
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
if (!this.attention) {
|
|
577
|
+
jsonResponse(res, 503, { error: 'Attention store not ready', code: 'attention_not_ready' });
|
|
578
|
+
return;
|
|
579
|
+
}
|
|
580
|
+
const parent = await this.loadParentCaller(req, res, child);
|
|
581
|
+
if (!parent)
|
|
582
|
+
return;
|
|
583
|
+
if (action === 'answer') {
|
|
584
|
+
const body = await parseBody(req);
|
|
585
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
586
|
+
|| Object.keys(body).some((key) => key !== 'answers')) {
|
|
587
|
+
throw new BadRequestError('Invalid attention answer');
|
|
588
|
+
}
|
|
589
|
+
const { item, waiterLive } = await this.attention.answer(child.id, eventId, body.answers, undefined, { kind: 'session', sessionId: parent.id, name: parent.name });
|
|
590
|
+
await deliverAttentionAnswer({
|
|
591
|
+
child,
|
|
592
|
+
item,
|
|
593
|
+
waiterLive,
|
|
594
|
+
sender: { from: parent.id, fromName: parent.name },
|
|
595
|
+
acceptMail: (session, mailBody) => this.acceptSessionMail(session, mailBody),
|
|
596
|
+
});
|
|
597
|
+
jsonResponse(res, 200, { event: item });
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const body = await parseBody(req);
|
|
601
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
602
|
+
|| Object.keys(body).some((key) => key !== 'note')) {
|
|
603
|
+
throw new BadRequestError('Invalid escalation');
|
|
604
|
+
}
|
|
605
|
+
if (body.note !== undefined && (typeof body.note !== 'string' || body.note.length > 500)) {
|
|
606
|
+
throw new BadRequestError('Invalid escalation note');
|
|
607
|
+
}
|
|
608
|
+
const current = await this.attention.waitForItem(child.id, eventId, 0);
|
|
609
|
+
if (current.resolvedAt || current.route !== 'parent') {
|
|
610
|
+
jsonResponse(res, 409, { error: 'Attention request is already with the human', code: 'attention_conflict' });
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const updated = await this.attention.setRoute(child.id, eventId, {
|
|
614
|
+
route: 'human',
|
|
615
|
+
escalatedAt: new Date().toISOString(),
|
|
616
|
+
...(typeof body.note === 'string' ? { escalationNote: body.note } : {}),
|
|
617
|
+
}, 'parent');
|
|
618
|
+
if (!updated || updated.resolvedAt || updated.route !== 'human') {
|
|
619
|
+
jsonResponse(res, 409, { error: 'Attention request is already with the human', code: 'attention_conflict' });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
this.publishHumanAttention(child.id, updated);
|
|
623
|
+
jsonResponse(res, 200, { event: updated });
|
|
285
624
|
}
|
|
286
625
|
setCoordination(coordination) {
|
|
287
626
|
this.coordination = coordination;
|
|
@@ -307,6 +646,8 @@ export class LocalApiServer extends EventEmitter {
|
|
|
307
646
|
list: async (id) => this.attention
|
|
308
647
|
? this.attention.list(id)
|
|
309
648
|
: { activity: { state: 'unknown' }, openCount: 0 },
|
|
649
|
+
listRouted: async (childId) => this.attention ? this.attention.listRouted(childId) : [],
|
|
650
|
+
setRoute: async (sessionId, eventId, fields, expectRoute) => this.attention ? this.attention.setRoute(sessionId, eventId, fields, expectRoute) : undefined,
|
|
310
651
|
},
|
|
311
652
|
prStatus: async () => null,
|
|
312
653
|
mail: {
|
|
@@ -325,6 +666,9 @@ export class LocalApiServer extends EventEmitter {
|
|
|
325
666
|
}
|
|
326
667
|
},
|
|
327
668
|
now: Date.now,
|
|
669
|
+
onAttentionEscalated: (sessionId, item) => {
|
|
670
|
+
this.publishHumanAttention(sessionId, item);
|
|
671
|
+
},
|
|
328
672
|
});
|
|
329
673
|
return this.defaultCoordination;
|
|
330
674
|
}
|
|
@@ -427,6 +771,11 @@ export class LocalApiServer extends EventEmitter {
|
|
|
427
771
|
publishSessionUpdate: (session) => centrifugo.publishSessionUpdate(userId, session),
|
|
428
772
|
removeSession: (id, options) => removeFtownSession({ store, runner, centrifugo, userId }, id, options),
|
|
429
773
|
collectUsage: (session) => collectSessionUsage(session),
|
|
774
|
+
onSessionEnding: async (session) => {
|
|
775
|
+
const coordination = this.getCoordination();
|
|
776
|
+
if (coordination)
|
|
777
|
+
await coordination.onSessionExit(session.id, session);
|
|
778
|
+
},
|
|
430
779
|
...(this.sessionDeps ? { sessionFactory: this.sessionDeps } : {}),
|
|
431
780
|
});
|
|
432
781
|
return this.sessionController;
|
|
@@ -612,7 +961,17 @@ export class LocalApiServer extends EventEmitter {
|
|
|
612
961
|
if (caller?.shellType)
|
|
613
962
|
body.shellType = caller.shellType;
|
|
614
963
|
}
|
|
615
|
-
|
|
964
|
+
let input;
|
|
965
|
+
try {
|
|
966
|
+
input = parseCreateSessionBody(body, useCallerAsParent ? callerSessionId : undefined);
|
|
967
|
+
}
|
|
968
|
+
catch (err) {
|
|
969
|
+
if (err instanceof WorktreeError || err instanceof PlanItemError) {
|
|
970
|
+
jsonResponse(res, 400, { error: err.message });
|
|
971
|
+
return;
|
|
972
|
+
}
|
|
973
|
+
throw err;
|
|
974
|
+
}
|
|
616
975
|
if (!input.command && !input.shellType && !input.prompt) {
|
|
617
976
|
jsonResponse(res, 400, {
|
|
618
977
|
error: 'Provide shellType, prompt, and/or command',
|
|
@@ -629,6 +988,10 @@ export class LocalApiServer extends EventEmitter {
|
|
|
629
988
|
jsonResponse(res, 201, { session: toWireSession(session) });
|
|
630
989
|
}
|
|
631
990
|
catch (err) {
|
|
991
|
+
if (err instanceof WorktreeError || err instanceof PlanItemError) {
|
|
992
|
+
jsonResponse(res, 400, { error: err.message });
|
|
993
|
+
return;
|
|
994
|
+
}
|
|
632
995
|
if (err instanceof ProviderAuthMissingError) {
|
|
633
996
|
const r = providerAuthMissingResponse(err);
|
|
634
997
|
jsonResponse(res, r.status, r.body);
|
|
@@ -674,6 +1037,13 @@ export class LocalApiServer extends EventEmitter {
|
|
|
674
1037
|
jsonResponse(res, 200, { sessionId });
|
|
675
1038
|
return;
|
|
676
1039
|
}
|
|
1040
|
+
if (await this.dispatchCoordinationRoutes(req, res, path, url))
|
|
1041
|
+
return;
|
|
1042
|
+
const parentAttentionMatch = path.match(/^\/api\/sessions\/([a-zA-Z0-9_-]+)\/attention\/([A-Za-z0-9_-]{1,100})\/(answer|escalate)$/);
|
|
1043
|
+
if (parentAttentionMatch) {
|
|
1044
|
+
await this.handleParentAttentionAction(req, res, parentAttentionMatch[1], parentAttentionMatch[2], parentAttentionMatch[3]);
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
677
1047
|
const attentionMatch = path.match(/^\/api\/sessions\/([a-zA-Z0-9_-]+)\/attention(?:\/([A-Za-z0-9_-]+))?$/);
|
|
678
1048
|
if (attentionMatch) {
|
|
679
1049
|
const id = attentionMatch[1];
|
|
@@ -688,6 +1058,28 @@ export class LocalApiServer extends EventEmitter {
|
|
|
688
1058
|
}
|
|
689
1059
|
const attentionSub = attentionMatch[2];
|
|
690
1060
|
const attentionAction = attentionSub === 'ack' || attentionSub === 'resolve' ? attentionSub : undefined;
|
|
1061
|
+
if (attentionSub === 'routed') {
|
|
1062
|
+
if (req.method !== 'GET') {
|
|
1063
|
+
jsonResponse(res, 405, { error: 'Method not allowed' });
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
const caller = this.readCallerSessionId(req);
|
|
1067
|
+
if (caller !== id) {
|
|
1068
|
+
jsonResponse(res, 403, { error: 'Only the parent session can do this', code: 'forbidden' });
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
const sessions = await this.store.listSessions();
|
|
1072
|
+
const items = [];
|
|
1073
|
+
for (const child of sessions) {
|
|
1074
|
+
if (child.parentSessionId !== id || child.id === id)
|
|
1075
|
+
continue;
|
|
1076
|
+
const routed = await this.attention.listRouted(child.id);
|
|
1077
|
+
for (const event of routed)
|
|
1078
|
+
items.push({ sessionId: child.id, sessionName: child.name, event });
|
|
1079
|
+
}
|
|
1080
|
+
jsonResponse(res, 200, { items });
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
691
1083
|
if (attentionSub === 'ask') {
|
|
692
1084
|
if (req.method !== 'POST') {
|
|
693
1085
|
jsonResponse(res, 405, { error: 'Method not allowed' });
|
|
@@ -699,26 +1091,43 @@ export class LocalApiServer extends EventEmitter {
|
|
|
699
1091
|
}
|
|
700
1092
|
const body = await parseBody(req);
|
|
701
1093
|
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
702
|
-
|| Object.keys(body).some((key) => !['toolName', 'toolUseId', 'toolInput'].includes(key)))
|
|
1094
|
+
|| Object.keys(body).some((key) => !['toolName', 'toolUseId', 'toolInput', 'to'].includes(key)))
|
|
703
1095
|
throw new BadRequestError('Invalid ask arguments');
|
|
704
1096
|
if (typeof body.toolName !== 'string' || !body.toolName.trim() || body.toolName.length > 200)
|
|
705
1097
|
throw new BadRequestError('Invalid toolName');
|
|
706
1098
|
if (typeof body.toolUseId !== 'string' || !body.toolUseId.trim() || body.toolUseId.length > 200)
|
|
707
1099
|
throw new BadRequestError('Invalid toolUseId');
|
|
1100
|
+
const to = this.readAttentionTarget(body);
|
|
1101
|
+
const { route, parent } = await this.resolveAttentionRouting(session, to);
|
|
708
1102
|
const toolData = { tool_name: body.toolName, tool_use_id: body.toolUseId, tool_input: body.toolInput };
|
|
709
|
-
const
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
if (
|
|
718
|
-
await this.
|
|
1103
|
+
const hold = route === 'parent' ? this.beginParentPushHold(id) : undefined;
|
|
1104
|
+
let suppressItemId;
|
|
1105
|
+
try {
|
|
1106
|
+
const { item, created } = await this.attention.recordDetailed(id, 'PreToolUse', toolData);
|
|
1107
|
+
// When ftown's ordinary (async) PreToolUse hook is missing or late, this is the only signal the UI gets.
|
|
1108
|
+
// Same stamped payload as /hook; deduped asks publish nothing, and the agent is not marked busy.
|
|
1109
|
+
// Only a newly created parent-routed item skips that notice and the push.
|
|
1110
|
+
let responseItem = item;
|
|
1111
|
+
if (item && created) {
|
|
1112
|
+
const applied = await this.applyParentRoute(session, item, created, route, parent);
|
|
1113
|
+
responseItem = applied.item;
|
|
1114
|
+
suppressItemId = applied.suppressPushId;
|
|
1115
|
+
if (!applied.skipNotice) {
|
|
1116
|
+
this.emit('event', { sessionId: id, eventName: 'PreToolUse', source: 'api', data: {
|
|
1117
|
+
hook_event_name: 'PreToolUse', ...toolData,
|
|
1118
|
+
attentionId: applied.item.id, attentionSequence: applied.item.sequence, attentionCreatedAt: applied.item.createdAt,
|
|
1119
|
+
} });
|
|
1120
|
+
}
|
|
1121
|
+
if (session.parentSessionId) {
|
|
1122
|
+
await this.getCoordination()?.emit(session, 'attention', applied.item.title, applied.item.id);
|
|
1123
|
+
}
|
|
719
1124
|
}
|
|
1125
|
+
jsonResponse(res, 200, { event: responseItem && responseItem.prompt ? responseItem : null });
|
|
1126
|
+
}
|
|
1127
|
+
finally {
|
|
1128
|
+
if (hold !== undefined)
|
|
1129
|
+
this.finishParentPushHold(id, hold, suppressItemId);
|
|
720
1130
|
}
|
|
721
|
-
jsonResponse(res, 200, { event: item && item.prompt ? item : null });
|
|
722
1131
|
return;
|
|
723
1132
|
}
|
|
724
1133
|
if (attentionSub && !attentionAction) {
|
|
@@ -754,12 +1163,13 @@ export class LocalApiServer extends EventEmitter {
|
|
|
754
1163
|
}
|
|
755
1164
|
const body = await parseBody(req);
|
|
756
1165
|
if (!body || typeof body !== 'object' || Array.isArray(body)
|
|
757
|
-
|| Object.keys(body).some((key) => !['title', 'message', 'questions'].includes(key)))
|
|
1166
|
+
|| Object.keys(body).some((key) => !['title', 'message', 'questions', 'to'].includes(key)))
|
|
758
1167
|
throw new BadRequestError('Invalid attention arguments');
|
|
759
1168
|
if (typeof body.message !== 'string' || !body.message.trim() || body.message.trim().length > 2000)
|
|
760
1169
|
throw new BadRequestError('Invalid attention message');
|
|
761
1170
|
if (body.title !== undefined && (typeof body.title !== 'string' || !body.title.trim() || body.title.trim().length > 200))
|
|
762
1171
|
throw new BadRequestError('Invalid attention title');
|
|
1172
|
+
const to = this.readAttentionTarget(body);
|
|
763
1173
|
const title = sanitizeAttentionText(typeof body.title === 'string' ? body.title : 'Session needs your input', 200);
|
|
764
1174
|
const message = sanitizeAttentionText(body.message, 2000);
|
|
765
1175
|
if (!title || !message)
|
|
@@ -773,20 +1183,37 @@ export class LocalApiServer extends EventEmitter {
|
|
|
773
1183
|
}
|
|
774
1184
|
prompt = parsed.value;
|
|
775
1185
|
}
|
|
776
|
-
const
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
1186
|
+
const { route, parent } = await this.resolveAttentionRouting(session, to);
|
|
1187
|
+
const hold = route === 'parent' ? this.beginParentPushHold(id) : undefined;
|
|
1188
|
+
let suppressItemId;
|
|
1189
|
+
try {
|
|
1190
|
+
const event = await this.attention.record(id, 'Notification', { notification_type: 'agent_needs_input', title, message }, prompt ? { prompt } : undefined);
|
|
1191
|
+
if (!event)
|
|
1192
|
+
throw new AttentionError('attention_storage_error', 'Attention history could not be persisted', 500);
|
|
1193
|
+
// record() only reports the item. A newly created row starts at one occurrence;
|
|
1194
|
+
// a repeat increments it and must keep the route it already has.
|
|
1195
|
+
const applied = await this.applyParentRoute(session, event, event.occurrences === 1, route, parent);
|
|
1196
|
+
suppressItemId = applied.suppressPushId;
|
|
1197
|
+
// Durable commit precedes publication; the live stream carries only
|
|
1198
|
+
// the sanitized request and its exact committed record identifier.
|
|
1199
|
+
// A new parent-routed item skips the dashboard notice. A repeat keeps
|
|
1200
|
+
// its stored route, so a human-routed repeat still publishes.
|
|
1201
|
+
if (!applied.skipNotice) {
|
|
1202
|
+
this.emit('event', { sessionId: id, eventName: 'Notification', source: 'api', data: {
|
|
1203
|
+
notification_type: 'agent_needs_input', title: applied.item.title, message: applied.item.message, notification_id: applied.item.id,
|
|
1204
|
+
attentionId: applied.item.id, attentionSequence: applied.item.sequence, attentionCreatedAt: applied.item.createdAt,
|
|
1205
|
+
...(applied.item.prompt ? { attentionPrompt: applied.item.prompt } : {}),
|
|
1206
|
+
} });
|
|
1207
|
+
}
|
|
1208
|
+
if (session.parentSessionId) {
|
|
1209
|
+
await this.getCoordination()?.emit(session, 'attention', applied.item.title, applied.item.id);
|
|
1210
|
+
}
|
|
1211
|
+
jsonResponse(res, 200, { event: applied.item });
|
|
1212
|
+
}
|
|
1213
|
+
finally {
|
|
1214
|
+
if (hold !== undefined)
|
|
1215
|
+
this.finishParentPushHold(id, hold, suppressItemId);
|
|
788
1216
|
}
|
|
789
|
-
jsonResponse(res, 200, { event });
|
|
790
1217
|
return;
|
|
791
1218
|
}
|
|
792
1219
|
if (!attentionSub && req.method === 'GET') {
|
|
@@ -1207,9 +1634,23 @@ export class LocalApiServer extends EventEmitter {
|
|
|
1207
1634
|
jsonResponse(res, 404, { error: 'Session not found' });
|
|
1208
1635
|
return;
|
|
1209
1636
|
}
|
|
1210
|
-
|
|
1637
|
+
const worktreeValues = url.searchParams.getAll('worktree');
|
|
1638
|
+
if (worktreeValues.length > 1)
|
|
1639
|
+
throw new BadRequestError('Invalid worktree mode');
|
|
1640
|
+
let worktreeMode = 'auto';
|
|
1641
|
+
if (worktreeValues.length === 1) {
|
|
1642
|
+
const mode = worktreeValues[0];
|
|
1643
|
+
if (mode !== 'keep' && mode !== 'force')
|
|
1644
|
+
throw new BadRequestError('Invalid worktree mode');
|
|
1645
|
+
worktreeMode = mode;
|
|
1646
|
+
}
|
|
1647
|
+
const removed = await controller.remove(sessionId, undefined, worktreeMode);
|
|
1211
1648
|
this.mail.markSessionEnded(sessionId);
|
|
1212
|
-
jsonResponse(res, 200, {
|
|
1649
|
+
jsonResponse(res, 200, {
|
|
1650
|
+
removed: true,
|
|
1651
|
+
sessionId,
|
|
1652
|
+
...(removed.worktreeOutcome ? { worktreeOutcome: removed.worktreeOutcome } : {}),
|
|
1653
|
+
});
|
|
1213
1654
|
return;
|
|
1214
1655
|
}
|
|
1215
1656
|
// POST /api/sessions/:id/revive — recreate a removed session from its tombstone
|
|
@@ -1717,7 +2158,7 @@ export class LocalApiServer extends EventEmitter {
|
|
|
1717
2158
|
committedAttention = await this.attention.record(ftownSessionId, hookEventName, rest, isPromptSubmit ? { promptOrigin: injected ? 'injected' : 'human' } : undefined);
|
|
1718
2159
|
}
|
|
1719
2160
|
if (hookEventName === 'UserPromptSubmit' || hookEventName === 'PreToolUse' || hookEventName === 'PostToolUse')
|
|
1720
|
-
this.mail.markAgentBusy(ftownSessionId);
|
|
2161
|
+
this.mail.markAgentBusy(ftownSessionId, hookEventName);
|
|
1721
2162
|
else if (hookEventName === 'Stop')
|
|
1722
2163
|
this.mail.markAgentIdle(ftownSessionId);
|
|
1723
2164
|
else if (hookEventName === 'SessionEnd') {
|