@jini-ai/chat 0.6.6 → 0.6.10

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 (160) hide show
  1. package/CHANGELOG.md +129 -1
  2. package/dist/core/accepted-input.d.ts +54 -0
  3. package/dist/core/accepted-input.js +165 -0
  4. package/dist/core/agent-plugin-chat-state.d.ts +43 -0
  5. package/dist/core/agent-plugin-chat-state.js +48 -0
  6. package/dist/core/assistant-client/a2ui-action-poster.js +32 -28
  7. package/dist/core/assistant-client/messages.d.ts +1 -1
  8. package/dist/core/assistant-client/messages.js +2 -2
  9. package/dist/core/assistant-client/transport.d.ts +2 -2
  10. package/dist/core/assistant-client/transport.js +187 -98
  11. package/dist/core/assistant-content.d.ts +2 -1
  12. package/dist/core/assistant-content.js +9 -11
  13. package/dist/core/browser/page-actions.js +4 -2
  14. package/dist/core/composer-history.d.ts +16 -9
  15. package/dist/core/composer-history.js +28 -16
  16. package/dist/core/conversation-title.d.ts +3 -0
  17. package/dist/core/conversation-title.js +3 -0
  18. package/dist/core/durable-projection.js +20 -13
  19. package/dist/core/durable-runs/continuation.d.ts +4 -0
  20. package/dist/core/durable-runs/continuation.js +31 -6
  21. package/dist/core/durable-runs/ledger-contracts.d.ts +2 -1
  22. package/dist/core/durable-runs/ports.d.ts +6 -2
  23. package/dist/core/index.d.ts +2 -0
  24. package/dist/core/index.js +2 -0
  25. package/dist/core/messages.d.ts +2 -0
  26. package/dist/core/partial-json.d.ts +6 -7
  27. package/dist/core/partial-json.js +81 -47
  28. package/dist/core/persistence/title.js +4 -2
  29. package/dist/core/question-answer.d.ts +23 -0
  30. package/dist/core/question-answer.js +66 -0
  31. package/dist/core/question-form/format.d.ts +6 -6
  32. package/dist/core/question-form/format.js +16 -17
  33. package/dist/core/question-form/normalize.d.ts +18 -0
  34. package/dist/core/question-form/normalize.js +112 -122
  35. package/dist/core/question-form/scan.js +141 -203
  36. package/dist/core/run-activity.js +62 -49
  37. package/dist/core/run-events/entry.d.ts +10 -1
  38. package/dist/core/run-events/entry.js +117 -87
  39. package/dist/core/todos.d.ts +1 -1
  40. package/dist/core/todos.js +60 -85
  41. package/dist/core/tool-events.js +14 -12
  42. package/dist/core/transcript.js +62 -16
  43. package/dist/core/turn-usage.js +54 -32
  44. package/dist/core/user-text-redaction.js +3 -0
  45. package/dist/core/util/manifest.js +77 -82
  46. package/dist/core/util/markdown-context.js +51 -53
  47. package/dist/core/util/parser.js +136 -144
  48. package/dist/core/util/pointer.js +16 -16
  49. package/dist/core/util/strip.js +57 -67
  50. package/dist/core/util/validate.js +15 -17
  51. package/dist/embed-widget/browser/session-store.js +21 -12
  52. package/dist/embed-widget/transports/fetch-sse.js +108 -95
  53. package/dist/embed-widget/vanilla/index.js +81 -70
  54. package/dist/react/agent-bridge/frontend-session-bridge.js +80 -52
  55. package/dist/react/components/AttachmentPreviewModal.js +25 -1
  56. package/dist/react/components/ChatSettingsDialog.d.ts +13 -0
  57. package/dist/react/components/ChatSettingsDialog.hooks.d.ts +115 -0
  58. package/dist/react/components/ChatSettingsDialog.hooks.js +40 -0
  59. package/dist/react/components/ChatSettingsDialog.js +72 -0
  60. package/dist/react/components/ChatSettingsSegmented.d.ts +18 -0
  61. package/dist/react/components/ChatSettingsSegmented.js +11 -0
  62. package/dist/react/components/Composer.d.ts +5 -1
  63. package/dist/react/components/Composer.js +126 -82
  64. package/dist/react/components/ConversationList.js +25 -39
  65. package/dist/react/components/ConversationTitleEditor.d.ts +27 -0
  66. package/dist/react/components/ConversationTitleEditor.js +54 -0
  67. package/dist/react/components/Icon.d.ts +1 -1
  68. package/dist/react/components/Icon.js +25 -45
  69. package/dist/react/components/Markdown.js +134 -123
  70. package/dist/react/components/McpUiSurfaceCard.d.ts +2 -2
  71. package/dist/react/components/McpUiSurfaceCard.js +30 -9
  72. package/dist/react/components/MessageList.d.ts +13 -13
  73. package/dist/react/components/MessageList.hooks.d.ts +6 -1
  74. package/dist/react/components/MessageList.hooks.js +19 -3
  75. package/dist/react/components/MessageList.js +34 -10
  76. package/dist/react/components/MessageRow.d.ts +1 -1
  77. package/dist/react/components/MessageRow.js +75 -47
  78. package/dist/react/components/MessageRow.usage.js +16 -11
  79. package/dist/react/components/MidRunUserMessage.js +16 -3
  80. package/dist/react/components/QuestionForm.js +148 -76
  81. package/dist/react/components/QuestionsPanel.js +1 -1
  82. package/dist/react/components/TodoCard.js +12 -6
  83. package/dist/react/components/ToolCallGroup.d.ts +4 -3
  84. package/dist/react/components/ToolCallGroup.js +7 -5
  85. package/dist/react/components/ToolCard.js +47 -40
  86. package/dist/react/components/composer-discovery.js +6 -1
  87. package/dist/react/components/mcp-ui-surface-state.d.ts +6 -11
  88. package/dist/react/components/mcp-ui-surface-state.js +23 -8
  89. package/dist/react/ext-event-renderer-registry.d.ts +8 -0
  90. package/dist/react/ext-event-renderer-registry.js +5 -2
  91. package/dist/react/features/chat-pane/components/AgentPluginUsage.d.ts +7 -0
  92. package/dist/react/features/chat-pane/components/AgentPluginUsage.js +45 -0
  93. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +55 -19
  94. package/dist/react/features/chat-pane/components/ChatPane.d.ts +1 -1
  95. package/dist/react/features/chat-pane/components/ChatPane.js +192 -74
  96. package/dist/react/features/chat-pane/components/RuntimeDropUp.d.ts +29 -0
  97. package/dist/react/features/chat-pane/components/RuntimeDropUp.js +45 -0
  98. package/dist/react/features/chat-pane/create-daemon-attachment-uploader.js +80 -47
  99. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +44 -40
  100. package/dist/react/features/chat-pane/create-mid-run-message-poster.d.ts +6 -6
  101. package/dist/react/features/chat-pane/create-mid-run-message-poster.js +8 -8
  102. package/dist/react/features/chat-pane/create-typed-answer-poster.d.ts +3 -3
  103. package/dist/react/features/chat-pane/create-typed-answer-poster.js +3 -3
  104. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.d.ts +6 -1
  105. package/dist/react/features/chat-pane/hooks/useAgentRuntimePicker.hooks.js +11 -5
  106. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.d.ts +12 -7
  107. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +340 -149
  108. package/dist/react/features/chat-pane/hooks/useChatPaneControlsHeight.hooks.js +7 -6
  109. package/dist/react/features/chat-pane/hooks/useChatPaneNewTabLinks.hooks.js +28 -10
  110. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.d.ts +3 -3
  111. package/dist/react/features/chat-pane/hooks/useChatPaneRuntimeInventory.hooks.js +60 -12
  112. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.d.ts +49 -0
  113. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +143 -0
  114. package/dist/react/features/chat-pane/model-options.d.ts +14 -0
  115. package/dist/react/features/chat-pane/model-options.js +45 -25
  116. package/dist/react/features/chat-pane/rules.d.ts +8 -9
  117. package/dist/react/features/chat-pane/rules.js +84 -46
  118. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  119. package/dist/react/features/chat-pane/styles.js +152 -36
  120. package/dist/react/features/chat-pane/types.d.ts +19 -9
  121. package/dist/react/hooks/assistant-chats.js +12 -5
  122. package/dist/react/hooks/composer-draft-cache.js +116 -118
  123. package/dist/react/hooks/composer-history-storage.js +5 -5
  124. package/dist/react/hooks/fab-position.js +7 -4
  125. package/dist/react/hooks/useAnsweredCards.d.ts +21 -4
  126. package/dist/react/hooks/useAnsweredCards.js +38 -11
  127. package/dist/react/hooks/useArtifactStream.js +28 -22
  128. package/dist/react/hooks/useComposer.d.ts +20 -0
  129. package/dist/react/hooks/useComposer.js +22 -9
  130. package/dist/react/hooks/useComposerHistory.hooks.d.ts +6 -1
  131. package/dist/react/hooks/useComposerHistory.hooks.js +39 -19
  132. package/dist/react/hooks/useComposerHistoryKeys.hooks.js +24 -14
  133. package/dist/react/hooks/useConversation.d.ts +7 -0
  134. package/dist/react/hooks/useConversation.js +23 -10
  135. package/dist/react/hooks/useExtEventGroups.js +39 -39
  136. package/dist/react/hooks/useQuestionForms.js +21 -26
  137. package/dist/react/hooks/useRunStream.js +10 -2
  138. package/dist/react/index.d.ts +7 -1
  139. package/dist/react/index.js +9 -0
  140. package/dist/react/message-blocks.d.ts +6 -4
  141. package/dist/react/message-blocks.js +94 -72
  142. package/dist/react/slots.d.ts +2 -2
  143. package/dist/react/tool-call-display.d.ts +12 -6
  144. package/dist/react/tool-call-display.js +53 -24
  145. package/dist/server/durable-runs/recover.js +132 -80
  146. package/dist/server/run-finalizer/entry.js +25 -15
  147. package/dist/store/legacy/sqlite/conversations.d.ts +11 -11
  148. package/dist/store/legacy/sqlite/conversations.js +69 -98
  149. package/dist/store/legacy/sqlite/messages.d.ts +10 -2
  150. package/dist/store/legacy/sqlite/messages.js +113 -71
  151. package/dist/store/runtime/chat-history-store.js +9 -3
  152. package/dist/store/runtime/durable-run-store.d.ts +2 -2
  153. package/dist/store/runtime/durable-run-store.js +165 -117
  154. package/dist/store/runtime/history-tools.js +118 -82
  155. package/dist/store/runtime/run-ledger.d.ts +4 -1
  156. package/dist/store/runtime/run-ledger.js +56 -16
  157. package/dist/store/sql/cursor.js +38 -18
  158. package/dist/store/sql/store.d.ts +18 -0
  159. package/dist/store/sql/store.js +46 -15
  160. package/package.json +18 -16
