mixdog 1.4.0 → 1.4.1

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 (236) hide show
  1. package/package.json +1 -1
  2. package/scripts/check-voice-runtime-inputs.mjs +25 -0
  3. package/scripts/gate-local.mjs +12 -3
  4. package/scripts/latest-guard.mjs +21 -9
  5. package/scripts/lib/isolated-test-env.mjs +10 -5
  6. package/scripts/lib/run-node-tests.mjs +37 -37
  7. package/scripts/lib/scoped-temp.d.mts +16 -0
  8. package/scripts/lib/scoped-temp.mjs +123 -0
  9. package/scripts/lib/stale-temp.mjs +44 -0
  10. package/scripts/post-release-check.mjs +1 -6
  11. package/scripts/pptx-kit-index.mjs +17 -4
  12. package/scripts/suite-health.mjs +10 -1
  13. package/scripts/test-data-dir.mjs +10 -7
  14. package/src/app.mjs +4 -0
  15. package/src/defaults/shipped-history.json +5 -0
  16. package/src/defaults/skills/browser-use/SKILL.md +6 -4
  17. package/src/defaults/skills/computer-use/SKILL.md +4 -2
  18. package/src/lib/rules-builder.cjs +29 -12
  19. package/src/output-styles/common.md +2 -2
  20. package/src/output-styles/simple.md +1 -1
  21. package/src/rules/agent/AGENT.md +5 -4
  22. package/src/rules/lead/LEAD.md +10 -5
  23. package/src/rules/shared/10-tool-workflow.md +3 -1
  24. package/src/rules/shared/45-destructive.md +0 -2
  25. package/src/rules/shared/50-execution.md +2 -0
  26. package/src/rules/shared/70-delivery.md +7 -9
  27. package/src/runtime/agent/orchestrator/agent-runtime/agent-progress-watchdog.mjs +29 -6
  28. package/src/runtime/agent/orchestrator/cache-break-trace.mjs +2 -0
  29. package/src/runtime/agent/orchestrator/context/deferred-tools.mjs +1 -1
  30. package/src/runtime/agent/orchestrator/context/skill-catalog.mjs +3 -1
  31. package/src/runtime/agent/orchestrator/mcp/oauth-login.mjs +5 -2
  32. package/src/runtime/agent/orchestrator/providers/account-pool.mjs +11 -0
  33. package/src/runtime/agent/orchestrator/providers/anthropic-api-recovery.mjs +1 -1
  34. package/src/runtime/agent/orchestrator/providers/anthropic-api-request.mjs +7 -3
  35. package/src/runtime/agent/orchestrator/providers/anthropic-api-transport.mjs +8 -2
  36. package/src/runtime/agent/orchestrator/providers/anthropic-max-tokens-recovery.mjs +6 -2
  37. package/src/runtime/agent/orchestrator/providers/anthropic-messages.mjs +2 -2
  38. package/src/runtime/agent/orchestrator/providers/anthropic-oauth-request/initial-status.mjs +3 -1
  39. package/src/runtime/agent/orchestrator/providers/anthropic-oauth.mjs +14 -3
  40. package/src/runtime/agent/orchestrator/providers/anthropic-sse.mjs +6 -0
  41. package/src/runtime/agent/orchestrator/providers/anthropic-turn-result.mjs +2 -2
  42. package/src/runtime/agent/orchestrator/providers/command-code-oauth-login.mjs +1 -10
  43. package/src/runtime/agent/orchestrator/providers/cursor.mjs +3 -0
  44. package/src/runtime/agent/orchestrator/providers/devin-wire-transport.mjs +1 -1
  45. package/src/runtime/agent/orchestrator/providers/devin.mjs +3 -0
  46. package/src/runtime/agent/orchestrator/providers/grok-oauth-login.mjs +210 -20
  47. package/src/runtime/agent/orchestrator/providers/lib/anthropic-prefix-trace.mjs +76 -11
  48. package/src/runtime/agent/orchestrator/providers/lib/anthropic-request-utils.mjs +45 -6
  49. package/src/runtime/agent/orchestrator/providers/lib/first-output-window.mjs +58 -0
  50. package/src/runtime/agent/orchestrator/providers/lib/listen-on.mjs +14 -0
  51. package/src/runtime/agent/orchestrator/providers/mimo-oauth-login.mjs +1 -10
  52. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-send.mjs +1 -0
  53. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/chunk-events.mjs +9 -2
  54. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream/stream-state.mjs +7 -0
  55. package/src/runtime/agent/orchestrator/providers/openai-compat-chat-stream.mjs +14 -3
  56. package/src/runtime/agent/orchestrator/providers/openai-compat-responses-events.mjs +4 -2
  57. package/src/runtime/agent/orchestrator/providers/openai-compat-responses.mjs +1 -0
  58. package/src/runtime/agent/orchestrator/providers/openai-compat-stream-common.mjs +5 -3
  59. package/src/runtime/agent/orchestrator/providers/openai-compat-stream.mjs +16 -3
  60. package/src/runtime/agent/orchestrator/providers/openai-compat-xai-send.mjs +1 -0
  61. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events/output-items.mjs +5 -2
  62. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state/response-events.mjs +13 -3
  63. package/src/runtime/agent/orchestrator/providers/openai-http-sse-response-state.mjs +13 -2
  64. package/src/runtime/agent/orchestrator/providers/openai-http-sse-watchdogs.mjs +81 -42
  65. package/src/runtime/agent/orchestrator/providers/openai-oauth-http-sse.mjs +8 -0
  66. package/src/runtime/agent/orchestrator/providers/openai-oauth-login.mjs +136 -5
  67. package/src/runtime/agent/orchestrator/providers/openai-ws-stream/event-switch.mjs +20 -12
  68. package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +7 -1
  69. package/src/runtime/agent/orchestrator/providers/openai-ws-watchdogs.mjs +48 -22
  70. package/src/runtime/agent/orchestrator/providers/registry.mjs +12 -0
  71. package/src/runtime/agent/orchestrator/runtime-core/ask-user-tool-defs.mjs +41 -13
  72. package/src/runtime/agent/orchestrator/runtime-core/orchestration.mjs +7 -6
  73. package/src/runtime/agent/orchestrator/runtime-core/session-text.mjs +7 -3
  74. package/src/runtime/agent/orchestrator/runtime-core/tool-defs.mjs +3 -1
  75. package/src/runtime/agent/orchestrator/runtime-core/workflow.mjs +5 -2
  76. package/src/runtime/agent/orchestrator/session/cache/scoped-cache.mjs +1 -3
  77. package/src/runtime/agent/orchestrator/session/cache-warm.mjs +8 -2
  78. package/src/runtime/agent/orchestrator/session/catalog-db/schema.mjs +4 -1
  79. package/src/runtime/agent/orchestrator/session/compact/execution-tail.mjs +1 -8
  80. package/src/runtime/agent/orchestrator/session/compact/messages.mjs +23 -0
  81. package/src/runtime/agent/orchestrator/session/compact/observation-supersession.mjs +103 -23
  82. package/src/runtime/agent/orchestrator/session/compact/runner.mjs +160 -36
  83. package/src/runtime/agent/orchestrator/session/context-utils.mjs +13 -1
  84. package/src/runtime/agent/orchestrator/session/conversation-import/claude.mjs +163 -22
  85. package/src/runtime/agent/orchestrator/session/conversation-import/codex.mjs +132 -17
  86. package/src/runtime/agent/orchestrator/session/conversation-import/convert.mjs +38 -7
  87. package/src/runtime/agent/orchestrator/session/conversation-import/imported-session.mjs +3 -0
  88. package/src/runtime/agent/orchestrator/session/conversation-import/turns.mjs +90 -22
  89. package/src/runtime/agent/orchestrator/session/loop/compact-policy.mjs +33 -3
  90. package/src/runtime/agent/orchestrator/session/loop/fresh-context.mjs +7 -2
  91. package/src/runtime/agent/orchestrator/session/loop/pre-dispatch-deny.mjs +1 -1
  92. package/src/runtime/agent/orchestrator/session/loop/provider-tool-exclusions.mjs +13 -5
  93. package/src/runtime/agent/orchestrator/session/loop/route-fallback.mjs +14 -0
  94. package/src/runtime/agent/orchestrator/session/loop/send-phase.mjs +13 -1
  95. package/src/runtime/agent/orchestrator/session/loop/tool-exec/before-hook.mjs +2 -2
  96. package/src/runtime/agent/orchestrator/session/loop/tool-exposure-migration.mjs +39 -21
  97. package/src/runtime/agent/orchestrator/session/manager/ask-turn-loop-options.mjs +3 -3
  98. package/src/runtime/agent/orchestrator/session/manager/ask-turn-settle.mjs +3 -1
  99. package/src/runtime/agent/orchestrator/session/manager/owner-route-fallbacks.mjs +2 -2
  100. package/src/runtime/agent/orchestrator/session/manager/route-fallback-chain.mjs +2 -1
  101. package/src/runtime/agent/orchestrator/session/manager/rules-cache.mjs +27 -9
  102. package/src/runtime/agent/orchestrator/session/manager/runtime-liveness.mjs +61 -6
  103. package/src/runtime/agent/orchestrator/session/manager/session-lifecycle.mjs +19 -8
  104. package/src/runtime/agent/orchestrator/session/manager/session-prompt-composition.mjs +12 -5
  105. package/src/runtime/agent/orchestrator/session/manager/tool-resolution.mjs +3 -3
  106. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint-context.mjs +4 -0
  107. package/src/runtime/agent/orchestrator/session/manager/turn-checkpoint.mjs +3 -0
  108. package/src/runtime/agent/orchestrator/session/manager/turn-interruption.mjs +9 -0
  109. package/src/runtime/agent/orchestrator/session/pre-send-compact.mjs +5 -1
  110. package/src/runtime/agent/orchestrator/session/prompt-cache-touch-store.mjs +30 -7
  111. package/src/runtime/agent/orchestrator/session/restart-prefix-parity.fixture.mjs +154 -0
  112. package/src/runtime/agent/orchestrator/session/store/session-delete.mjs +7 -0
  113. package/src/runtime/agent/orchestrator/session/store-summary-catalog.mjs +4 -0
  114. package/src/runtime/agent/orchestrator/session/store-summary-fields.mjs +15 -0
  115. package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +37 -8
  116. package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +9 -1
  117. package/src/runtime/agent/orchestrator/session/transcript-restore/restore.mjs +61 -2
  118. package/src/runtime/agent/orchestrator/stall-policy.mjs +23 -0
  119. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/invocation-plan.mjs +2 -2
  120. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool/spawn-env.mjs +4 -2
  121. package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +6 -1
  122. package/src/runtime/agent/orchestrator/tools/builtin/builtin-tools.mjs +46 -20
  123. package/src/runtime/agent/orchestrator/tools/builtin/cache-layers.mjs +7 -1
  124. package/src/runtime/agent/orchestrator/tools/builtin/git-command-tool.mjs +31 -5
  125. package/src/runtime/agent/orchestrator/tools/builtin/git-worktree-tool.mjs +22 -8
  126. package/src/runtime/agent/orchestrator/tools/builtin/lib/scoped-search-flight.mjs +9 -5
  127. package/src/runtime/agent/orchestrator/tools/builtin/read-single-stream.mjs +7 -1
  128. package/src/runtime/agent/orchestrator/tools/builtin/read-streaming.mjs +6 -3
  129. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-dispatch.mjs +1 -1
  130. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-publication.mjs +23 -12
  131. package/src/runtime/agent/orchestrator/tools/builtin/read-tool/batch-snapshot.mjs +6 -1
  132. package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +1 -8
  133. package/src/runtime/agent/orchestrator/tools/code-graph/dispatch/federation.mjs +3 -1
  134. package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -1
  135. package/src/runtime/agent/orchestrator/tools/graph-manifest.json +13 -13
  136. package/src/runtime/agent/orchestrator/tools/lib/isolated-test-env.mjs +4 -3
  137. package/src/runtime/agent/orchestrator/tools/lib/shell-warm-standby.mjs +26 -4
  138. package/src/runtime/channels/lib/webhook/signature.mjs +4 -1
  139. package/src/runtime/channels/lib/whisper-port.mjs +10 -1
  140. package/src/runtime/computer-bridge/action-schema.mjs +2 -2
  141. package/src/runtime/computer-bridge/core-actions.mjs +2 -2
  142. package/src/runtime/computer-bridge/error-recovery.mjs +5 -4
  143. package/src/runtime/computer-bridge/linux-action-capabilities.mjs +24 -5
  144. package/src/runtime/computer-bridge/linux-readiness.d.mts +7 -2
  145. package/src/runtime/local-provider/server-process.mjs +2 -1
  146. package/src/runtime/media/adapters/gemini-video.mjs +4 -1
  147. package/src/runtime/media/adapters/xai-media.mjs +2 -1
  148. package/src/runtime/memory/lib/embedding-worker.mjs +6 -3
  149. package/src/runtime/memory/lib/memory-action-handlers/cycle-backfill-action.mjs +1 -7
  150. package/src/runtime/memory/lib/memory-chunk-quality.mjs +1 -6
  151. package/src/runtime/office/authoring/pdf-author-action.mjs +1 -6
  152. package/src/runtime/office/design/design-art-direction.mjs +29 -27
  153. package/src/runtime/office/pdf/pdf-writer.mjs +6 -1
  154. package/src/runtime/office/portable/portable-snapshot-docx.mjs +7 -1
  155. package/src/runtime/office/portable/portable-snapshot-shared.mjs +6 -1
  156. package/src/runtime/office/quality/assurance-rendered.mjs +9 -2
  157. package/src/runtime/office/quality/assurance-structure-docx.mjs +3 -1
  158. package/src/runtime/office/quality/design-review.mjs +1 -5
  159. package/src/runtime/office/quality/document-brief.mjs +1 -5
  160. package/src/runtime/office/quality/forced-break-pages.mjs +9 -6
  161. package/src/runtime/office/quality/quality-pipeline.mjs +1 -1
  162. package/src/runtime/shared/bot-owned-work.mjs +56 -0
  163. package/src/runtime/shared/defaults-separation.mjs +1 -4
  164. package/src/runtime/shared/fetch-safe-listen.d.mts +16 -0
  165. package/src/runtime/shared/fetch-safe-listen.mjs +50 -0
  166. package/src/runtime/shared/handoff-artifacts.d.mts +2 -1
  167. package/src/runtime/shared/handoff-artifacts.mjs +18 -8
  168. package/src/runtime/shared/idle-auto-clear-gate.mjs +29 -7
  169. package/src/runtime/shared/llm/cost.mjs +5 -1
  170. package/src/runtime/shared/loopback-listener.mjs +25 -2
  171. package/src/runtime/shared/model-route-chain.d.mts +7 -2
  172. package/src/runtime/shared/model-route-chain.mjs +22 -4
  173. package/src/runtime/shared/open-url.mjs +10 -0
  174. package/src/runtime/shared/session-activity-signal.mjs +14 -0
  175. package/src/runtime/shared/session-scratch.mjs +37 -0
  176. package/src/runtime/shared/tool-card-model/agent-surface.mjs +1 -3
  177. package/src/runtime/shared/tool-card-model/card-status.mjs +15 -2
  178. package/src/runtime/shared/tool-result-summary.mjs +1 -1
  179. package/src/runtime/shared/tool-surface.mjs +3 -0
  180. package/src/runtime/shared/tool-work-units.mjs +2 -0
  181. package/src/runtime/shared/turn-snapshot-store.mjs +13 -6
  182. package/src/runtime/shared/worktrees/contract.mjs +4 -1
  183. package/src/runtime/shared/worktrees/git.mjs +15 -0
  184. package/src/runtime/shared/worktrees/manager.mjs +87 -38
  185. package/src/runtime/shared/worktrees/test-support.mjs +6 -10
  186. package/src/runtime/visualize-bridge/client.mjs +2 -0
  187. package/src/session-runtime/agent-home-delivery.mjs +41 -0
  188. package/src/session-runtime/agent-task-sessions.mjs +152 -63
  189. package/src/session-runtime/agent-tool-routing.mjs +8 -41
  190. package/src/session-runtime/boot/tools.mjs +5 -1
  191. package/src/session-runtime/bot-status.mjs +12 -6
  192. package/src/session-runtime/bot-target.mjs +38 -0
  193. package/src/session-runtime/channel-config-api.mjs +6 -1
  194. package/src/session-runtime/conversation-import/codex-titles.mjs +88 -0
  195. package/src/session-runtime/conversation-import/discovery.mjs +271 -25
  196. package/src/session-runtime/conversation-import/service.mjs +26 -5
  197. package/src/session-runtime/handoff-attachments.mjs +82 -31
  198. package/src/session-runtime/internal-tool-executor/ask-user-tool.mjs +12 -13
  199. package/src/session-runtime/internal-tool-executor/bridge-tools.mjs +1 -0
  200. package/src/session-runtime/internal-tool-executor/reaction-tool.mjs +4 -1
  201. package/src/session-runtime/internal-tool-executor/workspace-tools.mjs +8 -2
  202. package/src/session-runtime/lifecycle/cold-heir.mjs +11 -6
  203. package/src/session-runtime/lifecycle/session-catalog.mjs +9 -1
  204. package/src/session-runtime/model-route/set-route.mjs +11 -2
  205. package/src/session-runtime/provider-catalog-cache.mjs +82 -36
  206. package/src/session-runtime/provider-models/catalog-load.mjs +23 -6
  207. package/src/session-runtime/provider-models.mjs +4 -2
  208. package/src/session-runtime/resource-recall-api.mjs +6 -1
  209. package/src/session-runtime/runtime-feature-gates.mjs +5 -1
  210. package/src/session-runtime/services/agent-tool/execute.mjs +14 -9
  211. package/src/session-runtime/services/agent-tool/job-views/agent-list.mjs +20 -4
  212. package/src/session-runtime/services/agent-tool/model-select.mjs +48 -7
  213. package/src/session-runtime/services/agent-tool/render.mjs +71 -17
  214. package/src/session-runtime/services/agent-tool/spawn-preset.mjs +6 -2
  215. package/src/session-runtime/services/agent-tool/tool-def.mjs +9 -9
  216. package/src/session-runtime/services/provider-admin.mjs +2 -1
  217. package/src/session-runtime/tool-policy/policy-stage.mjs +1 -0
  218. package/src/session-runtime/workflow-agents-api/agent-editor.mjs +5 -1
  219. package/src/session-runtime/workflow-agents-api/agent-route.mjs +15 -4
  220. package/src/session-runtime/workflow-agents-api/onboarding.mjs +3 -3
  221. package/src/session-runtime/workspace/transitions.mjs +25 -0
  222. package/src/session-runtime/workspace/worktree-tool.mjs +26 -9
  223. package/src/standalone/daemon-session-control.mjs +18 -2
  224. package/src/standalone/session-service/agent-tree/agent-turns.mjs +16 -12
  225. package/src/standalone/session-service/session-calls/session-action.mjs +10 -3
  226. package/src/standalone/session-transport/client-registry/client-grace.mjs +12 -9
  227. package/src/tui/app/app-view/floating-panel.jsx +7 -4
  228. package/src/tui/app/model-picker/route-selection/fast-parameters.mjs +1 -3
  229. package/src/tui/app/model-picker/route-selection.mjs +5 -6
  230. package/src/tui/app/provider-setup/oauth-actions.mjs +38 -20
  231. package/src/tui/app/provider-setup/provider-items.mjs +8 -2
  232. package/src/tui/app/provider-setup-picker.mjs +9 -5
  233. package/src/tui/components/QuestionPanel.jsx +13 -4
  234. package/src/tui/dist/index.mjs +75 -28
  235. package/src/tui/session/session-flow/queue/take-entries.mjs +11 -0
  236. package/src/ui/statusline-agents.mjs +1 -3
