jeopi-cli 16.4.2 → 16.4.3

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 (125) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3179 -3140
  3. package/dist/types/advisor/config.d.ts +4 -3
  4. package/dist/types/cli/args.d.ts +2 -0
  5. package/dist/types/cli/update-cli.d.ts +4 -1
  6. package/dist/types/cli/usage-error.d.ts +4 -0
  7. package/dist/types/commit/agentic/agent.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +7 -1
  9. package/dist/types/config/settings-schema.d.ts +3 -3
  10. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  11. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  12. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  13. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  14. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  15. package/dist/types/internal-urls/types.d.ts +7 -0
  16. package/dist/types/irc/bus.d.ts +4 -0
  17. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  18. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  19. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  20. package/dist/types/modes/components/welcome.d.ts +4 -0
  21. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  22. package/dist/types/modes/orchestrate.d.ts +1 -1
  23. package/dist/types/modes/ultrathink.d.ts +1 -1
  24. package/dist/types/modes/workflow.d.ts +1 -1
  25. package/dist/types/session/agent-session.d.ts +5 -0
  26. package/dist/types/session/auth-broker-config.d.ts +2 -2
  27. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  28. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  29. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  30. package/dist/types/tools/browser/launch.d.ts +1 -0
  31. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  32. package/dist/types/tools/browser/run-output.d.ts +25 -0
  33. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  34. package/dist/types/utils/changelog.d.ts +35 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +13 -0
  37. package/src/advisor/__tests__/config.test.ts +36 -0
  38. package/src/advisor/config.ts +11 -8
  39. package/src/autolearn/controller.ts +14 -2
  40. package/src/cli/args.ts +12 -0
  41. package/src/cli/flag-tables.ts +21 -7
  42. package/src/cli/update-cli.ts +63 -13
  43. package/src/cli/usage-error.ts +7 -0
  44. package/src/commands/acp.ts +11 -2
  45. package/src/commands/launch.ts +12 -3
  46. package/src/commit/agentic/agent.ts +4 -0
  47. package/src/commit/agentic/index.ts +46 -21
  48. package/src/config/model-resolver.ts +24 -6
  49. package/src/config/settings-schema.ts +3 -3
  50. package/src/edit/index.ts +10 -76
  51. package/src/edit/renderer.ts +13 -10
  52. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  53. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  54. package/src/eval/executor-base.ts +84 -7
  55. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  56. package/src/eval/py/tool-bridge.ts +13 -14
  57. package/src/exec/non-interactive-env.ts +0 -1
  58. package/src/export/ttsr.ts +0 -3
  59. package/src/extensibility/custom-tools/loader.ts +70 -37
  60. package/src/extensibility/custom-tools/types.ts +3 -0
  61. package/src/extensibility/extensions/runner.ts +3 -0
  62. package/src/extensibility/extensions/types.ts +3 -0
  63. package/src/extensibility/plugins/manager.ts +21 -0
  64. package/src/internal-urls/docs-index.generated.txt +1 -1
  65. package/src/internal-urls/memory-protocol.ts +13 -8
  66. package/src/internal-urls/skill-protocol.ts +1 -1
  67. package/src/internal-urls/types.ts +7 -0
  68. package/src/irc/bus.ts +48 -18
  69. package/src/lsp/client.ts +7 -1
  70. package/src/lsp/deferred-diagnostics.ts +66 -0
  71. package/src/main.ts +13 -13
  72. package/src/mcp/tool-bridge.ts +57 -6
  73. package/src/mcp/transports/stdio.test.ts +45 -1
  74. package/src/mcp/transports/stdio.ts +6 -3
  75. package/src/modes/acp/acp-agent.ts +73 -2
  76. package/src/modes/acp/acp-event-mapper.ts +5 -0
  77. package/src/modes/acp/acp-mode.ts +11 -0
  78. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  79. package/src/modes/components/advisor-config.ts +15 -7
  80. package/src/modes/components/move-overlay.ts +2 -3
  81. package/src/modes/components/plan-review-overlay.ts +7 -0
  82. package/src/modes/components/tips.txt +1 -1
  83. package/src/modes/components/welcome.ts +13 -14
  84. package/src/modes/controllers/command-controller.ts +8 -10
  85. package/src/modes/controllers/event-controller.ts +17 -30
  86. package/src/modes/controllers/input-controller.ts +9 -6
  87. package/src/modes/controllers/selector-controller.ts +3 -2
  88. package/src/modes/controllers/tan-command-controller.ts +40 -1
  89. package/src/modes/interactive-mode.ts +13 -0
  90. package/src/modes/magic-keyword-boundary.ts +23 -0
  91. package/src/modes/orchestrate.ts +6 -5
  92. package/src/modes/print-mode.ts +9 -0
  93. package/src/modes/ultrathink.ts +6 -5
  94. package/src/modes/workflow.ts +5 -4
  95. package/src/prompts/system/eager-task.md +2 -2
  96. package/src/prompts/system/tan-context-switch.md +17 -0
  97. package/src/prompts/tools/browser.md +5 -5
  98. package/src/sdk.ts +3 -0
  99. package/src/session/agent-session.ts +172 -25
  100. package/src/session/auth-broker-config.ts +4 -4
  101. package/src/session/snapcompact-inline.ts +3 -19
  102. package/src/session/streaming-output.ts +40 -12
  103. package/src/slash-commands/builtin-registry.ts +8 -8
  104. package/src/task/executor.ts +8 -0
  105. package/src/tools/bash-skill-urls.ts +7 -4
  106. package/src/tools/bash.ts +30 -22
  107. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  108. package/src/tools/browser/cmux/rpc.ts +50 -0
  109. package/src/tools/browser/launch.ts +8 -4
  110. package/src/tools/browser/run-cancellation.ts +44 -5
  111. package/src/tools/browser/run-output.ts +76 -0
  112. package/src/tools/browser/tab-worker.ts +280 -133
  113. package/src/tools/eval-render.ts +16 -14
  114. package/src/tools/glob.ts +20 -6
  115. package/src/tools/grep.ts +1 -0
  116. package/src/tools/image-gen.ts +3 -4
  117. package/src/tools/irc.ts +27 -13
  118. package/src/tools/read.ts +14 -7
  119. package/src/tools/write.ts +22 -5
  120. package/src/tools/yield.ts +29 -1
  121. package/src/utils/changelog.ts +106 -9
  122. package/src/utils/git.ts +0 -1
  123. package/src/utils/title-generator.ts +68 -7
  124. package/src/web/search/index.ts +14 -6
  125. package/src/web/search/providers/perplexity.ts +18 -2
