handmux 0.29.2 → 0.31.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 (116) hide show
  1. package/dist/connectors/claude/index.js +33 -562
  2. package/dist/connectors/codebuddy/index.js +49 -0
  3. package/dist/connectors/hookBridge.js +560 -0
  4. package/dist/hooks/handmux-codebuddy-notify.sh +116 -0
  5. package/dist/hooks/handmux-notify.sh +2 -0
  6. package/dist/hooks/handmux-write.cjs +72 -5
  7. package/dist/package.json +13 -13
  8. package/dist/public/assets/_basePickBy-BzC0P9Wg.js +1 -0
  9. package/dist/public/assets/_baseUniq-DNOfPYVY.js +1 -0
  10. package/dist/public/assets/abnfDiagram-O67JEVCF-BsRDIQEB.js +1 -0
  11. package/dist/public/assets/arc-DLtLfMnX.js +1 -0
  12. package/dist/public/assets/architectureDiagram-NJMV4G6O-DoIXhWqZ.js +36 -0
  13. package/dist/public/assets/blockDiagram-BEXU5L5S-W0HiOcVA.js +140 -0
  14. package/dist/public/assets/c4Diagram-YGBWAQC7-72uAUMvX.js +38 -0
  15. package/dist/public/assets/channel-CzALSnb0.js +1 -0
  16. package/dist/public/assets/chunk-2Q5K7J3B-B9wSRFQ0.js +1 -0
  17. package/dist/public/assets/chunk-3FUC2YCW-ryphp4s0.js +2 -0
  18. package/dist/public/assets/chunk-5VM5RSS4-Cm4KeKby.js +15 -0
  19. package/dist/public/assets/chunk-GTNCS2PH-Du4_i7r3.js +88 -0
  20. package/dist/public/assets/chunk-GWA4HPMP-CL1biVIA.js +1 -0
  21. package/dist/public/assets/chunk-JWPE2WC7-B8eAVfuB.js +1 -0
  22. package/dist/public/assets/chunk-NETBCI7D-D273GzYg.js +1 -0
  23. package/dist/public/assets/chunk-WEXAMYUT-DipTfRbc.js +1 -0
  24. package/dist/public/assets/chunk-XXDRQBXY-D4SqXrQ8.js +1 -0
  25. package/dist/public/assets/classDiagram-v2-NBCMYWYE-PrpnIXcA.js +217 -0
  26. package/dist/public/assets/cose-bilkent-JH36ORCC-BfcYMYTa.js +1 -0
  27. package/dist/public/assets/cynefin-OW5HDTMX-Du659bmz.js +178 -0
  28. package/dist/public/assets/cynefinDiagram-VND7K2PF-DR_2Copy.js +62 -0
  29. package/dist/public/assets/cytoscape.esm-C5JX3QLV.js +331 -0
  30. package/dist/public/assets/dagre-6A5THRUB-COsChaV5.js +4 -0
  31. package/dist/public/assets/defaultLocale-CrowFXzY.js +1 -0
  32. package/dist/public/assets/diagram-22UHCM2B-Cx2T1HvL.js +200 -0
  33. package/dist/public/assets/diagram-3UASUU5V-Cp04LOka.js +24 -0
  34. package/dist/public/assets/diagram-ATOU4E4O-DlqFSshY.js +3 -0
  35. package/dist/public/assets/diagram-CDSNMT55-goq40ctA.js +30 -0
  36. package/dist/public/assets/diagram-MLGK6HIB-Bdzjsy4r.js +24 -0
  37. package/dist/public/assets/diagram-MPIPVDR6-DtU7cM6P.js +41 -0
  38. package/dist/public/assets/ebnfDiagram-ZINNZB2B-XyVCfmED.js +1 -0
  39. package/dist/public/assets/elk-276RUBZZ-aHVIw-2x.js +27 -0
  40. package/dist/public/assets/erDiagram-OPXOYQCR-Bq_PhTIL.js +99 -0
  41. package/dist/public/assets/flowDiagram-KWPJA3E3-WuqnDSiF.js +213 -0
  42. package/dist/public/assets/ganttDiagram-FUAMR5RP-DZ4lXzkC.js +292 -0
  43. package/dist/public/assets/gitGraphDiagram-X574FWY7-t6l07b9g.js +106 -0
  44. package/dist/public/assets/graph-jWsD5Ro-.js +1 -0
  45. package/dist/public/assets/index-DiYff8oR.css +32 -0
  46. package/dist/public/assets/index-YZNK723o.js +203 -0
  47. package/dist/public/assets/infoDiagram-VRGFBTTK-iUGqoOFp.js +2 -0
  48. package/dist/public/assets/init-Gi6I4Gst.js +1 -0
  49. package/dist/public/assets/ishikawaDiagram-OU5B5YK6-B0TmoUOK.js +70 -0
  50. package/dist/public/assets/journeyDiagram-ZHPQQLJL-CQE61MCu.js +139 -0
  51. package/dist/public/assets/kanban-definition-PNTS6WVX-D2MWCUyS.js +89 -0
  52. package/dist/public/assets/katex-C5jXJg4s.js +257 -0
  53. package/dist/public/assets/linear-WKGxVNP1.js +1 -0
  54. package/dist/public/assets/mermaid.core-DPEwUsaj.js +311 -0
  55. package/dist/public/assets/mindmap-definition-NLK3R4M7-DsYEsBsI.js +96 -0
  56. package/dist/public/assets/ordinal-Cboi1Yqb.js +1 -0
  57. package/dist/public/assets/pegDiagram-GJSIUBJH-BS52Fbxt.js +1 -0
  58. package/dist/public/assets/pieDiagram-5QR66LMP-D4o8g7nW.js +39 -0
  59. package/dist/public/assets/quadrantDiagram-O4NWA36T-DnYUxWll.js +7 -0
  60. package/dist/public/assets/railroadDiagram-XR7U4H2S-BKy_7ULV.js +1 -0
  61. package/dist/public/assets/requirementDiagram-PLB6GJNP-Ce5F4u8K.js +84 -0
  62. package/dist/public/assets/sankeyDiagram-IPEJSGJF-CCJDrut_.js +40 -0
  63. package/dist/public/assets/sequenceDiagram-PO4LG4MO-D5E8ISM1.js +169 -0
  64. package/dist/public/assets/sizeCapture-INFHLROL-BW3KmxDN.js +1 -0
  65. package/dist/public/assets/stateDiagram-v2-GCMORJYK-Bw2ZzQOE.js +291 -0
  66. package/dist/public/assets/swimlanes-2SLR337P-CLJ-6Ex_.js +1 -0
  67. package/dist/public/assets/swimlanesDiagram-TC7HE7FX-BD8rQfV7.js +8 -0
  68. package/dist/public/assets/timeline-definition-EJHVYXUP-C_lFJ-4W.js +120 -0
  69. package/dist/public/assets/usecaseDiagram-POWQR4AR-DWXawL2A.js +370 -0
  70. package/dist/public/assets/vennDiagram-UO4OBE2U-D4O5oZal.js +34 -0
  71. package/dist/public/assets/wardleyDiagram-VNRHLVJA-B6HElk__.js +78 -0
  72. package/dist/public/assets/xychartDiagram-PMCCYNJV-C5OAXnJy.js +7 -0
  73. package/dist/public/index.html +2 -2
  74. package/dist/src/agent-runtime/adapter.js +54 -10
  75. package/dist/src/agent-runtime/builtinRuntime.js +67 -6
  76. package/dist/src/agent-runtime/conversation.js +49 -9
  77. package/dist/src/agent-runtime/interaction.js +26 -12
  78. package/dist/src/agent-runtime/interactionLiveHub.js +4 -0
  79. package/dist/src/agent-runtime/runtime.js +83 -27
  80. package/dist/src/agent-runtime/tmuxRuntime.js +36 -6
  81. package/dist/src/agents/claude.js +2 -19
  82. package/dist/src/agents/claudeInteraction.js +2 -132
  83. package/dist/src/agents/claudePaneInput.js +22 -22
  84. package/dist/src/agents/codebuddy.js +100 -0
  85. package/dist/src/agents/codebuddyConversation.js +414 -0
  86. package/dist/src/agents/codebuddyInteraction.js +4 -0
  87. package/dist/src/agents/codebuddyNativeTail.js +206 -0
  88. package/dist/src/agents/codebuddyPaneInput.js +76 -0
  89. package/dist/src/agents/codebuddyTranscriptParse.js +178 -0
  90. package/dist/src/agents/hookEvents.js +143 -0
  91. package/dist/src/agents/hookInteraction.js +178 -0
  92. package/dist/src/agents/index.js +2 -1
  93. package/dist/src/claudeEvents.js +4 -5
  94. package/dist/src/cli/agentIntegration.js +31 -2
  95. package/dist/src/cli/codebuddyHooks.js +286 -0
  96. package/dist/src/cli/hookScaffold.js +25 -12
  97. package/dist/src/cli/i18n/en.js +1 -1
  98. package/dist/src/cli/i18n/zh.js +1 -1
  99. package/dist/src/cli/state.js +4 -0
  100. package/dist/src/cli/supervisor.js +2 -1
  101. package/dist/src/codebuddyEvents.js +151 -0
  102. package/dist/src/docs.js +21 -7
  103. package/dist/src/logDetail.js +27 -0
  104. package/dist/src/paneInput.js +107 -10
  105. package/dist/src/pendingPrompt.js +21 -5
  106. package/dist/src/routes/files.js +4 -1
  107. package/dist/src/routes/system.js +12 -2
  108. package/dist/src/server.js +69 -8
  109. package/dist/src/trimCapture.js +1 -1
  110. package/dist/src/workspace/checkpointer.js +48 -4
  111. package/dist/src/workspace/lock.js +54 -10
  112. package/dist/src/workspace/runtime.js +23 -8
  113. package/dist/src/workspaceProtocol.js +1 -0
  114. package/package.json +4 -4
  115. package/dist/public/assets/index-2ghkmGbT.js +0 -356
  116. package/dist/public/assets/index-BjnnFTLC.css +0 -32
