ftown 0.25.2 → 0.26.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 (108) hide show
  1. package/dist/command-rpc.d.ts +3 -0
  2. package/dist/command-rpc.js +41 -0
  3. package/dist/command-rpc.js.map +1 -1
  4. package/dist/coordination/board.d.ts +12 -0
  5. package/dist/coordination/board.js +240 -0
  6. package/dist/coordination/board.js.map +1 -0
  7. package/dist/coordination/card-store.d.ts +9 -0
  8. package/dist/coordination/card-store.js +188 -0
  9. package/dist/coordination/card-store.js.map +1 -0
  10. package/dist/coordination/contract.d.ts +236 -0
  11. package/dist/coordination/contract.js +63 -0
  12. package/dist/coordination/contract.js.map +1 -0
  13. package/dist/coordination/format.d.ts +5 -0
  14. package/dist/coordination/format.js +60 -0
  15. package/dist/coordination/format.js.map +1 -0
  16. package/dist/coordination/tree-events.d.ts +31 -0
  17. package/dist/coordination/tree-events.js +226 -0
  18. package/dist/coordination/tree-events.js.map +1 -0
  19. package/dist/coordination/wiring.d.ts +48 -0
  20. package/dist/coordination/wiring.js +143 -0
  21. package/dist/coordination/wiring.js.map +1 -0
  22. package/dist/coordination-cli.d.ts +34 -0
  23. package/dist/coordination-cli.js +460 -0
  24. package/dist/coordination-cli.js.map +1 -0
  25. package/dist/ftown-cli.js +12 -2
  26. package/dist/ftown-cli.js.map +1 -1
  27. package/dist/index.js +33 -7
  28. package/dist/index.js.map +1 -1
  29. package/dist/local-api-server.d.ts +6 -1
  30. package/dist/local-api-server.js +282 -5
  31. package/dist/local-api-server.js.map +1 -1
  32. package/dist/mail-delivery.d.ts +16 -2
  33. package/dist/mail-delivery.js +331 -77
  34. package/dist/mail-delivery.js.map +1 -1
  35. package/dist/mail-store.d.ts +7 -0
  36. package/dist/mail-store.js +71 -1
  37. package/dist/mail-store.js.map +1 -1
  38. package/dist/pull-request-events.d.ts +5 -0
  39. package/dist/pull-request-events.js +21 -1
  40. package/dist/pull-request-events.js.map +1 -1
  41. package/dist/quota/agy.d.ts +16 -0
  42. package/dist/quota/agy.js +287 -0
  43. package/dist/quota/agy.js.map +1 -0
  44. package/dist/quota/annotate.d.ts +5 -0
  45. package/dist/quota/annotate.js +56 -0
  46. package/dist/quota/annotate.js.map +1 -0
  47. package/dist/quota/claude.d.ts +27 -0
  48. package/dist/quota/claude.js +265 -0
  49. package/dist/quota/claude.js.map +1 -0
  50. package/dist/quota/codex.d.ts +25 -0
  51. package/dist/quota/codex.js +378 -0
  52. package/dist/quota/codex.js.map +1 -0
  53. package/dist/quota/format.d.ts +6 -0
  54. package/dist/quota/format.js +113 -0
  55. package/dist/quota/format.js.map +1 -0
  56. package/dist/quota/grok.d.ts +97 -0
  57. package/dist/quota/grok.js +563 -0
  58. package/dist/quota/grok.js.map +1 -0
  59. package/dist/quota/models-agy.d.ts +10 -0
  60. package/dist/quota/models-agy.js +144 -0
  61. package/dist/quota/models-agy.js.map +1 -0
  62. package/dist/quota/models-claude.d.ts +10 -0
  63. package/dist/quota/models-claude.js +227 -0
  64. package/dist/quota/models-claude.js.map +1 -0
  65. package/dist/quota/models-codex.d.ts +18 -0
  66. package/dist/quota/models-codex.js +305 -0
  67. package/dist/quota/models-codex.js.map +1 -0
  68. package/dist/quota/models-grok.d.ts +19 -0
  69. package/dist/quota/models-grok.js +272 -0
  70. package/dist/quota/models-grok.js.map +1 -0
  71. package/dist/quota/models-muse.d.ts +13 -0
  72. package/dist/quota/models-muse.js +200 -0
  73. package/dist/quota/models-muse.js.map +1 -0
  74. package/dist/quota/models-opencode.d.ts +51 -0
  75. package/dist/quota/models-opencode.js +400 -0
  76. package/dist/quota/models-opencode.js.map +1 -0
  77. package/dist/quota/muse.d.ts +38 -0
  78. package/dist/quota/muse.js +225 -0
  79. package/dist/quota/muse.js.map +1 -0
  80. package/dist/quota/opencode.d.ts +7 -0
  81. package/dist/quota/opencode.js +47 -0
  82. package/dist/quota/opencode.js.map +1 -0
  83. package/dist/quota/routing-config.d.ts +18 -0
  84. package/dist/quota/routing-config.js +438 -0
  85. package/dist/quota/routing-config.js.map +1 -0
  86. package/dist/quota/routing-service.d.ts +24 -0
  87. package/dist/quota/routing-service.js +118 -0
  88. package/dist/quota/routing-service.js.map +1 -0
  89. package/dist/quota/routing.d.ts +10 -0
  90. package/dist/quota/routing.js +180 -0
  91. package/dist/quota/routing.js.map +1 -0
  92. package/dist/quota/types.d.ts +304 -0
  93. package/dist/quota/types.js +45 -0
  94. package/dist/quota/types.js.map +1 -0
  95. package/dist/quota-cli.d.ts +80 -0
  96. package/dist/quota-cli.js +462 -0
  97. package/dist/quota-cli.js.map +1 -0
  98. package/dist/security/bridge/permissions.js +24 -2
  99. package/dist/security/bridge/permissions.js.map +1 -1
  100. package/dist/types.d.ts +7 -3
  101. package/dist/wire-types.d.ts +17 -1
  102. package/dist/wire-types.js +1 -1
  103. package/dist/wire-types.js.map +1 -1
  104. package/package.json +3 -2
  105. package/skills/ftown/SKILL.md +31 -5
  106. package/skills/ftown/references/orchestrator.md +17 -7
  107. package/skills/ftown/references/quota.md +177 -0
  108. package/skills/ftown/references/sessions.md +45 -3