@@ -1,5 +1,6 @@
1
1
  import workflowNotice from "../prompts/system/workflow-notice.md" with { type: "text" };
2
2
  import { createGradientHighlighter, type KeywordHighlighter } from "./gradient-highlight";
3
+ import { magicKeywordRegex } from "./magic-keyword-boundary";
3
4
  import { keywordInProse } from "./markdown-prose";
4
5
 
5
6
  /**
@@ -14,15 +15,15 @@ import { keywordInProse } from "./markdown-prose";
14
15
  * "workflowz.ts" never do.
15
16
  */
16
17
 
17
- // Detection: lowercase keyword flanked by whitespace or a string edge. Non-global so `.test` stays stateless.
18
- const WORKFLOW_WORD = /(?<!\S)workflowz(?!\S)/;
18
+ // Detection: lowercase keyword flanked by prose punctuation, whitespace, or a string edge.
19
+ const WORKFLOW_WORD = magicKeywordRegex("workflowz");
19
20
 
20
21
  /** Hidden system notice appended after a user message that mentions "workflowz". */
21
22
  export const WORKFLOW_NOTICE: string = workflowNotice.trim();
22
23
 
23
24
  /**
24
25
  * Whether `text` contains the standalone keyword "workflowz"
25
- * (lowercase, whitespace-delimited) in prose — never inside a code block, inline
26
+ * (lowercase, prose-delimited) in prose — never inside a code block, inline
26
27
  * code span, or XML/HTML section.
27
28
  */