@@ -3,6 +3,8 @@ import { createClaudeConversationAdapter } from '../agents/claudeConversation.js
3
3
  import { createClaudeInteractionAdapter } from '../agents/claudeInteraction.js';
4
4
  import { NativeInboxCoordinator } from '../agents/nativeInbox.js';
5
5
  import { createCodexNativeInboxSource, } from '../agents/nativeInboxSources.js';
6
+ import { createCodeBuddyConversationAdapter } from '../agents/codebuddyConversation.js';
7
+ import { createCodeBuddyInteractionAdapter } from '../agents/codebuddyInteraction.js';
6
8
  import { createCodexConversationAdapter } from '../agents/codexConversation.js';
7
9
  import { createCodexInteractionAdapter } from '../agents/codexInteraction.js';
8
10
  import { createPiConversationActivityReader, createPiConversationAdapter, } from '../agents/piConversation.js';
@@ -16,6 +18,7 @@ import { createCodexConversationActivationController } from '../agents/codexConv
16
18
  import { createClaudeSubscriptionUsageAdapter } from '../agents/claudeSubscriptionUsage.js';
17
19
  import { createCodexSubscriptionUsageAdapter } from '../agents/codexSubscriptionUsage.js';
18
20
  import { BridgeInboxCoordinator, PiInboxBridgeCoordinator, } from '../agents/piInboxBridge.js';