@@ -13,8 +13,9 @@ import { randomBytes } from 'node:crypto';
13
13
  import { createOAuthPkce, parseOAuthCodeInput } from './lib/oauth-pkce.mjs';
14
14
  import { OAUTH_PAGE_CONTENT_TYPE, oauthErrorHtml, oauthSuccessHtml } from './lib/oauth-page.mjs';
15
15
  import { openLoginUrl } from './lib/open-login-url.mjs';
16
+ import { sleepWithAbort } from './retry-backoff.mjs';
16
17
 
17
- const TOKEN_URL = 'https://auth.openai.com/oauth/token';
18
+ const DEFAULT_ISSUER = 'https://auth.openai.com';
18
19
  const AUTHORIZE_URL = 'https://auth.openai.com/oauth/authorize';
19
20
  const CODEX_OAUTH_SCOPE = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
20
21
  const CALLBACK_HOST = '127.0.0.1';
@@ -23,6 +24,9 @@ const CALLBACK_PATH = '/auth/callback';
23
24
  const REDIRECT_URI = `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`;
24
25
  const LOGIN_TIMEOUT_MS = 5 * 60_000;
25
26
  const TOKEN_TIMEOUT_MS = 30_000;
27
+ // Codex device-code login: the user has 15 minutes to enter the code.
28
+ const DEVICE_LOGIN_TIMEOUT_MS = 15 * 60_000;
29
+ const DEFAULT_DEVICE_INTERVAL_S = 5;
26
30
 