@@ -7,6 +7,33 @@ export { RECOVERY_LIMIT, RECOVERY_WINDOW_MS, RUN_STALENESS_MS, SAVED_WORK_NOTICE
7
7
  // translateRunFrame saves a run error frame as a status label holding only its message: the
8
8
  // daemon's MCP_BRIDGE_UNAVAILABLE code is dropped, so the bridge failure is matched by its text.
9
9
  const MCP_BRIDGE_FAILURE = /The assistant's tools did not load \(MCP bridge "jini" status: [^\r\n]*/;
10
+ const STOPPED_NOTICE = "Stopped. Saved work is above.";
11
+ /** Index of the last event matching `match`, or -1.
12
+ * @complexity O(e) time, O(1) space, no effects. */
13
+ function lastIndexWhere({ events, match }, _optional = {}) {
14
+ // Reverse scan preserves the last-marker rule on the desktop's ES2022 target.
15
+ for (let index = events.length - 1; index >= 0; index--)
16
+ if (match(events[index]))
17
+ return index;
18
+ return -1;
19
+ }
20
+ /** One saved event's contribution to the executor diagnostics. */
21
+ function diagnosticLines({ event }, _optional = {}) {
22
+ if (event.kind === "status")
23
+ return [event.label, event.detail ?? ""];
24
+ if (event.kind !== "raw")
25
+ return [];
26
+ try {
27
+ const result = JSON.parse(event.line);
28
+ // Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
29
+ if (result?.type === "result" && result.is_error === false)
30
+ return [];
31
+ }
32
+ catch {
33
+ // Non-JSON stdout can still contain the executor's failure reason.
34
+ }
35
+ return [event.line];
36
+ }
10
37
  /** Retryability is independent of session resumability. Inspect executor diagnostics, never
11
38
  * answer text or tool results: those can quote unrelated failures from completed work. Old
12
39
  * attempt diagnostics remain visible but cannot decide the fate of their continuation. */
@@ -14,55 +41,36 @@ function attemptDiagnostics({ run }, _optional = {}) {
14
41
  const events = run.message.events ?? [];
15
42
  // Checkpoints merge adjacent text, so a base's event count is not a durable offset. The
16
43
  // coordinator's recovery marker separates attempts and survives that compaction.
17
- let marker = -1;
18
- // Reverse scan preserves the last-marker rule on the desktop's ES2022 target.
19
- for (let index = run.attemptBase.length ? events.length - 1 : -1; index >= 0; index--) {
20
- const event = events[index];
21
- if (event.kind === "status" && event.code === "run_recovering") {
22
- marker = index;
23
- break;
24
- }
25
- }
26
- return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => {
27
- if (event.kind === "status")
28
- return [event.label, event.detail ?? ""];
29
- if (event.kind !== "raw")
30
- return [];
31
- try {
32
- const result = JSON.parse(event.line);
33
- // Successful CLI summaries contain usage metadata and answer prose, not failure evidence.
34
- if (result?.type === "result" && result.is_error === false)
35
- return [];
36
- }
37
- catch {
38
- // Non-JSON stdout can still contain the executor's failure reason.
39
- }
40
- return [event.line];
41
- }).join("\n");
44
+ const marker = run.attemptBase.length
45
+ ? lastIndexWhere({ events, match: (event) => event.kind === "status" && event.code === "run_recovering" })
46
+ : -1;
47
+ return events.slice(marker >= 0 ? marker + 1 : run.attemptBase.length).flatMap((event) => diagnosticLines({ event })).join("\n");
42
48
  }