21
+ import { hookEventSequence } from '../agents/hookEvents.js';
19
22
  import { AgentRuntime, } from './runtime.js';
20
23
  export function createClaudeConversationActivityReader(events) {
21
24
  return {
@@ -40,6 +43,29 @@ export function createClaudeConversationActivityReader(events) {
40
43
  },
41
44
  };
42
45
  }
46
+ // The same table Claude's reader uses, from CodeBuddy's own classification: the wave the 对话 lens draws
47
+ // and the roster row come from one source, so they cannot disagree about what the pane is doing.
48
+ export function createCodebuddyConversationActivityReader(events) {
49
+ return {
50
+ async read(run) {
51
+ if (events?.paneSession && events.paneSession(run.ref.paneId)?.sessionId !== run.ref.sessionId) {
52
+ return { activity: 'unknown', activeTurn: { state: 'unknown' } };
53
+ }
54
+ const kind = events?.paneKind?.(run.ref.paneId, run.process) ?? null;
55
+ const activity = kind === 'working' ? 'working'
56
+ : kind === 'permission' ? 'waiting'
57
+ : kind === 'compacting' ? 'compacting'
58
+ : kind === 'done' || kind === 'idle' || kind === 'end' || kind === 'error'
59
+ ? 'idle' : 'unknown';
60
+ return {
61
+ activity,
62
+ activeTurn: activity === 'idle' ? { state: 'none' }
63
+ : activity === 'unknown' ? { state: 'unknown' }
64
+ : { state: 'active', nativeTurnId: `codebuddy-run:${run.ref.runId}` },
65
+ };
66
+ },
67
+ };
68
+ }
43
69
  export function createCodexConversationActivityReader(app) {
44
70
  return {
45
71
  async read(run) {
@@ -80,7 +106,7 @@ export function createCodexConversationActivityReader(app) {
80
106
  }
81
107
  // Built-in wiring is deliberately static and product-owned. This is not a plugin loader: Agent identity
82
108
  // still comes only from BUILTIN_AGENT_ADAPTERS, while factories close over local provider clients/Bridge.
83
- export function createBuiltinAgentRuntime({ home, claudeEvents, claudeProjectsRoot, claudeConversationControl, claudeInteractionControl, codexApp, codexClear, codexActivationCommands, codexActivationReceipts, codexSessionsRoot, piSessionsRoot, ...runtimeOptions }) {
109
+ export function createBuiltinAgentRuntime({ home, codebuddyEvents, codebuddyConversationControl, codebuddyInteractionControl, claudeEvents, claudeProjectsRoot, claudeConversationControl, claudeInteractionControl, codexApp, codexClear, codexActivationCommands, codexActivationReceipts, codexSessionsRoot, piSessionsRoot, ...runtimeOptions }) {
84
110
  const factories = {
85
111
  claude: (context) => {
86
112
  const subscriptionUsage = createClaudeSubscriptionUsageAdapter({
@@ -92,11 +118,9 @@ export function createBuiltinAgentRuntime({ home, claudeEvents, claudeProjectsRo
92
118
  agentId: 'claude',
93
119
  sourceId: 'claude.hooks',
94
120
  label: 'Claude',
95
- sourceEventSequence: (eventId) => {
96
- const match = /^claude-hook-([1-9]\d*)$/.exec(eventId);
97
- const sequence = match ? Number(match[1]) : NaN;
98
- return Number.isSafeInteger(sequence) ? sequence : null;
99
- },
121
+ // The Hook spool's event ID carries its durable source sequence; the interpreter is the SHARED one
122
+ // because CodeBuddy's spool is written by the same script with the same ID shape.
123
+ sourceEventSequence: hookEventSequence,
100
124
  });
101
125
  const conversationSessions = claudeEvents && typeof claudeEvents.paneSession === 'function'
102
126
  ? claudeEvents
@@ -146,6 +170,43 @@ export function createBuiltinAgentRuntime({ home, claudeEvents, claudeProjectsRo
146
170
  onBridgeConnected: (lease) => inbox.bind(lease).then(() => undefined),
147
171
  };
148
172
  },
173
+ // CodeBuddy is Claude's twin on this path: the same file Hooks, the same shared writer, the same Bridge
174
+ // durable lane, so it reuses the same coordinator. There is deliberately no native/legacy source — the
175
+ // Connector publishes both the neutral pane baseline and every lifecycle edge, and the ordered projector
176
+ // is the only authority for CodeBuddy state from day one.
177
+ codebuddy: (context) => {
178
+ const inbox = new BridgeInboxCoordinator({
179
+ host: context.bridge,
180
+ projector: context.inbox,
181
+ agentId: 'codebuddy',
182
+ sourceId: 'codebuddy.hooks',
183
+ label: 'CodeBuddy',
184
+ sourceEventSequence: hookEventSequence,
185
+ });
186
+ const conversationSessions = codebuddyEvents && typeof codebuddyEvents.paneSession === 'function'
187
+ ? codebuddyEvents
188
+ : undefined;
189
+ return {
190
+ inbox: true,
191
+ conversation: createCodeBuddyConversationAdapter({
192
+ ...(conversationSessions === undefined ? {} : { sessions: conversationSessions }),
193
+ ...(codebuddyConversationControl === undefined ? {} : { control: codebuddyConversationControl }),
194
+ }),
195
+ conversationActivity: createCodebuddyConversationActivityReader(codebuddyEvents),
196
+ ...(codebuddyInteractionControl === undefined ? {} : {
197
+ interaction: createCodeBuddyInteractionAdapter(codebuddyInteractionControl, 750, (availability, message) => context.health.report({
198
+ capability: 'interaction', availability,
199
+ ...(message ? { message } : {}),
200
+ ...(availability === 'ready' ? { lastSuccessAt: Date.now() } : {}),
201
+ })),
202
+ }),
203
+ start: () => {
204
+ inbox.start();
205
+ return () => inbox.close();
206
+ },
207
+ onBridgeConnected: (lease) => inbox.bind(lease).then(() => undefined),
208
+ };
209
+ },
149
210
  codex: (context) => {
150
211
  const resolvedCodexSessionsRoot = codexSessionsRoot ?? codexSessionsDir(home);
151
212
  const subscriptionUsage = createCodexSubscriptionUsageAdapter({
@@ -179,6 +179,8 @@ function capabilities(raw) {
179
179
  return null;
180
180
  if (raw.steer !== undefined && raw.steer !== true)
181
181
  return null;
182
+ if (raw.promptWhileActive !== undefined && raw.promptWhileActive !== true)
183
+ return null;
182
184
  if (raw.branching !== undefined && raw.branching !== true)
183
185
  return null;
184
186
  return {
@@ -186,6 +188,7 @@ function capabilities(raw) {
186
188
  live: raw.live,
187
189
  ...(raw.sendable === true ? { sendable: true } : {}),
188
190
  ...(raw.steer === true ? { steer: true } : {}),
191
+ ...(raw.promptWhileActive === true ? { promptWhileActive: true } : {}),
189
192
  ...(raw.send === undefined ? {} : {
190
193
  send: [...raw.send],
191
194
  }),
@@ -579,6 +582,9 @@ export class ConversationService {
579
582
  };
580
583
  }
581
584
  const activity = await this.#activitySource.read(run);
585
+ // Probed before the transaction: whether this provider's own input takes a prompt while a turn runs is
586
+ // a provider capability (the descriptor), while the decision below is state.
587
+ const promptWhileActive = await this.#promptWhileActive(adapter, run);
582
588
  const begin = await this.#withWrite(() => {
583
589
  const existing = this.#submission(session.agentId, session.sessionId, request.clientRequestId);
584
590
  if (existing) {
@@ -602,8 +608,15 @@ export class ConversationService {
602
608
  }
603
609
  const hasQueue = this.#state.submissions.some((item) => item.agentId === session.agentId
604
610
  && item.sessionId === session.sessionId && item.state === 'queued');
605
- const direct = activity.activity === 'idle' && activity.activeTurn.state === 'none'
606
- && cycle.state === 'closed' && !hasQueue;
611
+ // A provider whose TUI queues the text natively can be written to WHILE its turn runs, so a phone
612
+ // send does not have to wait for idle — the user's message joins the turn in progress, exactly as if
613
+ // they had typed it in the terminal. Only a running turn qualifies: a permission gate, a compaction
614
+ // or an unknown state is not an input the pane will take (see ConversationCapabilities).
615
+ const inTurn = promptWhileActive && !hasQueue
616
+ && activity.activity === 'working' && activity.activeTurn.state === 'active'
617
+ && cycle.state !== 'closed';
618
+ const direct = inTurn || (activity.activity === 'idle' && activity.activeTurn.state === 'none'
619
+ && cycle.state === 'closed' && !hasQueue);
607
620
  const revision = ++this.#state.ledgerRevision;
608
621
  const submission = {
609
622
  agentId: session.agentId, sessionId: session.sessionId,
@@ -616,7 +629,9 @@ export class ConversationService {
616
629
  ...this.#currentBaseline(session.agentId, session.sessionId),
617
630
  };
618
631
  this.#state.submissions.push(submission);
619
- if (direct)
632
+ // An in-turn send joins a turn that is ALREADY claimed: claiming (or reopening) the cycle here would
633
+ // tell the Core a new turn started, and the running turn's completion would then never be observed.
634
+ if (direct && !inTurn)
620
635
  this.#claimCycle(cycle, submission, activity);
621
636
  try {
622
637
  this.#save();
@@ -628,7 +643,7 @@ export class ConversationService {
628
643
  } };
629
644
  }
630
645
  return direct
631
- ? { kind: 'dispatch', id: submission.clientRequestId }
646
+ ? { kind: 'dispatch', id: submission.clientRequestId, inTurn }
632
647
  : { kind: 'receipt', value: this.#submitReceipt(submission) };
633
648
  });
634
649
  if (begin.kind === 'receipt') {
@@ -636,7 +651,7 @@ export class ConversationService {
636
651
  this.#wake(owner);
637
652
  return begin.value;
638
653
  }
639
- return this.#dispatchClaim(run, begin.id, 'direct');
654
+ return this.#dispatchClaim(run, begin.id, 'direct', undefined, begin.inTurn);
640
655
  }
641
656
  async queueSnapshot(run) {
642
657
  this.#requireRun(run);
@@ -1487,7 +1502,11 @@ export class ConversationService {
1487
1502
  }
1488
1503
  this.#state.submissions = this.#state.submissions.filter((candidate) => candidate !== item);
1489
1504
  }
1490
- async #dispatchClaim(run, submissionId, origin, detached) {
1505
+ async #dispatchClaim(run, submissionId, origin, detached,
1506
+ // This dispatch was admitted knowing a turn is running, because the provider's own input takes a prompt
1507
+ // while it works (ConversationCapabilities.promptWhileActive). Only send() passes it: a queued delivery
1508
+ // still waits for idle, and that is what keeps the queue's ordering promise intact.
1509
+ promptWhileActive = false) {
1491
1510
  const runRef = copyRun(run.ref);
1492
1511
  if (!runRef.sessionId) {
1493
1512
  return { status: 'rejected', reason: 'invalid_request', nativeMutation: false };
@@ -1512,8 +1531,9 @@ export class ConversationService {
1512
1531
  || preDispatchActivity.activeTurn.state !== 'none')))) {
1513
1532
  receipt = { outcome: 'rejected', nativeMutation: false, reason: 'conflict' };
1514
1533
  }
1515
- else if (origin !== 'steer' && (preDispatchActivity.activity !== 'idle'
1534
+ else if (!promptWhileActive && origin !== 'steer' && (preDispatchActivity.activity !== 'idle'
1516
1535
  || preDispatchActivity.activeTurn.state !== 'none')) {
1536
+ // A busy provider that has NOT declared in-turn input gets the message queued, never written to.
1517
1537
  receipt = { outcome: 'busy', nativeMutation: false };
1518
1538
  }
1519
1539
  else
@@ -1538,8 +1558,12 @@ export class ConversationService {
1538
1558
  return !run.signal.aborted && this.#runs.resolve(runRef) === run
1539
1559
  && current.epoch === preDispatchActivity.epoch
1540
1560
  && current.revision === preDispatchActivity.revision
1541
- && current.activity === 'idle'
1542
- && current.activeTurn.state === 'none';
1561
+ // Every dispatch requires the world it was decided in. The idle path additionally requires
1562
+ // the pane to STILL be idle; the in-turn path was decided knowing a turn is running, so it
1563
+ // requires only that nothing has moved — if it has, the adapter writes nothing and the
1564
+ // message falls back to the queue.
1565
+ && (promptWhileActive
1566
+ || (current.activity === 'idle' && current.activeTurn.state === 'none'));
1543
1567
  },
1544
1568
  });
1545
1569
  if (!operation)
@@ -2102,6 +2126,22 @@ export class ConversationService {
2102
2126
  if (changed)
2103
2127
  this.#wake(owner);
2104
2128
  }
2129
+ // Provider capability: does its own input accept a prompt while a turn is running? Read from the
2130
+ // descriptor, the same place every other provider capability comes from, and never fatal — an
2131
+ // unavailable or mismatched probe simply means "no", leaving the ordinary queue path in charge.
2132
+ async #promptWhileActive(adapter, run) {
2133
+ try {
2134
+ const raw = await adapter.discoverNative(run.ref);
2135
+ if (raw === null)
2136
+ return false;
2137
+ const descriptor = this.#descriptor(raw, run.ref.agentId);
2138
+ return descriptor.session.sessionId === run.ref.sessionId
2139
+ && descriptor.capabilities.promptWhileActive === true;
2140
+ }
2141
+ catch {
2142
+ return false;
2143
+ }
2144
+ }
2105
2145
  #wake(owner) {
2106
2146
  const state = this.#dispatchers.get(owner) ?? { running: false, wakePending: false, timer: undefined };
2107
2147
  this.#dispatchers.set(owner, state);
@@ -343,17 +343,16 @@ export class InteractionService {
343
343
  }
344
344
  }
345
345
  for (const item of baseline) {
346
- let record = this.#recordBySource(run.ref, item.id);
346
+ let record = this.#pendingRecordBySource(run.ref, item.id);
347
347
  if (!record) {
348
348
  record = this.#createRecord(run.ref, item);
349
349
  this.#state.interactions.push(record);
350
350
  }
351
- else if (record.state === 'pending') {
351
+ else {
352
352
  record.pending = item;
353
353
  record.updatedAt = this.#now();
354
354
  }
355
- if (record.state === 'pending')
356
- state.pendingBySource.set(item.id, record);
355
+ state.pendingBySource.set(item.id, record);
357
356
  }
358
357
  this.#save();
359
358
  });
@@ -496,8 +495,14 @@ export class InteractionService {
496
495
  #recordsFor(run) {
497
496
  return this.#state.interactions.filter((record) => (record.agentId === run.agentId && record.run.runId === run.runId));
498
497
  }
499
- #recordBySource(run, sourceId) {
500
- return this.#recordsFor(run).find((record) => record.sourceInteractionId === sourceId);
498
+ // A native id names the SHAPE of the gate its adapter reads off the screen (see hookInteraction), so the
499
+ // same id legitimately comes back for a new occurrence: every AskUserQuestion review screen parses to
500
+ // "Ready to submit your answers?" with the same two options. Only a still-pending record IS that item —
501
+ // a resolved one belongs to an occurrence that is over, and re-publishing must mint a new public identity
502
+ // for the new one instead of silently dropping it (a dropped re-appearance left the phone with no card at
503
+ // all while the pane sat on the review screen).
504
+ #pendingRecordBySource(run, sourceId) {
505
+ return this.#recordsFor(run).find((record) => (record.sourceInteractionId === sourceId && record.state === 'pending'));
501
506
  }
502
507
  #accept(state, event) {
503
508
  if (state.phase === 'closed')
@@ -567,10 +572,7 @@ export class InteractionService {
567
572
  const item = pending(event.interaction);
568
573
  if (!item)
569
574
  throw new InteractionContractError('Interaction opened payload is invalid');
570
- let record = this.#recordBySource(state.run.ref, item.id);
571
- if (record && record.state !== 'pending') {
572
- throw new InteractionContractError('Resolved native interaction id was reused');
573
- }
575
+ let record = this.#pendingRecordBySource(state.run.ref, item.id);
574
576
  await this.#withWrite(() => {
575
577
  if (!record) {
576
578
  record = this.#createRecord(state.run.ref, item);
@@ -596,8 +598,16 @@ export class InteractionService {
596
598
  throw new InteractionContractError('Interaction terminal payload is invalid');
597
599
  }
598
600
  const record = state.pendingBySource.get(event.interactionId);
599
- if (!record)
601
+ if (!record) {
602
+ // A dispatched answer already retired this item, so the adapter reporting that the gate left the screen
603
+ // is the same fact arriving a second time — and an adapter only stops reporting it once its next poll
604
+ // sees a different screen. Failing the observation closed here killed every later card with it: the
605
+ // phone was left with no card while the pane sat on the next gate (the review screen after a question).
606
+ const settled = this.#recordsFor(state.run.ref).some((candidate) => (candidate.sourceInteractionId === event.interactionId && candidate.state !== 'pending'));
607
+ if (settled)
608
+ return;
600
609
  throw new InteractionContractError('Interaction terminal event targets unknown pending item');
610
+ }
601
611
  await this.#withWrite(() => {
602
612
  record.state = 'resolved';
603
613
  record.receipt = event.type === 'resolved'
@@ -658,7 +668,11 @@ export class InteractionService {
658
668
  });
659
669
  if (!finalized || !live || !live.exposed || live.phase === 'closed')
660
670
  return;
661
- live.pendingBySource.delete(record.sourceInteractionId);
671
+ // The index is keyed by native id, and a new occurrence of the same gate may already have taken that
672
+ // key over — only retire the entry this response actually answered.
673
+ if (live.pendingBySource.get(record.sourceInteractionId) === record) {
674
+ live.pendingBySource.delete(record.sourceInteractionId);
675
+ }
662
676
  if (result.status === 'accepted' || result.status === 'already_resolved') {
663
677
  await this.#emit(live, {
664
678
  type: 'resolved', revision: ++live.revision, interactionId: record.publicInteractionId,
@@ -1,3 +1,4 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  const DEFAULT_MAX_BUFFERED_EVENTS = 256;
2
3
  const DEFAULT_IDLE_GRACE_MS = 5_000;
3
4
  export class InteractionLiveHubError extends Error {
@@ -128,6 +129,7 @@ export class InteractionLiveHub {
128
129
  #create(run) {
129
130
  const shared = {
130
131
  run,
132
+ epoch: randomUUID(),
131
133
  ready: Promise.resolve(),
132
134
  subscribers: new Set(),
133
135
  idleTimer: undefined,
@@ -144,6 +146,7 @@ export class InteractionLiveHub {
144
146
  }
145
147
  shared.handle = handle;
146
148
  shared.checkpoint = {
149
+ epoch: shared.epoch,
147
150
  revision: handle.revision,
148
151
  pending: structuredClone(handle.pending),
149
152
  };
@@ -181,6 +184,7 @@ export class InteractionLiveHub {
181
184
  throw new InteractionLiveHubError('unavailable', 'Interaction terminal event is invalid');
182
185
  }
183
186
  shared.checkpoint = {
187
+ epoch: shared.epoch,
184
188
  revision: event.revision,
185
189
  pending: [...pending.values()],
186
190
  };
@@ -542,16 +542,30 @@ export class AgentRuntime {
542
542
  .sort((first, second) => first.paneId.localeCompare(second.paneId)
543
543
  || first.agentId.localeCompare(second.agentId));
544
544
  }
545
+ // Per-pane process evidence for a single identity decision. Both probes are lazily memoized so a decision
546
+ // that only needs one of them costs one `ps` read, and every evidence method the host offers must be
547
+ // forwarded here or adapters silently lose it.
548
+ #processContext(pane) {
549
+ const source = this.#process;
550
+ let inspected;
551
+ let group;
552
+ return {
553
+ inspectForeground: () => {
554
+ inspected ??= source.inspectForeground(pane);
555
+ return inspected;
556
+ },
557
+ ...(source.inspectForegroundGroup === undefined ? {} : {
558
+ inspectForegroundGroup: () => {
559
+ group ??= source.inspectForegroundGroup(pane);
560
+ return group;
561
+ },
562
+ }),
563
+ };
564
+ }
545
565
  async identifyPanes(panes) {
546
566
  const activeAdapters = this.adapters.filter((adapter) => (!this.#lifecycles.get(adapter.id)?.abort.signal.aborted));
547
567
  const resolved = await Promise.all(panes.map(async (pane) => {
548
- let inspected;
549
- const context = {
550
- inspectForeground: () => {
551
- inspected ??= this.#process.inspectForeground(pane);
552
- return inspected;
553
- },
554
- };
568
+ const context = this.#processContext(pane);
555
569
  const identity = await resolveAgentIdentity(pane, activeAdapters, context, {
556
570
  verifyTimeoutMs: this.#verifyTimeoutMs,
557
571
  });
@@ -905,16 +919,63 @@ export class AgentRuntime {
905
919
  return false;
906
920
  }
907
921
  const pane = panes.find((value) => value.paneId === candidate.paneId);
908
- return pane ? (await this.#verifyAgainst(adapter, candidate, pane)) === 'valid' : false;
922
+ if (!pane)
923
+ return false;
924
+ if (await this.#anchorStillOwnsPane(candidate, pane))
925
+ return true;
926
+ return (await this.#verifyAgainst(adapter, candidate, pane)) === 'valid';
927
+ }
928
+ // The hook that produced this candidate already named the process that owns the pane (pid, start time and
929
+ // tty). While that exact generation is alive on the pane's own tty the pane cannot have changed under it, and
930
+ // re-deriving the identity by scanning the pane's foreground group is both slower and flakier: while the
931
+ // Agent runs a tool, the group's foreground owner is that tool, so the scan intermittently finds no Agent and
932
+ // the attachment is refused — which is what makes an Agent's row blink in and out of the roster, each
933
+ // refusal revoking the run and the next attempt creating a new one. Anything else — the pid gone, a
934
+ // different start time, a tty we cannot tie to this pane, no probe on this host, a probe that threw — falls
935
+ // through to the full verification, so this only ever skips work.
936
+ async #anchorStillOwnsPane(candidate, pane) {
937
+ const anchor = candidate.process;
938
+ const inspectProcess = this.#process.inspectProcess;
939
+ if (!inspectProcess || !anchor || anchor.startedAt === undefined)
940
+ return false;
941
+ try {
942
+ const alive = await inspectProcess(anchor.pid);
943
+ if (alive === null || alive.startedAt !== anchor.startedAt)
944
+ return false;
945
+ // A recycled pid on another pane is not this attachment.
946
+ const tty = alive.tty ?? anchor.tty;
947
+ return !!tty && !!pane.tty && tty === pane.tty;
948
+ }
949
+ catch {
950
+ return false;
951
+ }
952
+ }
953
+ // Steady-state recheck for a lease that already exists. The lease carries the process generation it was
954
+ // attached to — pid AND start time — and while that exact generation is alive the pane cannot have changed
955
+ // under it, so the full identity probe (a `ps -t` plus the lsof that resolves an executable, ~100ms for a
956
+ // Node pane) would only re-derive what the anchor already proves. Confirm the anchor with the one cheap
957
+ // probe instead. Anything else — the pid gone, a different start time, no probe on this host, a probe that
958
+ // threw — falls through to the full verification, so this can only ever skip work, never revoke on doubt.
959
+ async #verifyTracked(tracked, pane) {
960
+ const anchor = tracked.candidate.process;
961
+ const inspectProcess = this.#process.inspectProcess;
962
+ if (inspectProcess) {
963
+ try {
964
+ const alive = await inspectProcess(anchor.pid);
965
+ // Alive with the start time we anchored on — or alive with no start time to compare, which the hook
966
+ // sometimes cannot read: a live anchor is not doubt. Falling through to the full verification would
967
+ // scan the pane's foreground group, find the TOOL the Agent is running rather than the Agent, and
968
+ // revoke a run that is plainly still there.
969
+ if (alive !== null && (anchor.startedAt === undefined || alive.startedAt === anchor.startedAt)) {
970
+ return 'valid';
971
+ }
972
+ }
973
+ catch { /* fall through to the full verification */ }
974
+ }
975
+ return this.#verifyAgainst(tracked.adapter, tracked.candidate, pane);
909
976
  }
910
977
  async #verifyAgainst(adapter, candidate, pane) {
911
- let inspected;
912
- const context = {
913
- inspectForeground: () => {
914
- inspected ??= this.#process.inspectForeground(pane);
915
- return inspected;
916
- },
917
- };
978
+ const context = this.#processContext(pane);
918
979
  try {
919
980
  return await lifecycleWithin((async () => {
920
981
  const identity = await resolveAgentIdentity(pane, this.adapters, context, {
@@ -924,7 +985,9 @@ export class AgentRuntime {
924
985
  return 'unknown';
925
986
  if (identity.kind !== 'matched' || identity.adapter.id !== adapter.id)
926
987
  return 'invalid';
927
- const foreground = await context.inspectForeground(pane);
988
+ // An adapter that names its own process anchors the lease on it. The foreground leaf is only the
989
+ // default, for the Agents that are themselves the pane's foreground process.
990
+ const foreground = identity.process ?? await context.inspectForeground(pane);
928
991
  if (foreground === null)
929
992
  return 'unknown';
930
993
  // A partial best-effort probe cannot prove that the process generation changed. Preserve the
@@ -944,9 +1007,7 @@ export class AgentRuntime {
944
1007
  if (tracked.lease.signal.aborted || this.runs.resolve(tracked.lease.ref) !== tracked.lease)
945
1008
  continue;
946
1009
  const pane = panes.get(tracked.lease.ref.paneId);
947
- const verdict = pane
948
- ? await this.#verifyAgainst(tracked.adapter, tracked.candidate, pane)
949
- : 'invalid';
1010
+ const verdict = pane ? await this.#verifyTracked(tracked, pane) : 'invalid';
950
1011
  if (verdict === 'invalid' && this.runs.resolve(tracked.lease.ref) === tracked.lease) {
951
1012
  await this.runs.revokePane(tracked.lease.ref.paneId, pane ? 'process_exit' : 'pane_detached');
952
1013
  }
@@ -955,13 +1016,7 @@ export class AgentRuntime {
955
1016
  for (const pane of panes.values()) {
956
1017
  if (this.#closed || this.runs.currentForPane(pane.paneId))
957
1018
  continue;
958
- let inspected;
959
- const context = {
960
- inspectForeground: () => {
961
- inspected ??= this.#process.inspectForeground(pane);
962
- return inspected;
963
- },
964
- };
1019
+ const context = this.#processContext(pane);
965
1020
  const identity = await resolveAgentIdentity(pane, activeAdapters, context, {
966
1021
  verifyTimeoutMs: this.#verifyTimeoutMs,
967
1022
  });
@@ -971,7 +1026,8 @@ export class AgentRuntime {
971
1026
  try {
972
1027
  // Exact command matches skip Adapter verification, so bound this probe at its own use site.
973
1028
  // A timed-out result must not attach later or block other panes and subsequent snapshots.
974
- foreground = await lifecycleWithin(context.inspectForeground(pane), this.#verifyTimeoutMs, 'Agent process discovery');
1029
+ // A verifier-named process was already resolved inside the verification budget.
1030
+ foreground = identity.process ?? await lifecycleWithin(context.inspectForeground(pane), this.#verifyTimeoutMs, 'Agent process discovery');
975
1031
  }
976
1032
  catch {
977
1033
  continue;
@@ -150,16 +150,24 @@ export async function processStartedAt(run, pid) {
150
150
  return Number.isFinite(parsed) ? parsed : undefined;
151
151
  }
152
152
  export function createLocalAgentProcessContext({ run = defaultRun, } = {}) {
153
+ const PS_FORMAT = 'pid=,ppid=,stat=,etime=,tty=,command=';
154
+ // One `ps` read, shared by both evidence methods below. The full-system scan is only the fallback for a
155
+ // platform whose targeted query returns nothing; rows are always re-filtered to the pane's own tty.
156
+ const foregroundGroup = async (pane) => {
157
+ const tty = normTty(pane.tty);
158
+ if (!tty)
159
+ return { tty: '', rows: [] };
160
+ let output = await run('ps', ['-t', tty, '-o', PS_FORMAT]);
161
+ if (!String(output).trim())
162
+ output = await run('ps', ['-Ao', PS_FORMAT]);
163
+ return { tty, rows: foregroundRows(output).filter((row) => row.tty === tty) };
164
+ };
153
165
  return {
154
166
  async inspectForeground(pane) {
155
- const tty = normTty(pane.tty);
167
+ const { tty, rows } = await foregroundGroup(pane);
156
168
  if (!tty)
157
169
  return null;
158
- let output = await run('ps', ['-t', tty, '-o', 'pid=,ppid=,stat=,etime=,tty=,command=']);
159
- if (!String(output).trim()) {
160
- output = await run('ps', ['-Ao', 'pid=,ppid=,stat=,etime=,tty=,command=']);
161
- }
162
- const row = foregroundLeaf(foregroundRows(output), tty, pane.currentCommand);
170
+ const row = foregroundLeaf(rows, tty, pane.currentCommand);
163
171
  if (!row)
164
172
  return null;
165
173
  const startedAt = await processStartedAt(run, row.pid);
@@ -172,5 +180,27 @@ export function createLocalAgentProcessContext({ run = defaultRun, } = {}) {
172
180
  ...(row.command ? { commandLine: row.command } : {}),
173
181
  };
174
182
  },
183
+ async inspectForegroundGroup(pane) {
184
+ const { rows } = await foregroundGroup(pane);
185
+ // Start time is resolved per row: a returned row can become a lease anchor, and a pid without a
186
+ // process generation is not an identity. Executable is deliberately NOT resolved — one lsof per row
187
+ // is expensive, and the adapters that need the group match on the command line.
188
+ return Promise.all(rows.map(async (row) => {
189
+ const startedAt = await processStartedAt(run, row.pid);
190
+ return {
191
+ pid: row.pid,
192
+ ppid: row.ppid,
193
+ tty: pane.tty ?? `/dev/${row.tty}`,
194
+ ...(startedAt === undefined ? {} : { startedAt }),
195
+ ...(row.command ? { commandLine: row.command } : {}),
196
+ };
197
+ }));
198
+ },
199
+ // One `ps -p`, and deliberately no executable lookup: a lease only needs to know that the pid it was
200
+ // attached to is still the same process generation.
201
+ async inspectProcess(pid) {
202
+ const startedAt = await processStartedAt(run, pid);
203
+ return startedAt === undefined ? null : { pid, startedAt };
204
+ },
175
205
  };
176
206
  }
@@ -13,6 +13,7 @@ import os from 'node:os';
13
13
  import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
14
14
  import { resolveByExecutable, executableBasename } from './processIdentity.js';
15
15
  import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
16
+ import { hookErrorMessage } from './hookEvents.js';
16
17
  const record = (value) => (value !== null && typeof value === 'object' && !Array.isArray(value)
17
18
  ? value
18
19
  : null);
@@ -59,24 +60,6 @@ function permMsg(body) {
59
60
  return '需要你批准计划';
60
61
  return t ? `需要你授权:${t}` : '需要你';
61
62
  }
62
- // Friendly Chinese for the StopFailure error type (matcher values, see the hooks doc). The payload shape
63
- // isn't verified against a live rate-limit yet, so read the type defensively from several likely fields and
64
- // always fall back to a bare 本轮出错 — a wrong field name degrades to the generic label, never throws.
65
- const STOPFAIL_LABEL = {
66
- rate_limit: '触发限流', overloaded: '服务过载', authentication_failed: '认证失败',
67
- oauth_org_not_allowed: '组织未授权', billing_error: '额度/账单问题', invalid_request: '请求无效',
68
- model_not_found: '模型不可用', server_error: '服务端错误', max_output_tokens: '输出超长', unknown: '未知错误',
69
- };
70
- function stopFailMsg(body) {
71
- const error = record(body.error);
72
- const type = text(body.error_type) || text(body.reason) || text(body.type)
73
- || text(body.error) || text(error?.type);
74
- const label = STOPFAIL_LABEL[type];
75
- if (label)
76
- return label;
77
- const raw = text(body.error) || text(body.message);
78
- return raw ? String(raw).replace(/\s+/g, ' ').trim().slice(0, 80) : '';
79
- }
80
63
  // Build the 进行中 one-liner for a resume (PostToolUse after the user answered/approved), surfacing the
81
64
  // choice they just made — AskUserQuestion stores it in tool_input.answers, keyed by question.
82
65
  function resumeMsg(body) {
@@ -136,7 +119,7 @@ export function classifyClaude(src, rawBody = {}) {
136
119
  if (src === 'compact')
137
120
  return null; // PostCompact: done → clear 压缩中/进行中
138
121
  if (src === 'stopfail')
139
- return { kind: 'error', msg: stopFailMsg(body) }; // turn died on an API error
122
+ return { kind: 'error', msg: hookErrorMessage(body) }; // turn died on an API error
140
123
  if (src === 'end')
141
124
  return { kind: 'end' };
142
125
  if (src === 'start')