@@ -1,6 +1,8 @@
1
- import { createMailMessage } from './mail-store.js';
1
+ import { createMailMessage, createNudgeToken } from './mail-store.js';
2
2
  import { HOOKED_SHELL_TYPES } from './harness-registry.js';
3
3
  import { createNativeMailWaker } from './native-mail.js';
4
+ import { DIGEST_MAX, MAIL_TYPES_V2, MAX_WAKE_ATTEMPTS, NON_WAKING_MAIL_TYPES, } from './coordination/contract.js';
5
+ export { MAIL_TYPES_V2, MAX_WAKE_ATTEMPTS, NON_WAKING_MAIL_TYPES };
4
6
  export const MAX_MAIL_BODY_LENGTH = 64 * 1024;
5
7
  export const MAX_MAIL_WAIT_SECONDS = 30;
6
8
  export const MAIL_NUDGE_DELAY_MS = 5_000;
@@ -40,7 +42,32 @@ export function parseNativeWakeRegistration(query) {
40
42
  }
41
43
  return { adapter, nativeSessionId };
42
44
  }
43
- export const MAIL_TYPES = ['message', 'task', 'result', 'escalation'];
45
+ export const MAIL_TYPES = MAIL_TYPES_V2;
46
+ const NUDGE_COUNT_ORDER = ['task', 'escalation', 'result', 'message'];
47
+ export function countMailTypes(messages) {
48
+ const counts = {};
49
+ for (const msg of messages) {
50
+ if (NON_WAKING_MAIL_TYPES.has(msg.type))
51
+ continue;
52
+ counts[msg.type] = (counts[msg.type] ?? 0) + 1;
53
+ }
54
+ return counts;
55
+ }
56
+ export function formatNudgeText(counts, digest) {
57
+ const parts = [];
58
+ let total = 0;
59
+ for (const type of NUDGE_COUNT_ORDER) {
60
+ const count = counts[type] ?? 0;
61
+ if (count > 0) {
62
+ total += count;
63
+ parts.push(`${count} ${type}`);
64
+ }
65
+ }
66
+ const base = `[ftown] ${total} new mail (${parts.join(', ')}) — run: ftown harness mail read`;
67
+ const trimmedDigest = typeof digest === 'string' ? sanitizeMessageText(digest).slice(0, DIGEST_MAX) : '';
68
+ return trimmedDigest ? `${base} | ${trimmedDigest}` : base;
69
+ }
70
+ export const NUDGE_TEXT = formatNudgeText;
44
71
  // Composer TUIs detect pastes by input arrival rate; the submit CR must come well
45
72
  // after that window or it is treated as a pasted newline.
46
73
  const COMPOSER_PASTE_SETTLE_MS = 600;