28
29
  export function containsWorkflow(text: string): boolean {
@@ -36,7 +37,7 @@ export function containsWorkflow(text: string): boolean {
36
37
  */
37
38
  export const highlightWorkflow: KeywordHighlighter = createGradientHighlighter({
38
39
  probe: /workflowz/,
39
- highlight: /(?<!\S)workflowz(?!\S)/g,
40
+ highlight: magicKeywordRegex("workflowz", "g"),
40
41
  stops: 14,
41
42
  hue: t => 30 + t * 120,
42
43
  });
@@ -1,7 +1,7 @@
1
1
  <system-reminder>
2
2
  Task delegation is enabled — subagents are the default for this request.
3
3
 
4
- Explore and settle the approach FIRST. Once the design is settled, you MUST fan the work out to `{{toolRefs.task}}` subagents instead of implementing it yourself.{{#if taskBatch}} Batch independent slices into ONE parallel `{{toolRefs.task}}` call; never serialize work that can run concurrently.{{/if}}
4
+ Explore and settle the approach FIRST — scoping, top-level decomposition, and cross-slice contracts are YOUR job; NEVER spawn a subagent to produce the overall plan (per-slice design travels with its executor). Once the design is settled, you MUST fan the work out to `{{toolRefs.task}}` subagents instead of implementing it yourself.{{#if taskBatch}} Batch independent slices into ONE parallel `{{toolRefs.task}}` call; never serialize work that can run concurrently.{{/if}}
5
5
 
6
- Work alone only for: a single-file edit under ~30 lines, a direct answer requiring no code changes, or a command the user explicitly asked you to run.
6
+ Work alone for: a single-file edit under ~30 lines, a direct answer requiring no code changes, a command the user explicitly asked you to run, or when only ONE runnable slice exists — a lone subagent is a lossy handoff, not parallelism.
7
7
  </system-reminder>
@@ -0,0 +1,17 @@
1
+ <system-notice cause="fork">
2
+ The conversation above belongs to your parent session.
3
+ You are a fork created solely to handle the user's request below.
4
+
5
+ Your parent agent is still working on the original task — that responsibility is
6
+ NOT yours. You have no obligations from the prior conversation.
7
+
8
+ - Focus EXCLUSIVELY on the user's immediate request. Nothing else.
9
+ - NEVER continue, follow up on, or intervene in anything discussed before this
10
+ message. Those belong to the parent session.
11
+ - Your parent is CONCURRENTLY editing this same working directory. Files may
12
+ change between your reads, look mid-refactor, or fail to compile. That is the
13
+ parent's live work — NEVER fix, audit, or build on it, even if it looks broken.
14
+ - Any todo list, plan, or unfinished checklist from the prior conversation is
15
+ the parent's. NEVER resume or update it.
16
+ - After addressing the user's request, STOP. Do not work on ANY OTHER TASK.
17
+ </system-notice>
@@ -5,7 +5,7 @@ Drives real Chromium tab; full puppeteer access via JS.
5
5
  - Three actions:
6
6
  - `open` — acquire/reuse named tab (`name` defaults `"main"`). Optional `url` (navigate once ready), `viewport`, `dialogs: "accept" | "dismiss"` (auto-handle `alert`/`confirm`/`beforeunload`; else page hangs till you wire `page.on('dialog', …)`).
7
7
  - `close` — release tab by `name`, or all with `all: true`. `kill: true` also kills spawned-app process trees.
8
- - `run` — execute JS in existing tab. `code` = async function body; `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Return value JSON-stringified into result; `display(value)` accumulates text/images.
8
+ - `run` — execute JS in existing tab. `code` = async function body; `page`, `browser`, `tab`, `display`, `assert`, `wait` in scope. Return value JSON-stringified into result; `display(value)` accumulates text/images. `wait(ms)` sleeps; `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until truthy and resolves with that value (default 30s timeout, 100ms interval; named error on timeout) — use it instead of in-page polling Promises inside `tab.evaluate`.
9
9
  - Tabs survive `run` calls and in-process subagents — open once, reuse.
10
10
  - Browser kinds (`app` on `open`):
11
11
  - default (no `app`) → headless Chromium with stealth patches.
@@ -17,9 +17,9 @@ Drives real Chromium tab; full puppeteer access via JS.
17
17
  - `tab.observe({ includeAll?, viewportOnly? })` — accessibility snapshot: `{ url, title, viewport, scroll, elements: [{ id, role, name, value, states, … }] }`. Ids stable until next observe/goto.
18
18
  - `tab.ariaSnapshot(selector?, { depth?, boxes? })` — Playwright-format ARIA-tree YAML (nested roles + accessible names + `/url`/`/placeholder`), scoped to `selector` or the whole document. Every node carries a `[ref=eN]` id; `[cursor=pointer]` flags clickables. Captures dense, hierarchical structure/text that `observe()`'s flat list flattens away. Refs renumber from e1 each call and stay valid until the next `ariaSnapshot()`.
19
19
  - `tab.ref("e5")` — `[ref=eN]` from the last ariaSnapshot → element handle with the common action methods (`.click()`, `.type()`, `.fill()`, `.hover()`, `.evaluate()`, …); the primary way to act on a ref. For convenience `aria-ref=e5` also works inline in `tab.click`/`type`/`fill`/`waitFor`/`scrollIntoView` (e.g. `tab.click("aria-ref=e5")`).
20
- - `tab.id(n)` — id from last observe → `ElementHandle` (`.click()`, `.type()`, …).
20
+ - `tab.id(n)` — id from last observe → element handle with the same action methods (`.click()`, `.type()`, `.fill()`, …).
21
21
  - `tab.click(selector)` / `tab.type(selector, text)` / `tab.fill(selector, value)` / `tab.press(key, { selector? })` / `tab.scroll(dx, dy)`.
22
- - `tab.waitFor(selector, { timeout? })` / `tab.waitForSelector(selector, { timeout?, visible?, hidden? })` — wait until attached (optionally visible/hidden); returns the `ElementHandle`.
22
+ - `tab.waitFor(selector, { timeout? })` / `tab.waitForSelector(selector, { timeout?, visible?, hidden? })` — wait until attached (optionally visible/hidden); returns an action-method handle.
23
23
  - `tab.drag(from, to)` — endpoints: selector (center-to-center) or `{ x, y }` viewport point (canvases, sliders).
24
24
  - `tab.scrollIntoView(selector)` — center in viewport; before clicking off-screen elements.
25
25
  - `tab.select(selector, …values)` — set `<select>` option(s); returns selection. `tab.fill` NEVER works for selects.
@@ -27,10 +27,10 @@ Drives real Chromium tab; full puppeteer access via JS.
27
27
  - `tab.waitForUrl(pattern, { timeout? })` — substring or `RegExp` (matches SPA pushState nav); returns matched URL.
28
28
  - `tab.waitForResponse(pattern, { timeout? })` — substring, `RegExp`, or `(response) => boolean`; returns puppeteer `HTTPResponse` (`.text()`/`.json()`/`.status()`/`.headers()`).
29
29
  - `tab.waitForNavigation({ waitUntil?, timeout? })` — resolves on the next navigation. Start it BEFORE the click/submit that triggers it; after `tab.goto` (which already waits) use `tab.waitForUrl`/`tab.waitForSelector` instead.
30
- - `tab.evaluate(fn, …args)` — `page.evaluate` for ad-hoc DOM reads.
30
+ - `tab.evaluate(fn, …args)` — run ad-hoc code in the page's MAIN world. DOM and page-defined globals (`window.myFlag`) are visible; mutations affect the page.
31
31
  - `tab.screenshot({ selector?, fullPage?, save?, silent? })` — capture + attach for viewing (`silent: true` skips). Pass `save` only when a later step needs the file.
32
32
  - `tab.extract(format = "markdown")` — readable page content (`"markdown"` | `"text"`); throws when nothing readable.
33
- - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.<op> timed out` error, never the whole-cell timeout.
33
+ - Selectors: CSS + puppeteer handlers `aria/Sign in`, `text/Continue`, `xpath/…`, `pierce/…`; also Playwright-style `p-aria/…`, `p-text/…`. Playwright-only engines/pseudos (`:has-text()`, `:visible`, …) are rejected — use `text/…` or `aria/…`. A stalled action/wait fails fast with a named `tab.<op>` error carrying a match-count diagnosis, never the whole-cell timeout; a selector matching nothing fails in ~2s (pass an explicit `{ timeout }` to `waitFor`/`waitForSelector` to wait out slow-appearing elements).
34
34
  </instruction>
35
35
 
36
36
  <critical>
package/src/sdk.ts CHANGED
@@ -847,6 +847,7 @@ function createCustomToolContext(ctx: ExtensionContext): CustomToolContext {
847
847
  isIdle: ctx.isIdle,
848
848
  hasQueuedMessages: ctx.hasPendingMessages,
849
849
  abort: ctx.abort,
850
+ localProtocolOptions: ctx.localProtocolOptions,
850
851
  };
851
852
  }
852
853
 
@@ -2053,6 +2054,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2053
2054
  modelRegistry,
2054
2055
  () => (hasSession ? createSessionMemoryRuntimeContext(session, agentDir, cwd) : undefined),
2055
2056
  settings,
2057
+ localProtocolOptions,
2056
2058
  );
2057
2059
 
2058
2060
  credentialDisabledTarget = extensionRunner;
@@ -2071,6 +2073,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
2071
2073
  session.abort({ reason: USER_INTERRUPT_LABEL });
2072
2074
  },
2073
2075
  settings,
2076
+ localProtocolOptions,
2074
2077
  autoApprove: options.autoApprove ?? false,
2075
2078
  });
2076
2079
  const toolContextStore = new ToolContextStore(getSessionContext);
@@ -173,7 +173,7 @@ import {
173
173
  resolveModelOverride,
174
174
  resolveModelRoleValue,
175
175
  } from "../config/model-resolver";
176
- import { MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
176
+ import { getKnownRoleIds, MODEL_ROLE_IDS, MODEL_ROLES } from "../config/model-roles";
177
177
  import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-templates";
178
178
  import { applyProviderGlobalsFromSettings } from "../config/provider-globals";
179
179
  import { buildServiceTierByFamily, serviceTierForAllFamilies, serviceTierSettingToTier } from "../config/service-tier";
@@ -387,6 +387,64 @@ const MID_RUN_TODO_NUDGE_MUTATING_TOOLS: Record<string, true> = {
387
387
  write: true,
388
388
  ast_edit: true,
389
389
  };
390
+ const MARKDOWN_PROMPT_PREFIX_RE = /^(?:>\s*)?(?:(?:[-*+]|\d+[.)])\s+)*/;
391
+ const PROMPT_LABEL_RE = /^(?:q(?:uestion)?|ask)\s*\d*\s*[:.)-]\s*/i;
392
+ const QUESTION_PROMPT_RE =
393
+ /^(?:what|which|when|where|why|how|who|whom|whose|do|does|did|can|could|would|will|should|is|are|am|may|shall)\b/i;
394
+ const USER_DIRECTED_PROMPT_RE = /\b(?:you|your|we|our)\b/i;
395
+ const USER_RESPONSE_CUE_RE =
396
+ /^(?:please\s+)?(?:confirm|reply|choose|pick|decide|advise)\b|^(?:please\s+)?answer\b|^(?:please\s+)?(?:let\s+me\s+know|tell\s+me)\b/i;
397
+
398
+ function assistantText(message: AssistantMessage): string {
399
+ return message.content
400
+ .filter((content): content is TextContent => content.type === "text")
401
+ .map(content => content.text)
402
+ .join("\n")
403
+ .trim();
404
+ }
405
+
406
+ interface PromptLine {
407
+ text: string;
408
+ hadPromptLabel: boolean;
409
+ }
410
+
411
+ function promptLine(line: string): PromptLine {
412
+ const withoutMarkdownPrefix = line.trim().replace(MARKDOWN_PROMPT_PREFIX_RE, "").trim();
413
+ const withoutPromptLabel = withoutMarkdownPrefix.replace(PROMPT_LABEL_RE, "").trim();
414
+ return {
415
+ text: withoutPromptLabel,
416
+ hadPromptLabel: withoutPromptLabel !== withoutMarkdownPrefix,
417
+ };
418
+ }
419
+
420
+ function isQuestionPromptLine(line: string): boolean {
421
+ const candidate = promptLine(line);
422
+ if (!/[??]\s*$/.test(candidate.text)) return false;
423
+ return (
424
+ candidate.hadPromptLabel ||
425
+ QUESTION_PROMPT_RE.test(candidate.text) ||
426
+ USER_DIRECTED_PROMPT_RE.test(candidate.text)
427
+ );
428
+ }
429
+
430
+ function isResponseCueLine(line: string): boolean {
431
+ const candidate = promptLine(line)
432
+ .text.replace(/[.!?。!?]+$/, "")
433
+ .trim();
434
+ return USER_RESPONSE_CUE_RE.test(candidate);
435
+ }
436
+
437
+ function isAwaitingUserAnswer(message: AssistantMessage): boolean {
438
+ const text = assistantText(message);
439
+ if (!text) return false;
440
+ const tailLines = text
441
+ .split(/\r?\n/)
442
+ .map(line => line.trim())
443
+ .filter(Boolean)
444
+ .slice(-6);
445
+ const lastLine = tailLines.at(-1);
446
+ return tailLines.some(isQuestionPromptLine) || (lastLine !== undefined && isResponseCueLine(lastLine));
447
+ }
390
448
  /** `customType` for the hidden mid-run todo nudge; `display: false`, so it reaches
391
449
  * the model but never renders in the TUI or transcript. */
392
450
  const MID_RUN_TODO_NUDGE_MESSAGE_TYPE = "mid-run-todo-nudge";
@@ -2415,7 +2473,7 @@ export class AgentSession {
2415
2473
  if (this.#advisorSharedInstructions) systemPrompt.push(this.#advisorSharedInstructions);
2416
2474
  if (config.instructions?.trim()) systemPrompt.push(config.instructions.trim());
2417
2475
 
2418
- const names = config.tools?.length ? new Set(config.tools) : ADVISOR_DEFAULT_TOOL_NAMES;
2476
+ const names = config.tools === undefined ? ADVISOR_DEFAULT_TOOL_NAMES : new Set(config.tools);
2419
2477
  const tools = (this.#advisorTools ?? []).filter(t => names.has(t.name));
2420
2478
 
2421
2479
  const advisorSessionId = this.#advisorSessionId(slug);
@@ -3946,6 +4004,17 @@ export class AgentSession {
3946
4004
  await emitAgentEndNotification();
3947
4005
  return;
3948
4006
  }
4007
+ } else if (this.#isHardErrorFallbackEligible(msg)) {
4008
+ // A non-retryable hard error on a model covered by a configured
4009
+ // fallback chain: retrying the SAME model is pointless, but a
4010
+ // DIFFERENT model is a fresh chance — consult the chain before
4011
+ // surfacing the failure. #handleRetryableError bails out (no
4012
+ // backoff-retry of the failing model) when no switch happens.
4013
+ const didRetry = await this.#handleRetryableError(msg, { hardErrorFallback: true });
4014
+ if (didRetry) {
4015
+ await emitAgentEndNotification();
4016
+ return;
4017
+ }
3949
4018
  }
3950
4019
  // Classifier refusals are persisted-skipped above; also prune the trailing
3951
4020
  // stub from active context so the next turn's prompt does not replay it.
@@ -3990,7 +4059,7 @@ export class AgentSession {
3990
4059
  await emitAgentEndNotification();
3991
4060
  return;
3992
4061
  }
3993
- const todoContinuationScheduled = await this.#checkTodoCompletion();
4062
+ const todoContinuationScheduled = await this.#checkTodoCompletion(msg);
3994
4063
  if (todoContinuationScheduled) {
3995
4064
  await emitAgentEndNotification();
3996
4065
  return;
@@ -6518,6 +6587,8 @@ export class AgentSession {
6518
6587
  abort: () => {
6519
6588
  this.agent.abort();
6520
6589
  },
6590
+ settings: this.settings,
6591
+ localProtocolOptions: this.#localProtocolOptions(),
6521
6592
  });
6522
6593
 
6523
6594
  for (const customTool of mcpTools) {
@@ -7003,6 +7074,30 @@ export class AgentSession {
7003
7074
  return this.#resolveRoleModelFull(role, this.#modelRegistry.getAvailable(), this.model);
7004
7075
  }
7005
7076
 
7077
+ /**
7078
+ * Resolve the explicit thinking suffix that should apply when a temporary
7079
+ * picker selects a model already assigned to a configured role.
7080
+ */
7081
+ resolveTemporaryModelThinkingLevel(model: Model): ConfiguredThinkingLevel | undefined {
7082
+ const availableModels = this.#modelRegistry.getAvailable();
7083
+ if (availableModels.length === 0) return undefined;
7084
+
7085
+ const matchPreferences = getModelMatchPreferences(this.settings);
7086
+ for (const role of getKnownRoleIds(this.settings)) {
7087
+ const roleValue = this.settings.getModelRole(role);
7088
+ if (!roleValue) continue;
7089
+
7090
+ const resolved = resolveModelRoleValue(roleValue, availableModels, {
7091
+ settings: this.settings,
7092
+ matchPreferences,
7093
+ });
7094
+ if (!resolved.explicitThinkingLevel || resolved.thinkingLevel === undefined || !resolved.model) continue;
7095
+ if (modelsAreEqual(resolved.model, model)) return resolved.thinkingLevel;
7096
+ }
7097
+
7098
+ return undefined;
7099
+ }
7100
+
7006
7101
  get promptTemplates(): ReadonlyArray<PromptTemplate> {
7007
7102
  return this.#promptTemplates;
7008
7103
  }
@@ -9263,6 +9358,11 @@ export class AgentSession {
9263
9358
  * candidate is small or the session has been idle long enough that the
9264
9359
  * provider prompt cache is cold), so it is cheap to run every turn. Gated
9265
9360
  * on the `compaction.supersedeReads` and `compaction.dropUseless` settings.
9361
+ *
9362
+ * Persists via `rewriteEntries` like every other history rewrite — the
9363
+ * session file must match the live (pruned) context or file-based forks
9364
+ * (`/fork`, `/tan`) and resume rebuild a divergent prefix and cold-miss the
9365
+ * provider prompt cache.
9266
9366
  */
9267
9367
  async #pruneStaleToolResults(): Promise<{ prunedCount: number; tokensSaved: number } | undefined> {
9268
9368
  const { supersedeReads, dropUseless } = this.settings.getGroup("compaction");
@@ -9285,6 +9385,7 @@ export class AgentSession {
9285
9385
  return undefined;
9286
9386
  }
9287
9387
 
9388
+ await this.sessionManager.rewriteEntries();
9288
9389
  const sessionContext = this.buildDisplaySessionContext();
9289
9390
  this.agent.replaceMessages(sessionContext.messages);
9290
9391
  this.#resetAllAdvisorRuntimes();
@@ -10383,21 +10484,21 @@ export class AgentSession {
10383
10484
 
10384
10485
  this.#emptyStopRetryCount++;
10385
10486
  if (this.#emptyStopRetryCount > EMPTY_STOP_MAX_RETRIES) {
10386
- logger.warn("Assistant returned empty stop after retry cap", {
10387
- attempts: this.#emptyStopRetryCount - 1,
10487
+ const attempts = this.#emptyStopRetryCount - 1;
10488
+ const finalError = "Assistant returned empty stop after retry cap";
10489
+ logger.warn(finalError, {
10490
+ attempts,
10388
10491
  model: assistantMessage.model,
10389
10492
  provider: assistantMessage.provider,
10390
10493
  });
10391
- if (this.#retryAttempt > 0) {
10392
- await this.#emitSessionEvent({
10393
- type: "auto_retry_end",
10394
- success: false,
10395
- attempt: this.#retryAttempt,
10396
- finalError: "Assistant returned empty stop after retry cap",
10397
- });
10398
- this.#retryAttempt = 0;
10399
- this.#refusalBackoff = undefined;
10400
- }
10494
+ await this.#emitSessionEvent({
10495
+ type: "auto_retry_end",
10496
+ success: false,
10497
+ attempt: this.#retryAttempt > 0 ? this.#retryAttempt : attempts,
10498
+ finalError,
10499
+ });
10500
+ this.#retryAttempt = 0;
10501
+ this.#refusalBackoff = undefined;
10401
10502
  this.#resolveRetry();
10402
10503
  // Tool-use orphans corrupt Anthropic message history (tool_result without
10403
10504
  // matching tool_use). Always remove them even when the retry cap is hit.
@@ -10891,7 +10992,7 @@ export class AgentSession {
10891
10992
  /**
10892
10993
  * Check if agent stopped with incomplete todos and prompt to continue.
10893
10994
  */
10894
- async #checkTodoCompletion(): Promise<boolean> {
10995
+ async #checkTodoCompletion(message: AssistantMessage): Promise<boolean> {
10895
10996
  // Skip todo reminders when the most recent turn was driven by an explicit user force —
10896
10997
  // the user wanted exactly that tool, not a follow-up nag about incomplete todos.
10897
10998
  const lastServedLabel = this.#toolChoiceQueue.consumeLastServedLabel();
@@ -10949,6 +11050,13 @@ export class AgentSession {
10949
11050
  return false;
10950
11051
  }
10951
11052
 
11053
+ if (isAwaitingUserAnswer(message)) {
11054
+ logger.debug("Todo completion: assistant is waiting for user input; skipping reminder", {
11055
+ incomplete: incomplete.length,
11056
+ });
11057
+ return false;
11058
+ }
11059
+
10952
11060
  // Build reminder message
10953
11061
  this.#todoReminderCount++;
10954
11062
  const todoList = incompleteByPhase
@@ -13078,6 +13186,33 @@ export class AgentSession {
13078
13186
  if (AIError.is(id, AIError.Flag.AuthFailed)) return false;
13079
13187
  return this.#modelRegistry.find("fireworks", toFireworksBaseModelId(model.id)) !== undefined;
13080
13188
  }
13189
+ /**
13190
+ * True when a turn failed with a hard (non-retryable) provider error but a
13191
+ * configured `retry.fallbackChains` entry covers the active model: the same
13192
+ * model is not worth retrying, yet a DIFFERENT model is a fresh chance, so
13193
+ * the chain is consulted before the error becomes final. Skips failures a
13194
+ * model switch cannot fix or must not replay: cancellations (abort-flavored
13195
+ * errors are not model faults), context overflow (compaction's job),
13196
+ * classifier refusals (chain consult is handled on the retryable path with
13197
+ * `pinFallback`), and turns that already emitted a tool call (replaying
13198
+ * could duplicate work).
13199
+ */
13200
+ #isHardErrorFallbackEligible(message: AssistantMessage): boolean {
13201
+ if (message.stopReason !== "error") return false;
13202
+ const model = this.model;
13203
+ if (!model) return false;
13204
+ const retrySettings = this.settings.getGroup("retry");
13205
+ if (!retrySettings.enabled || !retrySettings.modelFallback) return false;
13206
+ if (this.#isClassifierRefusal(message)) return false;
13207
+ const id = this.#classifyRetryMessage(message);
13208
+ if (AIError.is(id, AIError.Flag.Abort) || AIError.is(id, AIError.Flag.UserInterrupt)) return false;
13209
+ if (AIError.isContextOverflow(message, model.contextWindow ?? 0)) return false;
13210
+ if (this.#hasReplayUnsafeToolOutput(message)) return false;
13211
+ const currentSelector = formatRetryFallbackSelector(model, this.thinkingLevel);
13212
+ const role = this.#activeRetryFallback?.role ?? this.#resolveRetryFallbackRole(currentSelector);
13213
+ if (!role) return false;
13214
+ return this.#findRetryFallbackCandidates(role, currentSelector).length > 0;
13215
+ }
13081
13216
 
13082
13217
  /**
13083
13218
  * Switch the active model from a Fireworks Fast (`-fast`) variant to its base
@@ -13208,12 +13343,16 @@ export class AgentSession {
13208
13343
  }
13209
13344
 
13210
13345
  /**
13211
- * Handle retryable errors with exponential backoff.
13346
+ * Handle retryable errors with exponential backoff, credential rotation, and
13347
+ * model-fallback chains. Also entered for NON-retryable errors when a switch
13348
+ * is the recovery (`fireworksFastFallback`, `hardErrorFallback`): then a
13349
+ * successful model switch retries immediately, and a failed switch surfaces
13350
+ * the error without a same-model backoff retry.
13212
13351
  * @returns true if retry was initiated, false if max retries exceeded or disabled
13213
13352
  */
13214
13353
  async #handleRetryableError(
13215
13354
  message: AssistantMessage,
13216
- options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean },
13355
+ options?: { allowModelFallback?: boolean; fireworksFastFallback?: boolean; hardErrorFallback?: boolean },
13217
13356
  ): Promise<boolean> {
13218
13357
  const retrySettings = this.settings.getGroup("retry");
13219
13358
  // The Fireworks Fast→base degrade is an intrinsic model-selection safety net,
@@ -13399,11 +13538,16 @@ export class AgentSession {
13399
13538
  this.#retryAttempt = Math.min(this.#retryAttempt, 1);
13400
13539
  delayMs = refusalBackoffDelayMs(backoff.attempt);
13401
13540
  }
13402
- // Fast→base was requested but the base switch could not happen (e.g. the
13403
- // base model has no credential). Don't fall through to backing-off and
13404
- // retrying the failing fast model for a hard router error that the generic
13405
- // classifier wouldn't retry — surface it instead.
13406
- if (options?.fireworksFastFallback && !switchedModel && !this.#isRetryableError(message)) {
13541
+ // A fallback switch was the whole reason we entered (Fast→base degrade or
13542
+ // a hard-error chain consult) but it could not happen (e.g. no candidate
13543
+ // has a credential). Don't fall through to backing-off and retrying the
13544
+ // failing model for an error the generic classifier wouldn't retry —
13545
+ // surface it instead.
13546
+ if (
13547
+ (options?.fireworksFastFallback || options?.hardErrorFallback) &&
13548
+ !switchedModel &&
13549
+ !this.#isRetryableError(message)
13550
+ ) {
13407
13551
  this.#retryAttempt = 0;
13408
13552
  this.#resolveRetry();
13409
13553
  return false;
@@ -14102,10 +14246,13 @@ export class AgentSession {
14102
14246
  // Side-channel turns must not share OpenAI/Codex append-only
14103
14247
  // conversation state with the main agent turn: IRC and /btw can run
14104
14248
  // while the main turn is mid-tool-call. Keep the prompt-cache key
14105
- // stable, but give provider routing a unique request lineage.
14249
+ // stable, but give provider routing a unique request lineage. The
14250
+ // shared provider state map is still required so Codex can allocate
14251
+ // websocket state under that side-channel session id.
14106
14252
  sessionId: `${cacheSessionId}:side:${Snowflake.next()}`,
14107
14253
  promptCacheKey: cacheSessionId,
14108
- preferWebsockets: false,
14254
+ preferWebsockets: this.#preferWebsockets,
14255
+ providerSessionState: this.#providerSessionState,
14109
14256
  reasoning: toReasoningEffort(this.thinkingLevel),
14110
14257
  disableReasoning: shouldDisableReasoning(this.thinkingLevel),
14111
14258
  hideThinkingSummary: this.agent.hideThinkingSummary,
@@ -9,13 +9,13 @@
9
9
  * Precedence (highest first):
10
10
  * 1. `JEOPI_AUTH_BROKER_URL` / `JEOPI_AUTH_BROKER_TOKEN` env vars.
11
11
  * 2. `auth.broker.url` / `auth.broker.token` in `~/.jeopi/agent/config.yml`
12
- * (hidden from the settings UI; `!command` resolution supported).
12
+ * or `~/.jeopi/agent/config.yaml` (hidden from the settings UI; `!command` resolution supported).
13
13
  * 3. Token file `~/.jeopi/auth-broker.token` (paired with URL from env or config).
14
14
  *
15
15
  * Returns null when no broker URL is configured — caller falls back to the
16
16
  * local SQLite store.
17
17
  *
18
- * Reads config.yml directly (instead of going through `Settings.init`) because
18
+ * Reads config YAML directly (instead of going through `Settings.init`) because
19
19
  * `discoverAuthStorage` runs before the settings singleton is initialized in
20
20
  * `runRootCommand`, and we want hand-edited config entries to be honoured at
21
21
  * boot without forcing a startup reorder.
@@ -36,10 +36,10 @@ export { type AuthBrokerClientConfig, getAuthBrokerTokenFilePath };
36
36
 
37
37
  /**
38
38
  * Process-lifetime memo for {@link resolveAuthBrokerConfig}. Keyed on the env
39
- * inputs (plus agent dir, which decides which config.yml is read) so tests
39
+ * inputs (plus agent dir, which decides which config YAML is read) so tests
40
40
  * that flip `JEOPI_AUTH_BROKER_*` between cases still observe the change, while
41
41
  * repeated resolution within one CLI invocation (startup, subagent sessions)
42
- * skips the config.yml read and any `!command` token resolution.
42
+ * skips the config YAML read and any `!command` token resolution.
43
43
  */
44
44
  let cachedConfigKey: string | null = null;
45
45
  let cachedConfigPromise: Promise<AuthBrokerClientConfig | null> | null = null;
@@ -16,7 +16,6 @@
16
16
 
17
17
  import { countTokens } from "jeopi-agent-core";
18
18
  import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "jeopi-ai";
19
- import { isPersonalGitHubCopilotBaseUrl } from "jeopi-catalog/wire/github-copilot";
20
19
  import * as snapcompact from "jeopi-snapcompact";
21
20
  import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
22
21
  import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -78,19 +77,6 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
78
77
  return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
79
78
  }
80
79
 
81
- /**
82
- * The model is vision-capable for the endpoint we're actually about to hit.
83
- * GitHub Copilot business and enterprise hosts respond `400 vision is not
84
- * supported` on image inputs (issue #3387), so even if a stale cached spec
85
- * still advertises `["text","image"]` we MUST not rasterize transcripts when
86
- * the resolved `baseUrl` is non-personal.
87
- */
88
- function canSendImages(model: Model): boolean {
89
- if (!model.input.includes("image")) return false;
90
- if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
91
- return true;
92
- }
93
-
94
80
  interface SystemPromptImageTarget {
95
81
  scope: Exclude<SnapcompactSystemPromptMode, "none">;
96
82
  text: string;
@@ -291,7 +277,7 @@ export function estimateInlineSavings(input: {
291
277
  messages: readonly InlineMessageView[];
292
278
  }): SnapcompactSavingsEstimate {
293
279
  const { options, model } = input;
294
- if (!model || !canSendImages(model)) {
280
+ if (!model?.input.includes("image")) {
295
281
  return { visionCapable: false, savedTokens: 0 };
296
282
  }
297
283
 
@@ -430,10 +416,8 @@ export class SnapcompactInlineTransformer {
430
416
 
431
417
  async transform(context: Context, model: Model): Promise<Context> {
432
418
  // Vision gate: providers silently DROP images on text-only models —
433
- // rendering would lose the content entirely. Also short-circuits when the
434
- // resolved endpoint rejects vision regardless of the model's input list
435
- // (issue #3387: Copilot business endpoint).
436
- if (!canSendImages(model)) return context;
419
+ // rendering would lose the content entirely.
420
+ if (!model.input.includes("image")) return context;
437
421
 
438
422
  const shape = snapcompact.resolveShape(model, this.options.shape);
439
423
  const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);
@@ -735,6 +735,7 @@ export class OutputSink {
735
735
  #truncated = false;
736
736
  #lastChunkTime = 0;
737
737
  #pendingChunk = "";
738
+ #pendingChunkTimer: Timer | undefined;
738
739
 
739
740
  // Per-line column cap streaming state (persists across `push` calls so a
740
741
  // long line split across chunks still trips the same trigger).
@@ -806,20 +807,18 @@ export class OutputSink {
806
807
  push(chunk: string): void {
807
808
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
808
809
 
809
- // Throttled onChunk: coalesce chunks arriving inside the throttle window
810
- // and flush the buffered concatenation on the next eligible tick (plus a
811
- // final flush in dump()) so the preview never has silent gaps.
810
+ // Throttled onChunk: coalesce chunks arriving inside the throttle window.
811
+ // A timer flushes quiet tails at the throttle boundary; dump() catches a
812
+ // final pending chunk when the process exits before that timer fires.
812
813
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
813
814
  // what reached the sink — the column cap is for the persisted LLM view.
814
815
  if (this.#onChunk) {
815
816
  const now = Date.now();
816
817
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
817
- this.#lastChunkTime = now;
818
- const merged = this.#pendingChunk + chunk;
819
- this.#pendingChunk = "";
820
- this.#onChunk(merged);
818
+ this.#emitPendingChunkWith(chunk, now);
821
819
  } else {
822
820
  this.#pendingChunk += chunk;
821
+ this.#schedulePendingChunkFlush();
823
822
  }
824
823
  }
825
824
 
@@ -1121,6 +1120,7 @@ export class OutputSink {
1121
1120
  * branch in `dump()` against stale totals.
1122
1121
  */
1123
1122
  replace(text: string): void {
1123
+ this.#clearPendingChunkTimer();
1124
1124
  this.#buffer = text;
1125
1125
  this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1126
1126
  this.#head = "";
@@ -1138,6 +1138,38 @@ export class OutputSink {
1138
1138
  this.#pendingChunk = "";
1139
1139
  }
1140
1140
 
1141
+ #clearPendingChunkTimer(): void {
1142
+ if (!this.#pendingChunkTimer) return;
1143
+ clearTimeout(this.#pendingChunkTimer);
1144
+ this.#pendingChunkTimer = undefined;
1145
+ }
1146
+
1147
+ #emitPendingChunkWith(chunk: string, now: number): void {
1148
+ this.#clearPendingChunkTimer();
1149
+ this.#lastChunkTime = now;
1150
+ const merged = this.#pendingChunk + chunk;
1151
+ this.#pendingChunk = "";
1152
+ this.#onChunk?.(merged);
1153
+ }
1154
+
1155
+ #flushPendingChunk(): void {
1156
+ if (this.#pendingChunk.length === 0) {
1157
+ this.#clearPendingChunkTimer();
1158
+ return;
1159
+ }
1160
+ this.#emitPendingChunkWith("", Date.now());
1161
+ }
1162
+
1163
+ #schedulePendingChunkFlush(): void {
1164
+ if (this.#chunkThrottleMs <= 0 || this.#pendingChunkTimer) return;
1165
+ const elapsed = Date.now() - this.#lastChunkTime;
1166
+ const delay = Math.max(0, this.#chunkThrottleMs - elapsed);
1167
+ this.#pendingChunkTimer = setTimeout(() => {
1168
+ this.#pendingChunkTimer = undefined;
1169
+ this.#flushPendingChunk();
1170
+ }, delay);
1171
+ }
1172
+
1141
1173
  /**
1142
1174
  * Replay the rolling tail ring back into the artifact sink. When bytes
1143
1175
  * were actually dropped from the middle (the head budget was exhausted
@@ -1179,11 +1211,7 @@ export class OutputSink {
1179
1211
 
1180
1212
  // Flush any chunk still held back by the throttle so the live preview
1181
1213
  // ends with the complete stream.
1182
- if (this.#onChunk && this.#pendingChunk.length > 0) {
1183
- const pending = this.#pendingChunk;
1184
- this.#pendingChunk = "";
1185
- this.#onChunk(pending);
1186
- }
1214
+ this.#flushPendingChunk();
1187
1215
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1188
1216
 
1189
1217
  if (this.#file) {
@@ -34,7 +34,12 @@ import { resolveResumableSession } from "../session/session-listing";
34
34
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
35
35
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
36
36
  import { urlHyperlinkAlways } from "../tui";
37
- import { getChangelogPath, parseChangelog } from "../utils/changelog";
37
+ import {
38
+ getChangelogPath,
39
+ parseChangelog,
40
+ RECENT_CHANGELOG_ENTRY_LIMIT,
41
+ renderChangelogEntries,
42
+ } from "../utils/changelog";
38
43
  import { copyToClipboard } from "../utils/clipboard";
39
44
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
40
45
  import { buildContextReportText } from "./helpers/context-report";
@@ -1100,17 +1105,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1100
1105
  const changelogPath = getChangelogPath();
1101
1106
  const allEntries = await parseChangelog(changelogPath);
1102
1107
  const showFull = command.args.trim().toLowerCase() === "full";
1103
- const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
1108
+ const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
1104
1109
  if (entriesToShow.length === 0) {
1105
1110
  await runtime.output("No changelog entries found.");
1106
1111
  return commandConsumed();
1107
1112
  }
1108
- await runtime.output(
1109
- [...entriesToShow]
1110
- .reverse()
1111
- .map(entry => entry.content)
1112
- .join("\n\n"),
1113
- );
1113
+ await runtime.output(renderChangelogEntries(entriesToShow).markdown);
1114
1114
  return commandConsumed();
1115
1115
  },
1116
1116
  handleTui: async (command, runtime) => {