43
- function attemptFailure(run) {
44
- const diagnostics = attemptDiagnostics({ run }, {});
49
+ /** Quota/billing and bad requests need human repair. A 429 for rate throttling and a 5xx
50
+ * overload remain retryable; the provider's coarse RATE_LIMITED class also includes billing. */
51
+ function needsHumanRepair({ diagnostics }, _optional = {}) {
52
+ const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
53
+ if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
54
+ return true;
55
+ return /another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics);
56
+ }
57
+ function attemptFailure({ diagnostics }, _optional = {}) {
45
58
  // MCP_BRIDGE_UNAVAILABLE needs bridge repair; another attempt repeats the same failure.
46
59
  // Classify it before interpreting the bridge's stderr as an agent auth/watchdog failure.
47
60
  if (MCP_BRIDGE_FAILURE.test(diagnostics))
48
61
  return "permanent";
49
62
  if (/inactivity[_ -]?(timeout|watchdog)|watchdog.*(kill|stop)/i.test(diagnostics))
50
63
  return "inactivity-watchdog";
51
- const service = classifyAgentServiceFailure({ text: diagnostics });
52
- if (service === "AGENT_AUTH_REQUIRED")
64
+ if (classifyAgentServiceFailure({ text: diagnostics }) === "AGENT_AUTH_REQUIRED")
53
65
  return "auth-failure";
54
- // Quota/billing and bad requests need human repair. A 429 for rate throttling and a 5xx
55
- // overload remain retryable; the provider's coarse RATE_LIMITED class also includes billing.
56
- const requestStatuses = diagnostics.matchAll(/\b(?:http(?:[ /]?\d(?:\.\d)?)?|(?:status|error|response)(?:[ _-]?code)?|code(?=\s*[:=#]))[\s:=#-]*(4\d\d)\b/gi);
57
- if ([...requestStatuses].some((match) => match[1] !== "408" && match[1] !== "429"))
58
- return "permanent";
59
- if (/another answer in this chat is still running|invalid[_ -]?request|context[_ -](length|window)|maximum context|model.*(?:not[_ -]found|does not exist|not supported)|unsupported|does not support|permission denied|forbidden|insufficient[_ -]?(quota|balance|credit|funds)|credit balance is too low|exceeded your current quota|quota[_ -]?exceeded|usage limit|billing|payment required/i.test(diagnostics))
60
- return "permanent";
61
- return "retryable";
66
+ return needsHumanRepair({ diagnostics }) ? "permanent" : "retryable";
62
67
  }
63
68
  /** Keep the notice bounded and scoped to the same executor diagnostics as failure classification. */
69
+ function lastDiagnosticLine({ diagnostics }, _optional = {}) {
70
+ return diagnostics.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 300);
71
+ }
64
72
  function attemptFailureDetail({ run }, _optional = {}) {
65
- return attemptDiagnostics({ run }, {}).split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 300);
73
+ return lastDiagnosticLine({ diagnostics: attemptDiagnostics({ run }) });
66
74
  }
67
75
  function exclusion(run) {
68
76
  if (run.cancelReason)
@@ -71,15 +79,48 @@ function exclusion(run) {
71
79
  return "excluded";
72
80
  return null;
73
81
  }
82
+ /** Preflight cards precede model tools, so the normal open-tool activity scan cannot see them.
83
+ * Chat saves the bare resource without its wire toolUseId. Saved consent or subsequent model
84
+ * execution closes this preflight wait, including cards saved before this fix.
85
+ * A model tool closes the wait and no later card can reopen it, so the wait is pending exactly
86
+ * when no tool ran and the last card follows the last consent for this conversation.
87
+ * @complexity O(e) time, O(1) space, no effects.
88
+ */
89
+ function awaitingAnswer({ run }, _options = {}) {
90
+ if (isAwaitingAnswer({ events: run.message.events }))
91
+ return true;
92
+ const events = run.message.events ?? [];
93
+ if (events.some((event) => event.kind === "tool_use"))
94
+ return false;
95
+ const card = lastIndexWhere({ events, match: (event) => event.kind === "ext" && event.name === "mcp-ui" });
96
+ const consent = lastIndexWhere({ events, match: (event) => event.kind === "ext" && event.name === "agent_plugins"
97
+ && event.data?.conversationId === run.conversationId });
98
+ return card > consent;
99
+ }
74
100
  function budgetNotice(run, now) {
75
101
  if (now - run.lastProgressAt >= RUN_STALENESS_MS)
76
- return isAwaitingAnswer({ events: run.message.events }) ? AWAITING_ANSWER_NOTICE : SAVED_WORK_NOTICE;
102
+ return awaitingAnswer({ run }) ? AWAITING_ANSWER_NOTICE : SAVED_WORK_NOTICE;
77
103
  if (run.recoveryCount >= RECOVERY_LIMIT)
78
104
  return EXHAUSTED_NOTICE;
79
105
  if (run.recoveryDeadline !== null && now >= run.recoveryDeadline)
80
106
  return EXHAUSTED_NOTICE;
81
107
  return null;
82
108
  }
109
+ /** The exhausted notice's detail: the attempt's last diagnostic, else which budget ended. */
110
+ function exhaustedDetail({ run }, _optional = {}) {
111
+ return attemptFailureDetail({ run }) ?? (run.recoveryCount >= RECOVERY_LIMIT ? `Tried ${RECOVERY_LIMIT} times` : "Recovery window ended");
112
+ }
113
+ /** Saved work to carry into the next attempt, seeding the saved content when no text event holds it. */
114
+ function savedWork({ run }, _optional = {}) {
115
+ const saved = [...(run.message.events ?? [])];
116
+ if (!saved.some((event) => event.kind === "text") && run.message.content)
117
+ saved.unshift({ kind: "text", text: run.message.content });
118
+ return saved;
119
+ }
120
+ /** A dead attempt continues now; a fresh queued one waits (see the caller's comment). */
121
+ function continuesNow({ run, probe, trigger, now }, _optional = {}) {
122
+ return probe === "dead" && (trigger === "attempt-failed" || now - run.attemptStartedAt >= 10_000);
123
+ }
83
124
  /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
84
125
  * Each recovery decision logs one `logger.info` line (default console) of run metadata. */
85
126
  export function createDurableRecovery(ports, { logger = console } = {}) {
@@ -98,6 +139,16 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
98
139
  return false;
99
140
  return ports.verifyChildDead(run.child, {}).catch(() => false);
100
141
  }
142
+ async function launchNext({ run, next, native }, _optional = {}) {
143
+ try {
144
+ await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
145
+ await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
146
+ }
147
+ catch {
148
+ // A rejected/lost start response is uncertain: the daemon may already be executing. The
149
+ // same idempotent attempt is probed by the attached watcher before another CAS is possible.
150
+ }
151
+ }
101
152
  async function continueDead(run) {
102
153
  await ports.cancelAttempt(run, {}).catch(() => undefined);
103
154
  // A locator captured before a hard cancel can outlive its rollout. Reuse reconstruction,
@@ -105,10 +156,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
105
156
  const missingResume = run.request.agentId === 'codex' && /no rollout found for thread id/.test(attemptDiagnostics({ run }, {}));
106
157
  const native = !missingResume && await nativeAllowed(run);
107
158
  const nextRunId = ports.mintRunId();
108
- const saved = [...(run.message.events ?? [])];
109
- if (!saved.some((event) => event.kind === "text") && run.message.content)
110
- saved.unshift({ kind: "text", text: run.message.content });
111
- const events = recoveredRunEvents({ saved }, { notice: missingResume ? 'silent' : 'visible' });
159
+ const events = recoveredRunEvents({ saved: savedWork({ run }) }, { notice: missingResume ? 'silent' : 'visible' });
112
160
  if (!await ports.store.advance({ run, nextRunId, now: ports.now(), events }, {}))
113
161
  return "superseded";
114
162
  const next = await ports.store.load({ messageId: run.messageId }, {});
@@ -117,41 +165,46 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
117
165
  if (!next || next.runId !== nextRunId)
118
166
  return "superseded";
119
167
  if (next.cancelReason)
120
- return finalize(next, "Stopped. Saved work is above.", { canceled: true });
121
- try {
122
- await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
123
- await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
124
- }
125
- catch {
126
- // A rejected/lost start response is uncertain: the daemon may already be executing. The
127
- // same idempotent attempt is probed by the attached watcher before another CAS is possible.
128
- }
168
+ return finalize(next, STOPPED_NOTICE, { canceled: true });
169
+ await launchNext({ run, next, native });
129
170
  ports.attach(next, {});
130
171
  return "continued";
131
172
  }
132
- async function decide(run, trigger, liveRunId, seen) {
133
- if (exclusion(run))
134
- return finalize(run, "Stopped. Saved work is above.", { canceled: true });
173
+ async function probeAttempt({ run, trigger, liveRunId }, _optional = {}) {
135
174
  // A terminal executor end confirms this attempt is dead even when the daemon retains its
136
175
  // replayable record (HTTP 200). Other triggers must obtain fresh liveness evidence.
137
- const probe = seen.probe = trigger === "attempt-failed" ? "dead" : liveRunId === run.runId ? "live" : await ports.probe(run, {});
138
- if (probe === "live") {
139
- await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
140
- ports.attach(run, {});
141
- return "reattached";
142
- }
176
+ if (trigger === "attempt-failed")
177
+ return "dead";
178
+ return liveRunId === run.runId ? "live" : ports.probe(run, {});
179
+ }
180
+ async function reattach(run) {
181
+ await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
182
+ ports.attach(run, {});
183
+ return "reattached";
184
+ }
185
+ async function finalizeFailure({ run, failure, diagnostics }, _optional = {}) {
186
+ if (failure !== "permanent")
187
+ return finalize(run, failure === "auth-failure" ? "Not logged in. Saved work is above." : STOPPED_NOTICE, { canceled: true });
188
+ // A bridge failure shows its own reason (and stderr): the generic notice hides what needs repair.
189
+ const bridgeNotice = diagnostics.match(MCP_BRIDGE_FAILURE)?.[0];
190
+ return finalize(run, bridgeNotice ?? EXHAUSTED_NOTICE, { detail: bridgeNotice ? undefined : lastDiagnosticLine({ diagnostics }) });
191
+ }
192
+ async function decide(run, trigger, liveRunId) {
193
+ if (exclusion(run))
194
+ return { decision: await finalize(run, STOPPED_NOTICE, { canceled: true }) };
195
+ const probe = await probeAttempt({ run, trigger, liveRunId });
196
+ if (probe === "live")
197
+ return { probe, decision: await reattach(run) };
198
+ // A pending human answer takes precedence over executor exit diagnostics.
199
+ if (probe === "dead" && awaitingAnswer({ run }))
200
+ return { probe, decision: await resolveMissing(run, probe, trigger) };
143
201
  // Diagnostics decide only the fate of an attempt that is no longer running. Read before the
144
202
  // probe, raw stdout quoting a tool description ("OAuth-authenticated") canceled a live turn
145
203
  // as "Not logged in" (luvira, 2026-10-08).
146
- const failure = seen.failure = attemptFailure(run);
147
- if (failure === "auth-failure" || failure === "inactivity-watchdog")
148
- return finalize(run, failure === "auth-failure" ? "Not logged in. Saved work is above." : "Stopped. Saved work is above.", { canceled: true });
149
- // A bridge failure shows its own reason (and stderr): the generic notice hides what needs repair.
150
- if (failure === "permanent") {
151
- const bridgeNotice = attemptDiagnostics({ run }, {}).match(MCP_BRIDGE_FAILURE)?.[0];
152
- return finalize(run, bridgeNotice ?? EXHAUSTED_NOTICE, { detail: bridgeNotice ? undefined : attemptFailureDetail({ run }, {}) });
153
- }
154
- return resolveMissing(run, probe, trigger);
204
+ const diagnostics = attemptDiagnostics({ run });
205
+ const failure = attemptFailure({ diagnostics });
206
+ const decision = failure === "retryable" ? await resolveMissing(run, probe, trigger) : await finalizeFailure({ run, failure, diagnostics });
207
+ return { probe, failure, decision };
155
208
  }
156
209
  async function resolveMissing(run, probe, trigger) {
157
210
  await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: true }, {});
@@ -160,16 +213,16 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
160
213
  // a newer attempt committed by another caller while this one was awaiting storage/HTTP.
161
214
  if (timed.runId !== run.runId)
162
215
  return "superseded";
216
+ // A dead process cannot resume its question. End this turn with the existing card intact,
217
+ // rather than minting an attempt that runs preflight and asks the same question again.
218
+ if (probe === "dead" && awaitingAnswer({ run: timed }))
219
+ return finalize(timed, AWAITING_ANSWER_NOTICE);
163
220
  const notice = budgetNotice(timed, ports.now());
164
- if (notice) {
165
- const detail = notice === EXHAUSTED_NOTICE
166
- ? attemptFailureDetail({ run: timed }, {}) ?? (timed.recoveryCount >= RECOVERY_LIMIT ? `Tried ${RECOVERY_LIMIT} times` : "Recovery window ended")
167
- : undefined;
168
- return finalize(run, notice, { detail });
169
- }
221
+ if (notice)
222
+ return finalize(run, notice, { detail: notice === EXHAUSTED_NOTICE ? exhaustedDetail({ run: timed }) : undefined });
170
223
  // A queued attempt may be between durable acceptance and the daemon's start transaction.
171
224
  // Give that idempotent launch time to appear before a 404 can advance its generation.
172
- if (probe === "dead" && (trigger === "attempt-failed" || ports.now() - run.attemptStartedAt >= 10_000))
225
+ if (continuesNow({ run, probe, trigger, now: ports.now() }))
173
226
  return continueDead(timed);
174
227
  ports.attach(run, {});
175
228
  return "waiting";
@@ -183,12 +236,11 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
183
236
  return "terminal";
184
237
  if (expectedRunId && expectedRunId !== run.runId)
185
238
  return "superseded";
186
- const seen = {};
187
- const decision = await decide(run, trigger, options.liveRunId, seen);
239
+ const { decision, probe, failure } = await decide(run, trigger, options.liveRunId);
188
240
  // The watcher re-confirms its own live run every resolution window; log only real recovery
189
241
  // decisions. Metadata only: diagnostics, answers and cancel reasons can hold secrets.
190
242
  if (options.liveRunId !== run.runId)
191
- logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe: seen.probe, decision, failureClass: seen.failure }));
243
+ logger.info("[durable-recovery] " + JSON.stringify({ runId: run.runId, trigger, probe, decision, failureClass: failure }));
192
244
  return decision;