27
31
  function _scrubOAuthLoginBody(text) {
28
32
  return String(text || '')
@@ -40,21 +44,41 @@ function _scrubOAuthLoginBody(text) {
40
44
  * @param {(...tokens:string[])=>(object|null)} deps.identityFromTokens
41
45
  * @param {(token:string)=>number} deps.expiryFromAccessToken
42
46
  * @param {(tokens:object)=>void} deps.saveTokens
47
+ * @param {string} [deps.issuer] auth server base URL (tests point it at a fake server)
48
+ * @param {(ms:number, signal?:AbortSignal)=>Promise<void>} [deps.sleep] device-poll wait (tests inject a fast one)
49
+ * @param {()=>number} [deps.now]
50
+ * @param {number} [deps.deviceTimeoutMs] device-code login deadline (tests shorten it)
51
+ * @param {number} [deps.requestTimeoutMs] deadline of the device-code start request (tests shorten it)
43
52
  */
44
53
  export function createOpenAIOAuthLogin(deps) {
45
- const { clientId, originator, extractAccountId, identityFromTokens, expiryFromAccessToken, saveTokens } = deps;
54
+ const {
55
+ clientId,
56
+ originator,
57
+ extractAccountId,
58
+ identityFromTokens,
59
+ expiryFromAccessToken,
60
+ saveTokens,
61
+ issuer = DEFAULT_ISSUER,
62
+ sleep = sleepWithAbort,
63
+ now = Date.now,
64
+ deviceTimeoutMs = DEVICE_LOGIN_TIMEOUT_MS,
65
+ requestTimeoutMs = TOKEN_TIMEOUT_MS,
66
+ } = deps;
67
+ const issuerBase = issuer.replace(/\/+$/, '');
68
+ const tokenUrl = `${issuerBase}/oauth/token`;
69
+ const deviceApiBase = `${issuerBase}/api/accounts`;
46
70
 
47
- async function exchangeAuthorizationCode({ pkce, code, signal = null }) {
71
+ async function exchangeAuthorizationCode({ pkce, code, redirectUri = REDIRECT_URI, signal = null }) {
48
72
  signal?.throwIfAborted();
49
73
  const cleanCode = String(code || '').trim();
50
74
  if (!cleanCode) throw new Error('[openai-oauth] authorization code is required');
51
- const tokenRes = await fetch(TOKEN_URL, {
75
+ const tokenRes = await fetch(tokenUrl, {
52
76
  method: 'POST',
53
77
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
54
78
  body: new URLSearchParams({
55
79
  grant_type: 'authorization_code',
56
80
  code: cleanCode,
57
- redirect_uri: REDIRECT_URI,
81
+ redirect_uri: redirectUri,
58
82
  client_id: clientId,
59
83
  code_verifier: pkce.verifier,
60
84
  }),
@@ -87,7 +111,114 @@ export function createOpenAIOAuthLogin(deps) {
87
111
  return tokens;
88
112
  }
89
113
 
114
+ const postJson = (path, body, signal) =>
115
+ fetch(`${deviceApiBase}${path}`, {
116
+ method: 'POST',
117
+ headers: { 'Content-Type': 'application/json' },
118
+ body: JSON.stringify(body),
119
+ redirect: 'error',
120
+ signal,
121
+ });
122
+
123
+ // Codex device-code flow (`codex login --device-auth`): the user enters a
124
+ // short code at the verification URL on any machine; this host polls until
125
+ // the server hands back an authorization code, then exchanges it with the
126
+ // deviceauth redirect URI. Resolves null when the server has no device flow.
127
+ async function beginDeviceLogin() {
128
+ // No login deadline or cancel handle exists yet, so the start request
129
+ // carries its own timeout; it also bounds reading the response body.
130
+ const startSignal = AbortSignal.timeout(requestTimeoutMs);
131
+ const res = await postJson('/deviceauth/usercode', { client_id: clientId }, startSignal);
132
+ if (res.status === 404) return null;
133
+ if (!res.ok) throw new Error(`[openai-oauth] device code request failed with status ${res.status}`);
134
+ const started = await res.json();
135
+ const userCode = started.user_code || started.usercode;
136
+ const deviceAuthId = started.device_auth_id;
137
+ if (!userCode || !deviceAuthId)
138
+ throw new Error('[openai-oauth] device code response missing user_code or device_auth_id');
139
+ const interval = Number(String(started.interval ?? '').trim());
140
+ const intervalMs = (Number.isFinite(interval) && interval > 0 ? interval : DEFAULT_DEVICE_INTERVAL_S) * 1000;
141
+ const url = `${issuerBase}/codex/device`;
142
+
143
+ const controller = new AbortController();
144
+ const { signal } = controller;
145
+ const timedOut = () => new Error('[openai-oauth] device auth timed out after 15 minutes');
146
+ // One poll, aborted at the login deadline so a stalled request cannot
147
+ // outlive it. A reply that arrives in time is honoured whatever the clock
148
+ // says afterwards (the user did authorize).
149
+ const pollOnce = async (left) => {
150
+ const deadlineController = new AbortController();
151
+ const timer = setTimeout(() => deadlineController.abort(), left);
152
+ try {
153
+ const poll = await postJson(
154
+ '/deviceauth/token',
155
+ { device_auth_id: deviceAuthId, user_code: userCode },
156
+ AbortSignal.any([signal, deadlineController.signal])
157
+ );
158
+ if (poll.ok) return { granted: await poll.json() };
159
+ // 403/404: the user has not approved the code yet.
160
+ if (poll.status !== 403 && poll.status !== 404) {
161
+ throw new Error(`[openai-oauth] device auth failed with status ${poll.status}`);
162
+ }
163
+ return { granted: null };
164
+ } catch (err) {
165
+ if (!signal.aborted && deadlineController.signal.aborted) throw timedOut();
166
+ throw err;
167
+ } finally {
168
+ clearTimeout(timer);
169
+ }
170
+ };
171
+ const pollForCode = async () => {
172
+ const deadline = now() + deviceTimeoutMs;
173
+ for (;;) {
174
+ const left = deadline - now();
175
+ if (left <= 0) throw timedOut();
176
+ const { granted } = await pollOnce(left);
177
+ if (granted) return granted;
178
+ await sleep(Math.min(intervalMs, Math.max(0, deadline - now())), signal);
179
+ }
180
+ };
181
+ const waitForCallback = (async () => {
182
+ try {
183
+ const granted = await pollForCode();
184
+ return await exchangeAuthorizationCode({
185
+ pkce: { verifier: granted.code_verifier },
186
+ code: granted.authorization_code,
187
+ redirectUri: `${issuerBase}/deviceauth/callback`,
188
+ signal,
189
+ });
190
+ } catch (err) {
191
+ // A cancelled login settles empty, as the browser flow does.
192
+ if (signal.aborted) return null;
193
+ throw err;
194
+ }
195
+ })();
196
+ // A cancelled or expired login may reject before a host attaches a listener.
197
+ waitForCallback.catch(() => {});
198
+ process.stderr.write(
199
+ `\n[openai-oauth] To log in to ChatGPT (OpenAI OAuth), open:\n${url}\nand enter this code: ${userCode}\n\n`
200
+ );
201
+ return {
202
+ provider: 'openai-oauth',
203
+ url,
204
+ userCode,
205
+ waitForCallback,
206
+ cancel: () => controller.abort(),
207
+ };
208
+ }
209
+
210
+ // Without a browser on this host (headless/SSH) the device-code flow is used;
211
+ // like Codex, a server that has none (404) falls back to the browser flow.
90
212
  async function beginOAuthLogin({ openBrowser = true } = {}) {
213
+ if (!openBrowser) {
214
+ const device = await beginDeviceLogin();
215
+ if (device) return device;
216
+ process.stderr.write('\n[openai-oauth] Device code login is not enabled; falling back to browser login.\n');
217
+ }
218
+ return await beginBrowserLogin({ openBrowser });
219
+ }
220
+
221
+ async function beginBrowserLogin({ openBrowser }) {
91
222
  const pkce = createOAuthPkce(64);
92
223
  const state = randomBytes(16).toString('hex');
93
224
  const url = new URL(AUTHORIZE_URL);
@@ -27,11 +27,22 @@ export function createWsEventSwitch({
27
27
  // already cleared the pre-stream watchdog and armed the single idle timer.
28
28
  // response.created is a MEANINGFUL frame, so it also satisfies the
29
29
  // first-meaningful watchdog (keepalive/metadata frames never reach here,
30
- // so they never clear it). Semantic idle is not armed until actual model
31
- // progress; transport-only reasoning heartbeats are bounded by the outer
32
- // first-visible ceiling instead.
30
+ // so they never clear it). It is still metadata, not model output: it
31
+ // neither ends the first-output phase nor arms the semantic idle, and the
32
+ // agent sees it as transport.
33
33
  watchdogs.clearFirstMeaningful();
34
- progress('semantic');
34
+ progress('transport');
35
+ }
36
+
37
+ // A tool item starting is model output: it resets the semantic-idle timer so
38
+ // long server-side tool latency after item-added (before any arg delta) is
39
+ // not mistaken for a silent stall. An empty reasoning or message item is
40
+ // progress only once the model has produced output; before that the silent
41
+ // thinking follows it.
42
+ function onItemAdded() {
43
+ const kind = response.toolInFlight ? 'tool' : watchdogs.semanticSeen() ? 'semantic' : null;
44
+ if (kind) watchdogs.bumpSemantic();
45
+ progress(kind ?? 'transport');
35
46
  }
36
47
 
37
48
  function onOutputTextDelta(delta) {
@@ -84,11 +95,7 @@ export function createWsEventSwitch({
84
95
  case 'response.output_item.added':
85
96
  response.noteItemAdded(event.item);
86
97
  reportStreamItemStart(onStreamItemStart, responsesItemStart(event.item));
87
- // Item lifecycle is genuine progress: reset the semantic-idle timer so
88
- // long server-side tool latency after item-added (before any arg
89
- // delta) is not mistaken for a silent stall.
90
- watchdogs.resetSemantic();
91
- progress(response.toolInFlight ? 'tool' : 'semantic');
98
+ onItemAdded();
92
99
  break;
93
100
  case 'response.function_call_arguments.delta':
94
101
  case 'response.custom_tool_call_input.delta':
@@ -115,9 +122,10 @@ export function createWsEventSwitch({
115
122
  const kind = response.completeOutputItem(event.item);
116
123
  // A stream that names the phase only on the finished message.
117
124
  if (event.item?.type === 'message') reportStreamItemStart(onStreamItemStart, responsesItemStart(event.item));
118
- // Item-done is genuine lifecycle progress — reset semantic idle so
119
- // latency before the next item/args does not stall.
120
- watchdogs.resetSemantic();
125
+ // A completed item (reasoning, message, tool call) is model output —
126
+ // it ends the first-output phase and resets semantic idle so latency
127
+ // before the next item/args does not stall.
128
+ watchdogs.bumpSemantic();
121
129
  progress(kind);
122
130
  break;
123
131
  }
@@ -24,11 +24,13 @@ import {
24
24
  PROVIDER_SSE_IDLE_WATCHDOG_ENABLED,
25
25
  PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS,
26
26
  PROVIDER_WS_SEMANTIC_IDLE_TIMEOUT_MS,
27
+ providerFirstOutputTimeoutMs,
27
28
  } from '../stall-policy.mjs';
28
29
  import { _wsErrLabel, WS_MAX_INCOMING_FRAME_BYTES } from './openai-ws-pool.mjs';
29
30
  import { createWsResponseState } from './openai-ws-response-state.mjs';
30
31
  import { createWsTextRelay } from './openai-ws-text-relay.mjs';
31
32
  import { createWsStreamWatchdogs } from './openai-ws-watchdogs.mjs';
33
+ import { streamFirstOutputWindow } from './lib/first-output-window.mjs';
32
34
  import {
33
35
  enableSessionTransportTracking,
34
36
  disableSessionTransportTracking,
@@ -85,6 +87,7 @@ function resolveStreamTimeouts(_timeouts) {
85
87
  interChunkMs: _positiveInt(_timeouts?.interChunkMs, WS_INTER_CHUNK_MS),
86
88
  maxIncomingFrameBytes: _positiveInt(_timeouts?.maxIncomingFrameBytes, WS_MAX_INCOMING_FRAME_BYTES),
87
89
  firstMeaningfulMs: _positiveInt(_timeouts?.firstMeaningfulMs, PROVIDER_WS_FIRST_MEANINGFUL_TIMEOUT_MS),
90
+ firstOutputMs: _positiveInt(_timeouts?.firstOutputMs, providerFirstOutputTimeoutMs(semanticIdleMs)),
88
91
  semanticIdleMs,
89
92
  semanticIdleEnabled: PROVIDER_SSE_IDLE_WATCHDOG_ENABLED && semanticIdleMs > 0,
90
93
  };
@@ -112,7 +115,8 @@ export async function _streamResponse({
112
115
  if (socket?.readyState === WebSocket.OPEN) {
113
116
  markSessionTransportActivity(state?.sessionId);
114
117
  }
115
- const { maxIncomingFrameBytes, ...timeouts } = resolveStreamTimeouts(_timeouts);
118
+ const { maxIncomingFrameBytes, firstOutputMs, ...timeouts } = resolveStreamTimeouts(_timeouts);
119
+ const firstOutput = streamFirstOutputWindow({ sessionId: state?.sessionId, onStreamDelta, timeoutMs: firstOutputMs });
116
120
  const streamingStart = Date.now();
117
121
  // Mid-stream retry classifier needs to distinguish "stream died before we
118
122
  // even saw response.created" from "stream died after we had a partial
@@ -182,6 +186,7 @@ export async function _streamResponse({
182
186
  streamingStart,
183
187
  currentModel: () => response.model,
184
188
  timeouts,
189
+ firstOutput,
185
190
  attachPartialState,
186
191
  fail,
187
192
  });
@@ -256,6 +261,7 @@ export async function _streamResponse({
256
261
  if (!handlers.attach()) return;
257
262
  watchdogs.armPreStream();
258
263
  watchdogs.armFirstMeaningful();
264
+ watchdogs.armFirstOutput();
259
265
  // No proactive client ping: Codex's Responses WebSocket uses the library
260
266
  // default (automatic pong replies, no periodic client ping). Incoming
261
267
  // frames remain bounded by the 300s stream idle timeout.
@@ -1,14 +1,18 @@
1
1
  /**
2
- * openai-ws-watchdogs.mjs — the four timers that bound one Responses WS
2
+ * openai-ws-watchdogs.mjs — the five timers that bound one Responses WS
3
3
  * stream, and what each one proves when it fires:
4
4
  *
5
5
  * pre-stream — no server event at all after response.create (a wedged
6
6
  * post-upgrade socket); cleared by ANY frame.
7
7
  * first-meaningful — no response.created / content / tool-arg delta, even
8
8
  * while keepalive frames keep arriving; cleared once.
9
+ * first-output — no semantic output (reasoning, text, tool call) within
10
+ * the first-output window; metadata, empty items and
11
+ * keepalives never clear or extend it; cleared once.
9
12
  * inter-chunk — byte silence mid-stream; reset by EVERY frame.
10
- * semantic idle — deltas then silence (keepalives only); reset only by
11
- * meaningful output and item lifecycle.
13
+ * semantic idle — deltas then silence (keepalives only); armed by the
14
+ * first semantic output, then reset by meaningful output
15
+ * and item lifecycle.
12
16
  *
13
17
  * Every expiry hands a tagged error to `fail`, which owns terminalError, the
14
18
  * socket close and the settle; the timers never touch the Promise.
@@ -23,6 +27,8 @@ import { streamStalledError } from '../stall-policy.mjs';
23
27
  * @param {number} deps.streamingStart
24
28
  * @param {() => string} deps.currentModel
25
29
  * @param {{ preResponseCreatedMs: number, firstMeaningfulMs: number, interChunkMs: number, semanticIdleMs: number, semanticIdleEnabled: boolean }} deps.timeouts
30
+ * @param {ReturnType<typeof import('./lib/first-output-window.mjs').streamFirstOutputWindow>} deps.firstOutput
31
+ * the logical request's first-output window
26
32
  * @param {(err: Error) => Error} deps.attachPartialState
27
33
  * @param {(err: Error, closeReason: string) => void} deps.fail
28
34
  */
@@ -32,6 +38,7 @@ export function createWsStreamWatchdogs({
32
38
  streamingStart,
33
39
  currentModel,
34
40
  timeouts,
41
+ firstOutput,
35
42
  attachPartialState,
36
43
  fail,
37
44
  }) {
@@ -90,23 +97,25 @@ export function createWsStreamWatchdogs({
90
97
  fail(err, 'inter_chunk_timeout');
91
98
  }, interChunkMs);
92
99
  };
100
+ // A semantic stall (first-output or idle window): partial-final recovery
101
+ // attaches the streamed partial state so a wedged FINAL no-tool summary can
102
+ // be accepted as partial-final success by the loop. pendingToolUse gates
103
+ // out mid-flight tools.
104
+ const failStalled = (event, timeoutMs) => {
105
+ traceWsTimeout(event, timeoutMs);
106
+ const err = streamStalledError('Responses WS', timeoutMs, {
107
+ emittedToolCall: !!midState?.emittedToolCall,
108
+ });
109
+ attachPartialState(err);
110
+ try {
111
+ err.wsCloseCode = 4000;
112
+ } catch {}
113
+ fail(err, event);
114
+ };
93
115
  const resetSemanticIdle = () => {
94
116
  if (!semanticIdleEnabled) return;
95
117
  if (semanticIdleTimer) clearTimeout(semanticIdleTimer);
96
- semanticIdleTimer = setTimeout(() => {
97
- traceWsTimeout('semantic_idle_timeout', semanticIdleMs);
98
- const err = streamStalledError('Responses WS', semanticIdleMs, {
99
- emittedToolCall: !!midState?.emittedToolCall,
100
- });
101
- // Partial-final recovery: attach streamed partial state so a wedged
102
- // FINAL no-tool summary can be accepted as partial-final success by
103
- // the loop. pendingToolUse gates out mid-flight tools.
104
- attachPartialState(err);
105
- try {
106
- err.wsCloseCode = 4000;
107
- } catch {}
108
- fail(err, 'semantic_idle_timeout');
109
- }, semanticIdleMs);
118
+ semanticIdleTimer = setTimeout(() => failStalled('semantic_idle_timeout', semanticIdleMs), semanticIdleMs);
110
119
  try {
111
120
  semanticIdleTimer.unref?.();
112
121
  } catch {}
@@ -164,24 +173,41 @@ export function createWsStreamWatchdogs({
164
173
  firstMeaningfulTimer.unref?.();
165
174
  } catch {}
166
175
  },
176
+ /** Fires if the logical request produces no semantic output by its
177
+ * first-output deadline. Metadata (response.created / in_progress), empty
178
+ * item lifecycle and keepalive frames never clear or extend it, and the
179
+ * shorter semantic idle does not run before the first output, so a model
180
+ * may think silently past the idle window. A request that already had
181
+ * output has none: its stream runs under the semantic idle from its
182
+ * first frame (resetIdle). */
183
+ armFirstOutput() {
184
+ if (!firstOutput.outputSeen && semanticIdleEnabled && firstOutput.timeoutMs > 0) {
185
+ firstOutput.arm(() => failStalled('first_output_timeout', firstOutput.timeoutMs));
186
+ }
187
+ },
167
188
  /** Called on EVERY parsed server event: any frame proves the socket is
168
189
  * live. Also clears the pre-stream watchdog in case the first event is
169
- * not response.created. */
190
+ * not response.created, and starts the semantic idle (once) for a
191
+ * request that already had output. */
170
192
  resetIdle() {
171
193
  clearPreStream();
172
194
  resetInterChunk();
195
+ if (firstOutput.outputSeen && !semanticIdleTimer) resetSemanticIdle();
173
196
  },
174
- /** Meaningful-output progress: text/reasoning/tool-arg deltas. The
175
- * first one also satisfies the first-meaningful watchdog. */
197
+ /** Meaningful-output progress: text/reasoning/tool output. The first one
198
+ * also satisfies the first-meaningful watchdog and ends the first-output
199
+ * phase. */
176
200
  bumpSemantic() {
177
201
  clearFirstMeaningful();
202
+ firstOutput.noteOutput();
178
203
  resetSemanticIdle();
179
204
  },
180
- /** Item lifecycle progress (added/done) without a delta. */
181
- resetSemantic: resetSemanticIdle,
205
+ /** Whether the model has produced semantic output on this stream. */
206
+ semanticSeen: () => firstOutput.outputSeen,
182
207
  /** response.created is meaningful on its own. */
183
208
  clearFirstMeaningful,
184
209
  clearAll() {
210
+ firstOutput.clear();
185
211
  if (idleTimer) clearTimeout(idleTimer);
186
212
  if (interChunkTimer) {
187
213
  clearTimeout(interChunkTimer);
@@ -684,6 +684,18 @@ export function refreshProviderCatalogsOnStartup() {
684
684
  return _startupCatalogRefreshPromise;
685
685
  }
686
686
 
687
+ // Force-refresh only the named providers' catalogs, each bounded like the full
688
+ // refresh. It leaves the 24h window, the catalog revision and every other
689
+ // provider's cache alone.
690
+ export function refreshProviderCatalogsFor(names) {
691
+ const pending = [];
692
+ for (const [name, provider] of availableProviderEntries()) {
693
+ const refreshFn = names.includes(name) ? providerCatalogRefreshFn(provider) : null;
694
+ if (refreshFn) pending.push(boundedCatalogRefresh(name, provider, refreshFn, 'catalog refresh'));
695
+ }
696
+ return Promise.allSettled(pending);
697
+ }
698
+
687
699
  // Refresh the complete catalog at most once per 24h process window. The timer
688
700
  // above calls this automatically when the window expires; callers may bypass
689
701
  // that window after an explicit auth/config/model-refresh action.
@@ -8,9 +8,9 @@ export const ASK_USER_TOOL_DEFS = Object.freeze([
8
8
  name: 'ask_user',
9
9
  title: 'Ask User',
10
10
  trigger:
11
- 'Ask the user 1–4 multiple-choice questions and wait; when a plan needs the user to pick between clear options, instead of listing them in chat. Also hands the user a page or app step (CAPTCHA, sign-in, 2FA, a secret).',
11
+ 'Ask the user 1–4 multiple-choice questions and wait; when a plan needs the user to pick between clear options, instead of listing them in chat.',
12
12
  description:
13
- 'Ask the user multiple-choice questions and wait. Only for a decision only the user can make, with clear options; never for minor choices or approval of requested work, and open questions go in chat. Do not repeat the options in your message. A step only the user can do on a page or app (CAPTCHA, human check, sign-in, 2FA, a password) is a handoff here, never a request in plain chat.',
13
+ 'Ask the user multiple-choice questions and wait. Only for a decision only the user can make, with clear options; never for minor choices or approval of requested work, and open questions go in chat. Do not repeat the options in your message. A step only the user can do on a page or app (CAPTCHA, human check, sign-in, 2FA, a password) goes to handoff_user, never a request in plain chat. Load handoff_user directly if needed; browser/computer skills are not prerequisites.',
14
14
  annotations: {
15
15
  title: 'Ask User',
16
16
  readOnlyHint: true,
@@ -52,10 +52,37 @@ export const ASK_USER_TOOL_DEFS = Object.freeze([
52
52
  required: ['question', 'header', 'options'],
53
53
  },
54
54
  },
55
+ },
56
+ additionalProperties: false,
57
+ },
58
+ },
59
+ ]);
60
+
61
+ // The deferred handoff tool: loaded with the browser-use and computer-use
62
+ // skills (dependencies.tools), the only surfaces a handoff targets.
63
+ export const HANDOFF_USER_TOOL_DEFS = Object.freeze([
64
+ {
65
+ name: 'handoff_user',
66
+ title: 'Hand Off To User',
67
+ trigger:
68
+ 'Hand the user a page or app step (CAPTCHA, sign-in, 2FA, a secret); loads with the browser-use and computer-use skills.',
69
+ description:
70
+ 'A step only the user can do on a page or app (CAPTCHA, human check, sign-in, 2FA, a password) is a handoff here, never a request in plain chat.',
71
+ annotations: {
72
+ title: 'Hand Off To User',
73
+ readOnlyHint: true,
74
+ destructiveHint: false,
75
+ idempotentHint: false,
76
+ openWorldHint: false,
77
+ agentHidden: true,
78
+ },
79
+ inputSchema: {
80
+ type: 'object',
81
+ properties: {
55
82
  handoff: {
56
83
  type: 'object',
57
84
  description:
58
- 'Instead of questions: secret = the user types a password, sign-in, new password or one-time code into that field of the page (you never see it; result entered/user_skipped/no_field/page_changed/blocked/expired); takeover = the user does a step (sign-in, CAPTCHA, 2FA) and says done.',
85
+ 'secret = the user types a password, sign-in, new password or one-time code into that field of the page (you never see it; result entered/user_skipped/no_field/page_changed/blocked/expired); takeover = the user does a step and says done.',
59
86
  properties: {
60
87
  kind: { type: 'string', enum: ['secret', 'takeover'] },
61
88
  surface: { type: 'string', enum: ['browser', 'computer'] },
@@ -83,26 +110,26 @@ export const ASK_USER_TOOL_DEFS = Object.freeze([
83
110
  additionalProperties: false,
84
111
  },
85
112
  },
113
+ required: ['handoff'],
86
114
  additionalProperties: false,
87
115
  },
88
116
  },
89
117
  ]);
90
118
 
91
- /** The handoff of an ask_user call ({ kind, surface, target, field, instructions }), or null. */
92
- export function validateAskUserHandoff(args) {
119
+ /** The handoff of a handoff_user call ({ kind, surface, target, field, instructions, … }). */
120
+ export function validateUserHandoff(args) {
93
121
  const handoff = args?.handoff;
94
- if (handoff == null) return null;
95
- if (args.questions != null) throw new Error('ask_user takes questions or a handoff, not both');
96
- if (typeof handoff !== 'object' || Array.isArray(handoff)) throw new Error('ask_user handoff must be an object');
122
+ if (handoff == null) throw new Error('handoff_user needs a handoff');
123
+ if (typeof handoff !== 'object' || Array.isArray(handoff)) throw new Error('handoff_user handoff must be an object');
97
124
  if (handoff.kind !== 'secret' && handoff.kind !== 'takeover') {
98
- throw new Error('ask_user handoff kind is secret or takeover');
125
+ throw new Error('handoff_user handoff kind is secret or takeover');
99
126
  }
100
127
  if (handoff.surface !== 'browser' && handoff.surface !== 'computer') {
101
- throw new Error('ask_user handoff surface is browser or computer');
128
+ throw new Error('handoff_user handoff surface is browser or computer');
102
129
  }
103
130
  const text = (key, limit) => (typeof handoff[key] === 'string' ? handoff[key].trim().slice(0, limit) : '');
104
131
  if (handoff.kind === 'takeover' && !text('instructions', 2000)) {
105
- throw new Error('ask_user takeover needs instructions');
132
+ throw new Error('handoff_user takeover needs instructions');
106
133
  }
107
134
  const login = handoffLogin(handoff);
108
135
  let need = login ? 'sign_in' : 'action';
@@ -125,10 +152,10 @@ export function validateAskUserHandoff(args) {
125
152
  function handoffLogin(handoff) {
126
153
  if (handoff.purpose == null) return null;
127
154
  if (handoff.purpose !== 'login' || handoff.kind !== 'takeover' || handoff.surface !== 'browser') {
128
- throw new Error('ask_user purpose login is a browser takeover');
155
+ throw new Error('handoff_user purpose login is a browser takeover');
129
156
  }
130
157
  const page = loginPageUrl(handoff.url);
131
- if (!page) throw new Error('ask_user login needs an https url (http only on localhost)');
158
+ if (!page) throw new Error('handoff_user login needs an https url (http only on localhost)');
132
159
  return page;
133
160
  }
134
161
 
@@ -159,6 +186,7 @@ function handoffIdentity(handoff) {
159
186
 
160
187
  /** Throws a clear error when the questions are malformed; returns the list. */
161
188
  export function validateAskUserQuestions(args) {
189
+ if (args?.handoff != null) throw new Error('ask_user takes questions only; a page or app step goes to handoff_user');
162
190
  const questions = args?.questions;
163
191
  if (!Array.isArray(questions) || questions.length < 1 || questions.length > ASK_USER_MAX_QUESTIONS) {
164
192
  throw new Error(`ask_user requires 1-${ASK_USER_MAX_QUESTIONS} questions`);
@@ -10,7 +10,7 @@ same change scope rather than splitting every file or step.`,
10
10
  swarm: `Delegate every independent scope to its own agent; a scope is one
11
11
  verifiable deliverable, not a bundle of steps. Treat disjoint file or
12
12
  module sets as independent and merge scopes only on a true output
13
- dependency. Prefer parallel scopes over sequential slices in one agent.`,
13
+ dependency.`,
14
14
  };
15
15
 
16
16
  const SOLO = `# Orchestration Mode: Solo
@@ -34,14 +34,16 @@ self-contained brief.`;
34
34
  export function orchestrationInstructions(value) {
35
35
  const mode = normalizeOrchestrationMode(value);
36
36
  if (mode === 'none') return SOLO;
37
+ // Focused already says Lead executes the main scope directly.
38
+ const oneScope =
39
+ mode === 'focused' ? '' : '\nIf the plan has only one implementation scope, Lead executes it directly.';
37
40
  return `# Orchestration Mode: ${mode[0].toUpperCase()}${mode.slice(1)}
38
41
 
39
42
  ${BATCHING[mode]}
40
43
 
41
44
  The mode sets how work splits; an agent's description sets its role and when
42
45
  it runs. Splitting applies to every role alike — implementation, review,
43
- investigation, writing — by file, module, commit range or risk area.
44
- If the plan has only one implementation scope, Lead executes it directly.
46
+ investigation, writing — by file, module, commit range or risk area.${oneScope}
45
47
 
46
48
  Delegation is for work whose size justifies a brief. When the brief would have
47
49
  to spell out the edits themselves — a few localized changes whose sites Lead
@@ -49,9 +51,8 @@ already holds — Lead makes them directly in every mode; the number of files or
49
51
  scopes does not change this.
50
52
 
51
53
  Dispatch all ready independent scopes in one turn. Only a scope that requires
52
- another's output waits. Brief each agent as the Lead rules describe.
53
- A running agent receives only changes within its own scope; a new independent
54
- need goes to a new agent. When scopes touch the same file, name one owner for
54
+ another's output waits.
55
+ A new independent need goes to a new agent. When scopes touch the same file, name one owner for
55
56
  that file in each brief.
56
57
  Use only available agents; when none fits a role, Lead does that work itself
57
58
  and never changes settings to enable one.`;
@@ -84,12 +84,16 @@ export function cleanSessionPreview(text, max = 160) {
84
84
  const limit = Math.max(16, Number(max) || 160);
85
85
  // Previews run on truncated source: an injected block that lost its closing
86
86
  // tag still must not become the preview.
87
- return stripInjectedBlocks(stripSessionDisplayEnvelope(text), { dropUnterminated: true })
87
+ const cleaned = stripInjectedBlocks(stripSessionDisplayEnvelope(text), { dropUnterminated: true })
88
88
  .replace(/\[(?:Pasted text|Image)\s*#?\d+(?:\s*(?::[^\]\r\n]*|\+\d+\s+lines))?\]/gi, ' ')
89
89
  .replace(/^Reference files:\s*/i, '')
90
90
  .replace(/\s+/g, ' ')
91
- .trim()
92
- .slice(0, limit);
91
+ .trim();
92
+ if (cleaned.length <= limit) return cleaned;
93
+ // A slice of a longer string keeps that whole string alive in V8. Previews
94
+ // outlive their message (summary rows, catalog caches, the preview memo), so
95
+ // the cut is copied out: a 160-char preview must not pin the whole reply.
96
+ return JSON.parse(JSON.stringify(cleaned.slice(0, limit)));
93
97
  }
94
98
 
95
99
  // Stable sentinel carried in late-tool (deferred MCP) announcement reminders.
@@ -115,7 +115,7 @@ export const SESSION_TOOL = {
115
115
  session_id: {
116
116
  type: 'string',
117
117
  description:
118
- 'ID from list; required for read/send/cancel/delete, optional for status/bots. send/cancel/delete cannot target the caller.',
118
+ 'ID from list; required for read/send/cancel/delete, optional for status/bots. send/cancel/delete cannot target the caller. read/send/status also take a bot id (its home chat).',
119
119
  },
120
120
  prompt: { type: 'string', description: 'send: message to enqueue; loads a cold session.' },
121
121
  to: {
@@ -264,6 +264,7 @@ export function collectStandaloneToolDefs({
264
264
  visualizeToolDefs = [],
265
265
  setupToolDefs = [],
266
266
  askUserToolDefs = [],
267
+ handoffUserToolDefs = [],
267
268
  reactionToolDefs = [],
268
269
  goalTools = [],
269
270
  agentTools = [],
@@ -286,6 +287,7 @@ export function collectStandaloneToolDefs({
286
287
  ...admitted(visualizeToolDefs, VISUALIZE_RUNTIME_TOOLS),
287
288
  ...setupToolDefs,
288
289
  ...askUserToolDefs,
290
+ ...handoffUserToolDefs,
289
291
  ...reactionToolDefs,
290
292
  ...goalTools,
291
293
  ...agentTools,
@@ -178,8 +178,11 @@ export function webSearchRouteOrDefault(...routeLikes) {
178
178
  });
179
179
  }
180
180
 
181
- export function normalizeWorkflowRoute(routeLike, fallback = {}) {
182
- return normalizeRouteChain(routeLike, normalizeWorkflowRouteLeaf, fallback);
181
+ // A stored chain may hold a fallback on the main model's own provider (inactive,
182
+ // see executionFallbacks), so reading and re-saving it is lenient; a route the
183
+ // user saves directly passes `{ strict: true }`.
184
+ export function normalizeWorkflowRoute(routeLike, fallback = {}, { strict = false } = {}) {
185
+ return normalizeRouteChain(routeLike, normalizeWorkflowRouteLeaf, fallback, { strict });
183
186
  }
184
187
 
185
188
  function normalizeWorkflowRouteLeaf(routeLike, fallback = {}) {
@@ -4,9 +4,7 @@
4
4
  // touched path; unknown paths still fall back to a full session clear.
5
5
  import { join, resolve as _pathResolve, isAbsolute as _pathIsAbs, normalize as _pathNorm } from 'node:path';
6
6
  import { _normalizeCacheKey } from './util.mjs';
7
- import {
8
- hasGlobMagic,
9
- } from '../../tools/builtin/path-utils.mjs';
7
+ import { hasGlobMagic } from '../../tools/builtin/path-utils.mjs';
10
8
  import { prepareGrepArgs } from '../../tools/builtin/arg-guard.mjs';
11
9
  import { registerCacheInvalidationListener } from '../../tools/builtin/cache-layers.mjs';
12
10
  import { setBoundedTextCacheEntry } from './text-cache-budget.mjs';