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.
Files changed (83) hide show
  1. package/dist/attention-cli.d.ts +32 -0
  2. package/dist/attention-cli.js +310 -0
  3. package/dist/attention-cli.js.map +1 -0
  4. package/dist/attention-events.d.ts +2 -1
  5. package/dist/attention-events.js.map +1 -1
  6. package/dist/attention-store.d.ts +6 -3
  7. package/dist/attention-store.js +60 -2
  8. package/dist/attention-store.js.map +1 -1
  9. package/dist/brief-cli.d.ts +10 -0
  10. package/dist/brief-cli.js +163 -0
  11. package/dist/brief-cli.js.map +1 -0
  12. package/dist/command-rpc.d.ts +11 -0
  13. package/dist/command-rpc.js +83 -16
  14. package/dist/command-rpc.js.map +1 -1
  15. package/dist/coordination/attention-routing.d.ts +37 -0
  16. package/dist/coordination/attention-routing.js +154 -0
  17. package/dist/coordination/attention-routing.js.map +1 -0
  18. package/dist/coordination/board.js +82 -3
  19. package/dist/coordination/board.js.map +1 -1
  20. package/dist/coordination/contract-b.d.ts +206 -0
  21. package/dist/coordination/contract-b.js +58 -0
  22. package/dist/coordination/contract-b.js.map +1 -0
  23. package/dist/coordination/contract-c.d.ts +117 -0
  24. package/dist/coordination/contract-c.js +47 -0
  25. package/dist/coordination/contract-c.js.map +1 -0
  26. package/dist/coordination/contract.d.ts +8 -1
  27. package/dist/coordination/contract.js +1 -1
  28. package/dist/coordination/contract.js.map +1 -1
  29. package/dist/coordination/format.js +51 -12
  30. package/dist/coordination/format.js.map +1 -1
  31. package/dist/coordination/plan.d.ts +74 -0
  32. package/dist/coordination/plan.js +534 -0
  33. package/dist/coordination/plan.js.map +1 -0
  34. package/dist/coordination/routes-brief.d.ts +10 -0
  35. package/dist/coordination/routes-brief.js +149 -0
  36. package/dist/coordination/routes-brief.js.map +1 -0
  37. package/dist/coordination/routes-plan.d.ts +13 -0
  38. package/dist/coordination/routes-plan.js +182 -0
  39. package/dist/coordination/routes-plan.js.map +1 -0
  40. package/dist/coordination/routes-worktrees.d.ts +29 -0
  41. package/dist/coordination/routes-worktrees.js +138 -0
  42. package/dist/coordination/routes-worktrees.js.map +1 -0
  43. package/dist/coordination/wiring.d.ts +17 -2
  44. package/dist/coordination/wiring.js +81 -11
  45. package/dist/coordination/wiring.js.map +1 -1
  46. package/dist/coordination-cli.d.ts +6 -1
  47. package/dist/coordination-cli.js +23 -7
  48. package/dist/coordination-cli.js.map +1 -1
  49. package/dist/create-ftown-session.d.ts +26 -1
  50. package/dist/create-ftown-session.js +208 -104
  51. package/dist/create-ftown-session.js.map +1 -1
  52. package/dist/ftown-cli.js +14 -2
  53. package/dist/ftown-cli.js.map +1 -1
  54. package/dist/index.js +29 -0
  55. package/dist/index.js.map +1 -1
  56. package/dist/local-api-server.d.ts +24 -0
  57. package/dist/local-api-server.js +473 -32
  58. package/dist/local-api-server.js.map +1 -1
  59. package/dist/mail-delivery.d.ts +18 -4
  60. package/dist/mail-delivery.js +50 -8
  61. package/dist/mail-delivery.js.map +1 -1
  62. package/dist/plan-cli.d.ts +52 -0
  63. package/dist/plan-cli.js +357 -0
  64. package/dist/plan-cli.js.map +1 -0
  65. package/dist/security/bridge/permissions.js +55 -5
  66. package/dist/security/bridge/permissions.js.map +1 -1
  67. package/dist/session-controller.d.ts +17 -1
  68. package/dist/session-controller.js +93 -2
  69. package/dist/session-controller.js.map +1 -1
  70. package/dist/sessions-commands.js +75 -5
  71. package/dist/sessions-commands.js.map +1 -1
  72. package/dist/types.d.ts +9 -1
  73. package/dist/worktrees-cli.d.ts +39 -0
  74. package/dist/worktrees-cli.js +168 -0
  75. package/dist/worktrees-cli.js.map +1 -0
  76. package/dist/worktrees.d.ts +16 -0
  77. package/dist/worktrees.js +292 -0
  78. package/dist/worktrees.js.map +1 -0
  79. package/hooks/ftown-antigravity-hook.sh +12 -6
  80. package/package.json +2 -2
  81. package/skills/ftown/SKILL.md +88 -5
  82. package/skills/ftown/references/orchestrator.md +32 -29
  83. package/skills/ftown/references/sessions.md +155 -12
@@ -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
- const input = parseCreateSessionBody(body, useCallerAsParent ? callerSessionId : undefined);
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 { item, created } = await this.attention.recordDetailed(id, 'PreToolUse', toolData);
710
- // When ftown's ordinary (async) PreToolUse hook is missing or late, this is the only signal the UI gets.
711
- // Same stamped payload as /hook; deduped asks publish nothing, and the agent is not marked busy.
712
- if (item && created) {
713
- this.emit('event', { sessionId: id, eventName: 'PreToolUse', source: 'api', data: {
714
- hook_event_name: 'PreToolUse', ...toolData,
715
- attentionId: item.id, attentionSequence: item.sequence, attentionCreatedAt: item.createdAt,
716
- } });
717
- if (session.parentSessionId) {
718
- await this.getCoordination()?.emit(session, 'attention', item.title, item.id);
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 event = await this.attention.record(id, 'Notification', { notification_type: 'agent_needs_input', title, message }, prompt ? { prompt } : undefined);
777
- if (!event)
778
- throw new AttentionError('attention_storage_error', 'Attention history could not be persisted', 500);
779
- // Durable commit precedes publication; the live stream carries only
780
- // the sanitized request and its exact committed record identifier.
781
- this.emit('event', { sessionId: id, eventName: 'Notification', source: 'api', data: {
782
- notification_type: 'agent_needs_input', title: event.title, message: event.message, notification_id: event.id,
783
- attentionId: event.id, attentionSequence: event.sequence, attentionCreatedAt: event.createdAt,
784
- ...(event.prompt ? { attentionPrompt: event.prompt } : {}),
785
- } });
786
- if (session.parentSessionId) {
787
- await this.getCoordination()?.emit(session, 'attention', event.title, event.id);
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
- await controller.remove(sessionId);
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, { removed: true, sessionId });
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') {