@@ -85,14 +112,17 @@ export class MailDeliveryService {
85
112
  lastNudgeAt = new Map();
86
113
  agentBusySince = new Map();
87
114
  nativeLeases = new Map();
88
- nativeBatches = new Map();
89
115
  selectionLocks = new Map();
90
116
  codexClaims = new Map();
91
117
  codexRetryAt = new Map();
118
+ batchAttempts = new Map();
119
+ museNativeTried = new Map();
92
120
  wakeLocks = new Map();
93
121
  wakeControllers = new Map();
94
122
  legacyReadyAt = new Map();
95
123
  lastInjection = new Map();
124
+ digestProvider = null;
125
+ eventSink = null;
96
126
  stopped = false;
97
127
  dependencyEpoch = 0;
98
128
  nativeWake;
@@ -120,11 +150,19 @@ export class MailDeliveryService {
120
150
  this.invalidateRecovery();
121
151
  this.runner = runner;
122
152
  }
153
+ setDigestProvider(p) {
154
+ this.digestProvider = p;
155
+ }
156
+ setEventSink(s) {
157
+ this.eventSink = s;
158
+ }
123
159
  invalidateRecovery() {
124
160
  this.dependencyEpoch++;
125
161
  for (const timer of this.nudgeTimers.values())
126
162
  this.clearTimeoutFn(timer);
127
163
  this.nudgeTimers.clear();
164
+ this.batchAttempts.clear();
165
+ this.museNativeTried.clear();
128
166
  for (const controller of this.wakeControllers.values())
129
167
  controller.abort();
130
168
  }
@@ -140,7 +178,7 @@ export class MailDeliveryService {
140
178
  if (!current() || !session || session.status !== 'running' || session.shellType === 'pi'
141
179
  || !runner.isRunning(sessionId))
142
180
  return;
143
- const pending = await mailStore.listUndelivered(sessionId);
181
+ const pending = await mailStore.listUnnudgedWaking(sessionId);
144
182
  if (!current() || !pending.length || !runner.isRunning(sessionId))
145
183
  return;
146
184
  // Reads may have raced deletion/stop or an inbox drain. The wake path checks
@@ -167,9 +205,10 @@ export class MailDeliveryService {
167
205
  waiter.deliver([]);
168
206
  this.mailWaiters.clear();
169
207
  this.nativeLeases.clear();
170
- this.nativeBatches.clear();
171
208
  this.legacyReadyAt.clear();
172
209
  this.codexRetryAt.clear();
210
+ this.batchAttempts.clear();
211
+ this.museNativeTried.clear();
173
212
  this.lastInjection.clear();
174
213
  for (const controller of this.wakeControllers.values())
175
214
  controller.abort();
@@ -218,7 +257,7 @@ export class MailDeliveryService {
218
257
  }
219
258
  available(sessionId, messages) {
220
259
  const claimed = this.codexClaims.get(sessionId);
221
- return claimed ? messages.filter(message => message.id !== claimed) : messages;
260
+ return claimed?.size ? messages.filter(message => !claimed.has(message.id)) : messages;
222
261
  }
223
262
  /** Hook activity (UserPromptSubmit/PreToolUse/PostToolUse): the agent is mid-turn. */
224
263
  markAgentBusy(sessionId) {
@@ -240,8 +279,51 @@ export class MailDeliveryService {
240
279
  this.lastInjection.set(sessionId, { text, at: this.now() });
241
280
  }
242
281
  /** Let the API server record an opencode/claude-mod plugin handoff (mail handed to the plugin). */
243
- notePluginHandoff(sessionId) {
282
+ async notePluginHandoff(sessionId) {
244
283
  this.recordInjection(sessionId, OPENCODE_MAIL_WAKE_TEXT);
284
+ const existingTimer = this.nudgeTimers.get(sessionId);
285
+ if (existingTimer) {
286
+ this.clearTimeoutFn(existingTimer);
287
+ this.nudgeTimers.delete(sessionId);
288
+ }
289
+ this.pendingNudgeFrom.delete(sessionId);
290
+ const mailStore = this.mailStore;
291
+ if (!mailStore)
292
+ return;
293
+ const token = createNudgeToken(this.now());
294
+ try {
295
+ const snapshot = await mailStore.listUnnudgedWaking(sessionId);
296
+ const undeliveredWaking = this.wakeLocks.has(sessionId)
297
+ ? (await mailStore.listUndelivered(sessionId)).filter((m) => !NON_WAKING_MAIL_TYPES.has(m.type))
298
+ : snapshot;
299
+ const batchIds = Array.from(new Set([...snapshot.map((m) => m.id), ...undeliveredWaking.map((m) => m.id)]));
300
+ if (batchIds.length === 0)
301
+ return;
302
+ await this.withSelection(sessionId, async () => {
303
+ await mailStore.markNudged(sessionId, batchIds, token);
304
+ });
305
+ const inFlightWake = this.wakeLocks.get(sessionId);
306
+ if (inFlightWake) {
307
+ void inFlightWake
308
+ .then(async () => {
309
+ const retryTimer = this.nudgeTimers.get(sessionId);
310
+ if (retryTimer) {
311
+ this.clearTimeoutFn(retryTimer);
312
+ this.nudgeTimers.delete(sessionId);
313
+ }
314
+ this.pendingNudgeFrom.delete(sessionId);
315
+ await this.withSelection(sessionId, async () => {
316
+ await mailStore.markNudged(sessionId, batchIds, token);
317
+ });
318
+ })
319
+ .catch((err) => {
320
+ console.error('[MailDelivery] Plugin handoff stamp failed:', err instanceof Error ? err.message : String(err));
321
+ });
322
+ }
323
+ }
324
+ catch (err) {
325
+ console.error('[MailDelivery] Plugin handoff stamp failed:', err instanceof Error ? err.message : String(err));
326
+ }
245
327
  }
246
328
  /**
247
329
  * Whether a UserPromptSubmit hook payload was submitted by ftown itself (bridge or installed plugin).
@@ -288,6 +370,7 @@ export class MailDeliveryService {
288
370
  this.recordInjection(session.id, line);
289
371
  if (!this.runner.write(session.id, line))
290
372
  return false;
373
+ options?.onFirstWrite?.();
291
374
  // Composer TUIs detect pastes by input arrival rate; the submit CR must come well
292
375
  // after that window or it is treated as a pasted newline.
293
376
  await this.delay(COMPOSER_PASTE_SETTLE_MS);
@@ -333,6 +416,17 @@ export class MailDeliveryService {
333
416
  const threadId = typeof body.threadId === 'string' && body.threadId.trim() ? body.threadId.trim() : undefined;
334
417
  const msg = createMailMessage({ from, fromName, to: session.id, type, threadId, body: text });
335
418
  await this.mailStore.append(msg);
419
+ if (this.eventSink) {
420
+ const sink = this.eventSink;
421
+ try {
422
+ void Promise.resolve(sink.onAccepted(msg, session)).catch((err) => {
423
+ console.error('[MailDelivery] Event sink failed:', err instanceof Error ? err.message : String(err));
424
+ });
425
+ }
426
+ catch (err) {
427
+ console.error('[MailDelivery] Event sink failed:', err instanceof Error ? err.message : String(err));
428
+ }
429
+ }
336
430
  await this.notifyCommittedMail(session, fromName ?? from);
337
431
  return { ok: true, id: msg.id };
338
432
  }
@@ -358,7 +452,7 @@ export class MailDeliveryService {
358
452
  waiter.deliver(marked);
359
453
  }
360
454
  }
361
- pending = (await mailStore.listUndelivered(session.id)).length > 0;
455
+ pending = (await mailStore.listUnnudgedWaking(session.id)).length > 0;
362
456
  });
363
457
  if (pending && (!hadWaiter || session.shellType === 'codex') && session.shellType !== 'pi') {
364
458
  const legacyDelay = HOOKED_SHELL_TYPES.has(session.shellType ?? 'claude')
@@ -505,24 +599,57 @@ export class MailDeliveryService {
505
599
  return;
506
600
  }
507
601
  if (!runner || !runner.isRunning(sessionId) || session.status !== 'running') {
508
- this.codexRetryAt.set(sessionId, this.now() + MAIL_NUDGE_BUSY_RECHECK_MS);
509
- this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
602
+ const pending = await this.withSelection(sessionId, async () => current() ? this.available(sessionId, await mailStore.listUnnudgedWaking(sessionId)) : []);
603
+ if (!current() || !pending.length) {
604
+ this.pendingNudgeFrom.delete(sessionId);
605
+ this.codexRetryAt.delete(sessionId);
606
+ this.batchAttempts.delete(sessionId);
607
+ return;
608
+ }
609
+ const batchIds = pending.map((m) => m.id);
610
+ const batchKey = batchIds.slice().sort().join(',');
611
+ const prev = this.batchAttempts.get(sessionId);
612
+ const attempts = (prev?.key === batchKey ? prev.count : 0) + 1;
613
+ if (attempts >= MAX_WAKE_ATTEMPTS) {
614
+ const token = createNudgeToken(this.now());
615
+ await this.withSelection(sessionId, async () => {
616
+ if (!current())
617
+ return;
618
+ await mailStore.markNudged(sessionId, batchIds, token);
619
+ });
620
+ this.batchAttempts.delete(sessionId);
621
+ this.codexRetryAt.delete(sessionId);
622
+ this.pendingNudgeFrom.delete(sessionId);
623
+ }
624
+ else {
625
+ this.batchAttempts.set(sessionId, { key: batchKey, count: attempts });
626
+ this.codexRetryAt.set(sessionId, this.now() + MAIL_NUDGE_BUSY_RECHECK_MS);
627
+ this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
628
+ }
510
629
  return;
511
630
  }
512
- const message = await this.withSelection(sessionId, async () => {
631
+ let claimSet;
632
+ const batch = await this.withSelection(sessionId, async () => {
513
633
  if (!current())
514
- return undefined;
515
- const pending = this.available(sessionId, await mailStore.listUndelivered(sessionId));
634
+ return [];
635
+ const pending = this.available(sessionId, await mailStore.listUnnudgedWaking(sessionId));
516
636
  if (!current() || !pending.length)
517
- return undefined;
518
- this.codexClaims.set(sessionId, pending[0].id);
519
- return pending[0];
637
+ return [];
638
+ claimSet = new Set(pending.map(m => m.id));
639
+ this.codexClaims.set(sessionId, claimSet);
640
+ return pending;
520
641
  });
521
- if (!message)
642
+ if (!batch.length || !claimSet) {
643
+ this.pendingNudgeFrom.delete(sessionId);
644
+ this.codexRetryAt.delete(sessionId);
645
+ this.batchAttempts.delete(sessionId);
522
646
  return;
647
+ }
648
+ const batchIds = batch.map(m => m.id);
649
+ const batchKey = batchIds.slice().sort().join(',');
523
650
  if (!current()) {
524
651
  await this.withSelection(sessionId, async () => {
525
- if (this.codexClaims.get(sessionId) === message.id)
652
+ if (this.codexClaims.get(sessionId) === claimSet)
526
653
  this.codexClaims.delete(sessionId);
527
654
  });
528
655
  return;
@@ -536,22 +663,25 @@ export class MailDeliveryService {
536
663
  if (!current() || controller.signal.aborted)
537
664
  return false;
538
665
  const live = await store.loadSession(sessionId);
539
- const pending = await mailStore.listUndelivered(sessionId);
666
+ const pending = await mailStore.listUnnudgedWaking(sessionId);
667
+ const pendingIds = new Set(pending.map(m => m.id));
540
668
  return current() && !controller.signal.aborted && !!live && live.status === 'running'
541
669
  && live.shellType === 'codex' && nativeIdentity(live) === identity && runner.isRunning(sessionId)
542
- && this.codexClaims.get(sessionId) === message.id && pending.some(m => m.id === message.id);
670
+ && this.codexClaims.get(sessionId) === claimSet && batchIds.some(id => pendingIds.has(id));
543
671
  };
544
- const wakeText = codexMailText(message);
672
+ const wakeText = batch.map(codexMailText).join('\n\n');
545
673
  // Recorded BEFORE the wake call: the prompt hook can fire before it resolves.
546
674
  this.recordInjection(sessionId, wakeText);
547
675
  const result = await this.nativeWake(session, wakeText, controller.signal, isCurrent)
548
676
  .catch(() => ({ status: 'unknown' }));
549
677
  if (result.status === 'accepted' && current() && !controller.signal.aborted) {
678
+ const token = createNudgeToken(this.now());
550
679
  accepted = await this.withSelection(sessionId, async () => {
551
- if (!current() || controller.signal.aborted || this.codexClaims.get(sessionId) !== message.id)
680
+ if (!current() || controller.signal.aborted || this.codexClaims.get(sessionId) !== claimSet)
552
681
  return false;
553
- const marked = await mailStore.markDelivered(sessionId, [message.id], 'cli');
554
- return marked.some(m => m.id === message.id);
682
+ const marked = await mailStore.markDelivered(sessionId, batchIds, 'cli');
683
+ await mailStore.markNudged(sessionId, batchIds, token);
684
+ return marked.length > 0;
555
685
  });
556
686
  }
557
687
  }
@@ -562,24 +692,44 @@ export class MailDeliveryService {
562
692
  if (this.wakeControllers.get(sessionId) === controller)
563
693
  this.wakeControllers.delete(sessionId);
564
694
  await this.withSelection(sessionId, async () => {
565
- if (this.codexClaims.get(sessionId) === message.id)
695
+ if (this.codexClaims.get(sessionId) === claimSet)
566
696
  this.codexClaims.delete(sessionId);
567
697
  });
568
698
  }
569
699
  if (!current())
570
700
  return;
571
- if (!accepted)
572
- this.codexRetryAt.set(sessionId, this.now() + MAIL_NUDGE_BUSY_RECHECK_MS);
573
- else
701
+ if (accepted) {
574
702
  this.codexRetryAt.delete(sessionId);
575
- const pending = await mailStore.listUndelivered(sessionId);
703
+ this.batchAttempts.delete(sessionId);
704
+ }
705
+ else {
706
+ const prev = this.batchAttempts.get(sessionId);
707
+ const attempts = (prev?.key === batchKey ? prev.count : 0) + 1;
708
+ if (attempts >= MAX_WAKE_ATTEMPTS) {
709
+ const token = createNudgeToken(this.now());
710
+ await this.withSelection(sessionId, async () => {
711
+ if (!current())
712
+ return;
713
+ await mailStore.markNudged(sessionId, batchIds, token);
714
+ });
715
+ this.batchAttempts.delete(sessionId);
716
+ this.codexRetryAt.delete(sessionId);
717
+ }
718
+ else {
719
+ this.batchAttempts.set(sessionId, { key: batchKey, count: attempts });
720
+ this.codexRetryAt.set(sessionId, this.now() + MAIL_NUDGE_BUSY_RECHECK_MS);
721
+ }
722
+ }
723
+ const remaining = await mailStore.listUnnudgedWaking(sessionId);
576
724
  if (!current())
577
725
  return;
578
- if (pending.length)
726
+ if (remaining.length) {
579
727
  this.scheduleMailNudge(sessionId, fromLabel, accepted ? MAIL_NUDGE_DELAY_MS : MAIL_NUDGE_BUSY_RECHECK_MS);
728
+ }
580
729
  else {
581
730
  this.pendingNudgeFrom.delete(sessionId);
582
731
  this.codexRetryAt.delete(sessionId);
732
+ this.batchAttempts.delete(sessionId);
583
733
  }
584
734
  }
585
735
  async wakeSession(sessionId) {
@@ -588,13 +738,13 @@ export class MailDeliveryService {
588
738
  if (!current() || !store || !mailStore)
589
739
  return;
590
740
  const fromLabel = this.pendingNudgeFrom.get(sessionId) ?? 'ftown';
591
- const undelivered = await mailStore.listUndelivered(sessionId);
741
+ const undelivered = await mailStore.listUnnudgedWaking(sessionId);
592
742
  if (!current())
593
743
  return;
594
744
  if (undelivered.length === 0) {
595
745
  this.pendingNudgeFrom.delete(sessionId);
596
- this.nativeBatches.delete(sessionId);
597
746
  this.legacyReadyAt.delete(sessionId);
747
+ this.batchAttempts.delete(sessionId);
598
748
  return;
599
749
  }
600
750
  let session = await store.loadSession(sessionId);
@@ -617,55 +767,93 @@ export class MailDeliveryService {
617
767
  this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
618
768
  return;
619
769
  }
770
+ let museStamped;
620
771
  if (session.shellType === 'muse') {
621
- let identity = nativeIdentity(session);
622
- const prior = this.nativeBatches.get(sessionId);
623
- if (prior?.identity === identity && undelivered.every((message) => prior.ids.has(message.id)))
624
- return;
625
772
  // A hook may have drained mail during the session lookup. Never wake for a stale snapshot.
626
773
  session = await store.loadSession(sessionId);
627
- const pending = await mailStore.listUndelivered(sessionId);
774
+ const pending = await mailStore.listUnnudgedWaking(sessionId);
628
775
  if (!current() || !pending.length || !session || session.status !== 'running' || !runner.isRunning(sessionId))
629
776
  return;
630
777
  if (session.shellType !== 'muse') {
631
778
  this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_DELAY_MS);
632
779
  return;
633
780
  }
634
- identity = nativeIdentity(session);
635
- const controller = new AbortController();
636
- this.wakeControllers.set(sessionId, controller);
637
- const isCurrent = async () => {
638
- if (!current() || controller.signal.aborted)
639
- return false;
640
- const live = await store.loadSession(sessionId);
641
- const pending = await mailStore.listUndelivered(sessionId);
642
- return current() && !controller.signal.aborted && !!live && live.status === 'running'
643
- && nativeIdentity(live) === identity && !this.isAgentRecentlyBusy(sessionId)
644
- && runner.isRunning(sessionId) && pending.some(message => undelivered.some(original => original.id === message.id));
645
- };
646
- let result;
647
- // Recorded BEFORE the wake call: the prompt hook can fire before it resolves.
648
- this.recordInjection(sessionId, MAIL_WAKE_TEXT);
649
- try {
650
- result = await this.nativeWake(session, MAIL_WAKE_TEXT, controller.signal, isCurrent).catch(() => ({ status: 'unknown' }));
651
- }
652
- finally {
653
- if (this.wakeControllers.get(sessionId) === controller)
654
- this.wakeControllers.delete(sessionId);
655
- }
656
- if (!current())
657
- return;
658
- if (result.status === 'accepted' || result.status === 'unknown') {
659
- this.nativeBatches.set(sessionId, { identity, ids: new Set(pending.map((message) => message.id)) });
660
- return;
781
+ const batchIds = pending.map((message) => message.id);
782
+ const batchKey = batchIds.slice().sort().join(',');
783
+ if (this.museNativeTried.get(sessionId) !== batchKey) {
784
+ const museToken = createNudgeToken(this.now());
785
+ const stamped = await mailStore.markNudged(sessionId, batchIds, museToken);
786
+ if (!stamped.length) {
787
+ this.pendingNudgeFrom.delete(sessionId);
788
+ return;
789
+ }
790
+ if (!current()) {
791
+ await mailStore.unmarkNudged(sessionId, batchIds, museToken);
792
+ return;
793
+ }
794
+ this.museNativeTried.set(sessionId, batchKey);
795
+ const prev = this.batchAttempts.get(sessionId);
796
+ const museAttempts = (prev?.key === batchKey ? prev.count : 0) + 1;
797
+ this.batchAttempts.set(sessionId, { key: batchKey, count: museAttempts });
798
+ const identity = nativeIdentity(session);
799
+ const controller = new AbortController();
800
+ this.wakeControllers.set(sessionId, controller);
801
+ const isCurrent = async () => {
802
+ if (!current() || controller.signal.aborted)
803
+ return false;
804
+ const live = await store.loadSession(sessionId);
805
+ const currentPending = await mailStore.listUndelivered(sessionId);
806
+ return current() && !controller.signal.aborted && !!live && live.status === 'running'
807
+ && nativeIdentity(live) === identity && !this.isAgentRecentlyBusy(sessionId)
808
+ && runner.isRunning(sessionId) && currentPending.some(message => batchIds.includes(message.id));
809
+ };
810
+ let result;
811
+ // Recorded BEFORE the wake call: the prompt hook can fire before it resolves.
812
+ this.recordInjection(sessionId, MAIL_WAKE_TEXT);
813
+ try {
814
+ result = await this.nativeWake(session, MAIL_WAKE_TEXT, controller.signal, isCurrent).catch(() => ({ status: 'unknown' }));
815
+ }
816
+ finally {
817
+ if (this.wakeControllers.get(sessionId) === controller)
818
+ this.wakeControllers.delete(sessionId);
819
+ }
820
+ // Never unstamp after an accepted/unknown native wake, regardless of later busy/isCurrent checks.
821
+ if (result.status === 'accepted' || result.status === 'unknown') {
822
+ this.pendingNudgeFrom.delete(sessionId);
823
+ this.batchAttempts.delete(sessionId);
824
+ return;
825
+ }
826
+ if (museAttempts >= MAX_WAKE_ATTEMPTS) {
827
+ this.pendingNudgeFrom.delete(sessionId);
828
+ this.batchAttempts.delete(sessionId);
829
+ return;
830
+ }
831
+ if (!current() || !(await isCurrent())) {
832
+ await mailStore.unmarkNudged(sessionId, batchIds, museToken);
833
+ if (current() && runner.isRunning(sessionId)) {
834
+ const live = await store.loadSession(sessionId);
835
+ if (current() && live && live.status === 'running' && runner.isRunning(sessionId)) {
836
+ this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
837
+ }
838
+ }
839
+ return;
840
+ }
841
+ museStamped = { batch: stamped, batchIds: stamped.map((m) => m.id), token: museToken };
661
842
  }
662
843
  }
844
+ const unstampMuse = async () => {
845
+ if (museStamped?.batchIds.length) {
846
+ await mailStore.unmarkNudged(sessionId, museStamped.batchIds, museStamped.token);
847
+ }
848
+ };
663
849
  const legacyWait = (this.legacyReadyAt.get(sessionId) ?? 0) - this.now();
664
850
  if (legacyWait > 0) {
851
+ await unstampMuse();
665
852
  this.scheduleMailNudge(sessionId, fromLabel, legacyWait);
666
853
  return;
667
854
  }
668
855
  if (this.isAgentRecentlyBusy(sessionId)) {
856
+ await unstampMuse();
669
857
  this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
670
858
  return;
671
859
  }
@@ -677,40 +865,106 @@ export class MailDeliveryService {
677
865
  const sinceLast = this.now() - lastNudgeAt;
678
866
  if (sinceLast < MAIL_NUDGE_MIN_INTERVAL_MS) {
679
867
  // Rate-limited: retry once the window reopens (mail may still be unread).
868
+ await unstampMuse();
680
869
  this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_MIN_INTERVAL_MS - sinceLast);
681
870
  return;
682
871
  }
683
872
  }
684
- // The session isn't running (crashed/restarting): don't clear the pending
685
- // marker. No timer is armed for this session at this point (the caller
686
- // already removed it before invoking us), so clearing it here would lose
687
- // the sender label until unrelated new mail happens to call
688
- // scheduleMailNudge again. Keeping it means the next opportunity to
689
- // re-check this session (new mail via acceptMail, or an external
690
- // restart/idle hook re-invoking this nudge check) still knows who to
691
- // credit.
873
+ let digest = null;
874
+ if (this.digestProvider) {
875
+ try {
876
+ digest = await this.digestProvider.digestFor(sessionId);
877
+ }
878
+ catch {
879
+ digest = null;
880
+ }
881
+ if (!current()) {
882
+ await unstampMuse();
883
+ return;
884
+ }
885
+ }
692
886
  // Recheck after native probing/rejection and before each legacy side effect.
693
- const pending = await mailStore.listUndelivered(sessionId);
887
+ let pending;
888
+ if (museStamped) {
889
+ const stillUndelivered = new Set((await mailStore.listUndelivered(sessionId)).map((m) => m.id));
890
+ pending = museStamped.batch.filter((m) => stillUndelivered.has(m.id));
891
+ }
892
+ else {
893
+ pending = await mailStore.listUnnudgedWaking(sessionId);
894
+ }
694
895
  session = await store.loadSession(sessionId);
695
- if (!current() || !pending.length || !session || session.status !== 'running' || !runner.isRunning(sessionId))
896
+ if (!current() || !pending.length || !session || session.status !== 'running' || !runner.isRunning(sessionId)) {
897
+ await unstampMuse();
696
898
  return;
899
+ }
697
900
  if (session.shellType === 'codex') {
901
+ await unstampMuse();
698
902
  this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_DELAY_MS);
699
903
  return;
700
904
  }
701
905
  const refreshedLease = this.pluginLeaseRemaining(session);
702
906
  if (refreshedLease > 0) {
907
+ await unstampMuse();
703
908
  this.scheduleMailNudge(sessionId, fromLabel, refreshedLease);
704
909
  return;
705
910
  }
706
911
  if (this.isAgentRecentlyBusy(sessionId)) {
912
+ await unstampMuse();
707
913
  this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_BUSY_RECHECK_MS);
708
914
  return;
709
915
  }
916
+ let activeBatch;
917
+ let batchIds;
918
+ let token;
919
+ if (museStamped) {
920
+ activeBatch = pending;
921
+ batchIds = museStamped.batchIds;
922
+ token = museStamped.token;
923
+ }
924
+ else {
925
+ token = createNudgeToken(this.now());
926
+ const requestedIds = pending.map((m) => m.id);
927
+ const stamped = await mailStore.markNudged(sessionId, requestedIds, token);
928
+ if (!stamped.length) {
929
+ await mailStore.unmarkNudged(sessionId, requestedIds, token);
930
+ this.pendingNudgeFrom.delete(sessionId);
931
+ return;
932
+ }
933
+ activeBatch = stamped;
934
+ batchIds = stamped.map((m) => m.id);
935
+ }
936
+ if (!current()) {
937
+ await mailStore.unmarkNudged(sessionId, batchIds, token);
938
+ return;
939
+ }
940
+ const batchKey = batchIds.slice().sort().join(',');
941
+ const prev = this.batchAttempts.get(sessionId);
942
+ const attempts = (prev?.key === batchKey ? prev.count : 0) + 1;
943
+ this.batchAttempts.set(sessionId, { key: batchKey, count: attempts });
944
+ const text = sanitizeMessageText(formatNudgeText(countMailTypes(activeBatch), digest));
945
+ let wroteToPty = false;
946
+ const injected = await this.injectPtyLine(session, 'ftown-mail', text, {
947
+ onFirstWrite: () => {
948
+ wroteToPty = true;
949
+ },
950
+ });
951
+ const deliveredToPty = wroteToPty || injected;
952
+ if (!deliveredToPty) {
953
+ if (attempts >= MAX_WAKE_ATTEMPTS) {
954
+ this.batchAttempts.delete(sessionId);
955
+ this.pendingNudgeFrom.delete(sessionId);
956
+ return;
957
+ }
958
+ await mailStore.unmarkNudged(sessionId, batchIds, token);
959
+ if (current())
960
+ this.scheduleMailNudge(sessionId, fromLabel, MAIL_NUDGE_DELAY_MS);
961
+ return;
962
+ }
963
+ this.batchAttempts.delete(sessionId);
710
964
  this.pendingNudgeFrom.delete(sessionId);
711
- this.lastNudgeAt.set(sessionId, this.now());
712
- const text = sanitizeMessageText(`You have new ftown mail from ${fromLabel} — run \`ftown harness mail read\` to read it.`);
713
- await this.injectPtyLine(session, 'ftown-mail', text);
965
+ if (current()) {
966
+ this.lastNudgeAt.set(sessionId, this.now());
967
+ }
714
968
  }
715
969
  delay(ms) {
716
970
  return new Promise((resolve) => this.setTimeoutFn(resolve, ms));