193
245
  },
194
246
  };
@@ -1,5 +1,9 @@
1
1
  import { isTerminalRunStatus } from '../../core/messages.js';
2
2
  import { readSseFrames, runContentFromEvents, runEventsForSave, runInterruptedNotice, translateRunFrame } from '../../core/run-events/entry.js';
3
+ /** An error frame keeps a later successful end from settling as a success. */
4
+ function endStatus({ failed, terminal }, _optional = {}) {
5
+ return failed && terminal === "succeeded" ? "failed" : terminal;
6
+ }
3
7
  /** Follow replayable run streams and save through atomic host ledger operations.
4
8
  * The host retains authorization, owner scope, kernel transactions, boot reconciliation, HTTP
5
9
  * credentials and excluded run-ID prefixes. Replay starts at zero; error-before-success and
@@ -22,11 +26,14 @@ export function createAssistantRunFinalizer(args, options = {}) {
22
26
  catch { /* The host owns reporting failures. */ }
23
27
  }
24
28
  const active = new Map();
25
- async function settle(watch, status, events) {
26
- watch.terminal = true;
29
+ function clearTrailing(watch) {
27
30
  if (watch.trailing)
28
31
  scheduler.cancel({ handle: watch.trailing.handle });
29
32
  watch.trailing = undefined;
33
+ }
34
+ async function settle(watch, status, events) {
35
+ watch.terminal = true;
36
+ clearTrailing(watch);
30
37
  await args.ledger.settle({
31
38
  conversationId: watch.conversationId,
32
39
  messageId: watch.messageId,
@@ -57,9 +64,7 @@ export function createAssistantRunFinalizer(args, options = {}) {
57
64
  }
58
65
  return;
59
66
  }
60
- if (watch.trailing)
61
- scheduler.cancel({ handle: watch.trailing.handle });
62
- watch.trailing = undefined;
67
+ clearTrailing(watch);
63
68
  watch.checkpointedEvents = watch.events.length;
64
69
  watch.checkpointedAt = now();
65
70
  watch.checkpointPending = true;
@@ -77,29 +82,36 @@ export function createAssistantRunFinalizer(args, options = {}) {
77
82
  void checkpoint(watch);
78
83
  });
79
84
  }
80
- async function readStream(watch) {
85
+ /** The connection's event body, or why it cannot be followed. Non-success bodies are cancelled. */
86
+ async function openStream(watch) {
81
87
  const response = await daemon.openEvents({ runId: watch.runId, principalId: watch.principalId });
82
- if (response.status === 404 || !response.ok || !response.body) {
83
- await response.body?.cancel().catch(() => undefined);
84
- return response.status === 404 ? { kind: "gone" } : { kind: "dropped" };
85
- }
88
+ if (response.ok && response.body)
89
+ return { kind: "open", body: response.body };
90
+ await response.body?.cancel().catch(() => undefined);
91
+ return response.status === 404 ? { kind: "gone" } : { kind: "dropped" };
92
+ }
93
+ async function consumeFrames(watch, body) {
86
94
  // Each connection replays the complete history, rather than appending duplicate deltas.
87
95
  watch.events = [];
88
96
  watch.failed = false;
89
97
  const carry = {};
90
- for await (const frame of readSseFrames({ body: response.body })) {
98
+ for await (const frame of readSseFrames({ body })) {
91
99
  const outcome = translateRunFrame({ kind: frame.event, raw: frame.data, notices: args.notices }, { carry });
92
100
  watch.events.push(...outcome.events);
93
101
  if (outcome.error)
94
102
  watch.failed = true;
95
103
  if (outcome.terminal) {
96
104
  watch.terminal = true;
97
- return { kind: "ended", status: watch.failed && outcome.terminal === "succeeded" ? "failed" : outcome.terminal };
105
+ return { kind: "ended", status: endStatus({ failed: watch.failed, terminal: outcome.terminal }) };
98
106
  }
99
107
  void checkpoint(watch);
100
108
  }
101
109
  return { kind: "dropped" };
102
110
  }
111
+ async function readStream(watch) {
112
+ const opened = await openStream(watch);
113
+ return opened.kind === "open" ? consumeFrames(watch, opened.body) : opened;
114
+ }
103
115
  async function follow(watch) {
104
116
  for (let attempt = 0; attempt <= maxReconnects; attempt += 1) {
105
117
  const result = await readStream(watch).catch(() => ({ kind: "dropped" }));
@@ -140,9 +152,7 @@ export function createAssistantRunFinalizer(args, options = {}) {
140
152
  })
141
153
  .finally(() => {
142
154
  watch.terminal = true;
143
- if (watch.trailing)
144
- scheduler.cancel({ handle: watch.trailing.handle });
145
- watch.trailing = undefined;
155
+ clearTrailing(watch);
146
156
  active.delete(runId);
147
157
  });
148
158
  active.set(runId, { watch, done });
@@ -18,8 +18,8 @@ export declare function listConversations({ db, projectId }: {
18
18
  }): {
19
19
  latestRun: {
20
20
  durationMs?: number;
21
- endedAt?: number | undefined;
22
- startedAt?: number | undefined;
21
+ endedAt?: number;
22
+ startedAt?: number;
23
23
  status: string;
24
24
  } | undefined;
25
25
  totalDurationMs?: number;
@@ -41,13 +41,13 @@ export declare function getConversation({ db, id }: {
41
41
  db: SqliteDb;
42
42
  id: string;
43
43
  }): {
44
- totalDurationMs?: number;
45
44
  latestRun: {
46
45
  durationMs?: number;
47
- endedAt?: number | undefined;
48
- startedAt?: number | undefined;
46
+ endedAt?: number;
47
+ startedAt?: number;
49
48
  status: string;
50
49
  } | undefined;
50
+ totalDurationMs?: number;
51
51
  id: any;
52
52
  projectId: any;
53
53
  title: any;
@@ -75,13 +75,13 @@ export declare function insertConversation({ db, c }: {
75
75
  db: SqliteDb;
76
76
  c: DbRow;
77
77
  }): {
78
- totalDurationMs?: number;
79
78
  latestRun: {
80
79
  durationMs?: number;
81
- endedAt?: number | undefined;
82
- startedAt?: number | undefined;
80
+ endedAt?: number;
81
+ startedAt?: number;
83
82
  status: string;
84
83
  } | undefined;
84
+ totalDurationMs?: number;
85
85
  id: any;
86
86
  projectId: any;
87
87
  title: any;
@@ -102,13 +102,13 @@ export declare function updateConversation({ db, id, patch }: {
102
102
  id: string;
103
103
  patch: DbRow;
104
104
  }): {
105
- totalDurationMs?: number;
106
105
  latestRun: {
107
106
  durationMs?: number;
108
- endedAt?: number | undefined;
109
- startedAt?: number | undefined;
107
+ endedAt?: number;
108
+ startedAt?: number;
110
109
  status: string;
111
110
  } | undefined;
111
+ totalDurationMs?: number;
112
112
  id: any;
113
113
  projectId: any;
114
114
  title: any;