@oh-my-pi/pi-coding-agent 16.0.2 → 16.0.4

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 (97) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +0 -1
  3. package/dist/cli.js +580 -359
  4. package/dist/types/advisor/advise-tool.d.ts +30 -1
  5. package/dist/types/cli/args.d.ts +1 -0
  6. package/dist/types/commands/install.d.ts +1 -1
  7. package/dist/types/commands/launch.d.ts +3 -0
  8. package/dist/types/config/model-resolver.d.ts +8 -0
  9. package/dist/types/config/settings-schema.d.ts +1 -11
  10. package/dist/types/edit/file-snapshot-store.d.ts +2 -0
  11. package/dist/types/eval/js/shared/runtime.d.ts +1 -0
  12. package/dist/types/eval/js/worker-core.d.ts +1 -0
  13. package/dist/types/extensibility/extensions/loader.d.ts +2 -2
  14. package/dist/types/goals/runtime.d.ts +0 -1
  15. package/dist/types/mcp/tool-bridge.d.ts +3 -0
  16. package/dist/types/modes/components/custom-editor.d.ts +14 -4
  17. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  18. package/dist/types/modes/interactive-mode.d.ts +1 -1
  19. package/dist/types/modes/setup-wizard/wizard-overlay.d.ts +3 -2
  20. package/dist/types/modes/theme/mermaid-cache.d.ts +18 -1
  21. package/dist/types/modes/types.d.ts +1 -1
  22. package/dist/types/registry/agent-lifecycle.d.ts +16 -1
  23. package/dist/types/sdk.d.ts +8 -0
  24. package/dist/types/session/agent-session.d.ts +20 -8
  25. package/dist/types/session/session-dump-format.d.ts +8 -2
  26. package/dist/types/session/session-entries.d.ts +4 -0
  27. package/dist/types/session/session-history-format.d.ts +2 -0
  28. package/dist/types/session/session-manager.d.ts +22 -0
  29. package/dist/types/stt/downloader.d.ts +5 -5
  30. package/dist/types/task/executor.d.ts +6 -0
  31. package/dist/types/task/persisted-revive.d.ts +36 -0
  32. package/dist/types/tiny/models.d.ts +8 -0
  33. package/dist/types/tools/builtin-names.d.ts +1 -1
  34. package/dist/types/tools/index.d.ts +0 -1
  35. package/package.json +12 -12
  36. package/src/advisor/__tests__/advisor.test.ts +150 -50
  37. package/src/advisor/advise-tool.ts +48 -6
  38. package/src/advisor/runtime.ts +10 -3
  39. package/src/auto-thinking/classifier.ts +12 -3
  40. package/src/cli/args.ts +3 -0
  41. package/src/cli/flag-tables.ts +1 -0
  42. package/src/cli.ts +2 -2
  43. package/src/commands/install.ts +3 -3
  44. package/src/commands/launch.ts +3 -0
  45. package/src/config/model-resolver.ts +28 -11
  46. package/src/config/settings-schema.ts +1 -12
  47. package/src/edit/file-snapshot-store.ts +12 -3
  48. package/src/eval/agent-bridge.ts +2 -0
  49. package/src/eval/js/context-manager.ts +2 -1
  50. package/src/eval/js/shared/runtime.ts +189 -15
  51. package/src/eval/js/worker-core.ts +19 -0
  52. package/src/export/html/index.ts +1 -1
  53. package/src/export/html/tool-views.generated.js +34 -35
  54. package/src/extensibility/extensions/loader.ts +21 -9
  55. package/src/goals/runtime.ts +1 -23
  56. package/src/internal-urls/docs-index.generated.ts +82 -84
  57. package/src/main.ts +26 -4
  58. package/src/mcp/render.ts +11 -1
  59. package/src/mcp/tool-bridge.ts +3 -0
  60. package/src/modes/components/custom-editor.test.ts +63 -18
  61. package/src/modes/components/custom-editor.ts +63 -15
  62. package/src/modes/components/tips.txt +2 -1
  63. package/src/modes/controllers/command-controller.ts +2 -2
  64. package/src/modes/controllers/input-controller.ts +15 -9
  65. package/src/modes/controllers/selector-controller.ts +13 -8
  66. package/src/modes/controllers/tan-command-controller.ts +1 -0
  67. package/src/modes/interactive-mode.ts +4 -2
  68. package/src/modes/setup-wizard/wizard-overlay.ts +26 -4
  69. package/src/modes/theme/mermaid-cache.ts +74 -11
  70. package/src/modes/theme/theme.ts +14 -1
  71. package/src/modes/types.ts +1 -1
  72. package/src/prompts/system/system-prompt.md +4 -1
  73. package/src/registry/agent-lifecycle.ts +60 -8
  74. package/src/sdk.ts +20 -26
  75. package/src/session/agent-session.ts +253 -82
  76. package/src/session/artifacts.ts +19 -1
  77. package/src/session/session-dump-format.ts +167 -23
  78. package/src/session/session-entries.ts +4 -0
  79. package/src/session/session-history-format.ts +37 -3
  80. package/src/session/session-manager.ts +94 -4
  81. package/src/slash-commands/builtin-registry.ts +4 -7
  82. package/src/stt/asr-client.ts +6 -0
  83. package/src/stt/downloader.ts +13 -6
  84. package/src/stt/stt-controller.ts +52 -11
  85. package/src/task/executor.ts +18 -2
  86. package/src/task/index.ts +2 -2
  87. package/src/task/persisted-revive.ts +128 -0
  88. package/src/tiny/models.ts +10 -0
  89. package/src/tiny/worker.ts +4 -3
  90. package/src/tools/builtin-names.ts +0 -1
  91. package/src/tools/index.ts +0 -4
  92. package/src/tools/output-meta.ts +17 -3
  93. package/src/tools/read.ts +26 -0
  94. package/src/utils/title-generator.ts +4 -4
  95. package/dist/types/tools/render-mermaid.d.ts +0 -38
  96. package/src/prompts/tools/render-mermaid.md +0 -9
  97. package/src/tools/render-mermaid.ts +0 -69
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "16.0.2",
4
+ "version": "16.0.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,17 +47,17 @@
47
47
  "@agentclientprotocol/sdk": "0.25.0",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "16.0.2",
51
- "@oh-my-pi/omp-stats": "16.0.2",
52
- "@oh-my-pi/pi-agent-core": "16.0.2",
53
- "@oh-my-pi/pi-ai": "16.0.2",
54
- "@oh-my-pi/pi-catalog": "16.0.2",
55
- "@oh-my-pi/pi-mnemopi": "16.0.2",
56
- "@oh-my-pi/pi-natives": "16.0.2",
57
- "@oh-my-pi/pi-tui": "16.0.2",
58
- "@oh-my-pi/pi-utils": "16.0.2",
59
- "@oh-my-pi/pi-wire": "16.0.2",
60
- "@oh-my-pi/snapcompact": "16.0.2",
50
+ "@oh-my-pi/hashline": "16.0.4",
51
+ "@oh-my-pi/omp-stats": "16.0.4",
52
+ "@oh-my-pi/pi-agent-core": "16.0.4",
53
+ "@oh-my-pi/pi-ai": "16.0.4",
54
+ "@oh-my-pi/pi-catalog": "16.0.4",
55
+ "@oh-my-pi/pi-mnemopi": "16.0.4",
56
+ "@oh-my-pi/pi-natives": "16.0.4",
57
+ "@oh-my-pi/pi-tui": "16.0.4",
58
+ "@oh-my-pi/pi-utils": "16.0.4",
59
+ "@oh-my-pi/pi-wire": "16.0.4",
60
+ "@oh-my-pi/snapcompact": "16.0.4",
61
61
  "@opentelemetry/api": "^1.9.1",
62
62
  "@opentelemetry/context-async-hooks": "^2.7.1",
63
63
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -2,17 +2,18 @@ import { describe, expect, it, vi } from "bun:test";
2
2
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
3
  import { createAdvisorMessageCard } from "../../modes/components/advisor-message";
4
4
  import { getThemeByName } from "../../modes/theme/theme";
5
- import { formatSessionDumpText } from "../../session/session-dump-format";
6
5
  import { formatSessionHistoryMarkdown } from "../../session/session-history-format";
7
6
  import { YieldQueue } from "../../session/yield-queue";
8
7
  import {
9
8
  ADVISOR_READONLY_TOOL_NAMES,
10
9
  AdviseTool,
11
10
  type AdvisorAgent,
11
+ type AdvisorNote,
12
12
  AdvisorRuntime,
13
13
  type AdvisorRuntimeHost,
14
14
  formatAdvisorBatchContent,
15
15
  isInterruptingSeverity,
16
+ resolveAdvisorDeliveryChannel,
16
17
  } from "..";
17
18
 
18
19
  describe("advisor", () => {
@@ -52,7 +53,7 @@ describe("advisor", () => {
52
53
  },
53
54
  scheduleIdleFlush: () => {},
54
55
  });
55
- yq.register<{ note: string; severity?: "nit" | "concern" | "blocker" }>("advisor", {
56
+ yq.register<AdvisorNote>("advisor", {
56
57
  build: entries =>
57
58
  entries.length === 0
58
59
  ? null
@@ -62,9 +63,7 @@ describe("advisor", () => {
62
63
  display: true,
63
64
  attribution: "agent",
64
65
  timestamp: Date.now(),
65
- content:
66
- "Advisor (a senior reviewer watching your work — weigh it, don't blindly obey):\n" +
67
- entries.map(e => `- ${e.severity ? `[${e.severity}] ` : ""}${e.note}`).join("\n"),
66
+ content: formatAdvisorBatchContent(entries),
68
67
  } as AgentMessage),
69
68
  });
70
69
 
@@ -77,8 +76,9 @@ describe("advisor", () => {
77
76
  expect(msg.role).toBe("custom");
78
77
  expect(msg.customType).toBe("advisor");
79
78
  expect(msg.display).toBe(true);
80
- expect(msg.content).toContain("[blocker] second note");
81
- expect(msg.content).toContain("- first note");
79
+ expect(msg.content).toContain("second note");
80
+ expect(msg.content).toContain('severity="blocker"');
81
+ expect(msg.content).toContain("first note");
82
82
  });
83
83
 
84
84
  it("skipIdleFlush prevents idle scheduling", () => {
@@ -124,15 +124,21 @@ describe("advisor", () => {
124
124
  expect(isInterruptingSeverity(undefined)).toBe(false);
125
125
  });
126
126
 
127
- it("formats a batch with the advisor prefix and severity-tagged bullets", () => {
127
+ it("wraps each note in an advisory tag with severity as an attribute and escapes the body", () => {
128
128
  const content = formatAdvisorBatchContent([
129
129
  { note: "first note" },
130
- { note: "second note", severity: "blocker" },
130
+ { note: "second <note> & more", severity: "blocker" },
131
131
  ]);
132
- const lines = content.split("\n");
133
- expect(lines[0]).toContain("senior reviewer");
134
- expect(lines[1]).toBe("- first note");
135
- expect(lines[2]).toBe("- [blocker] second note");
132
+ // No-severity note: bare advisory tag (no severity attribute).
133
+ expect(content).toMatch(/<advisory guidance="[^"]*">\nfirst note\n<\/advisory>/);
134
+ // Severity rides an attribute, not an inline `[blocker]` tag or a bullet.
135
+ expect(content).toMatch(/<advisory severity="blocker" guidance="[^"]*">/);
136
+ expect(content).not.toContain("[blocker]");
137
+ expect(content).not.toContain("- first note");
138
+ // XML-significant characters in the body are escaped so they can't break the tag.
139
+ expect(content).toContain("second &lt;note&gt; &amp; more");
140
+ // Exactly one severity attribute (only the blocker note carries one).
141
+ expect(content.split('severity="').length - 1).toBe(1);
136
142
  });
137
143
  });
138
144
 
@@ -279,6 +285,56 @@ describe("advisor", () => {
279
285
  expect(promptInputs[0]).not.toContain("note");
280
286
  });
281
287
 
288
+ it("renders the watched delta with a heading, watched-role labels, and no inner ## headings", () => {
289
+ const promptInputs: string[] = [];
290
+ const agent = makeAgent(promptInputs);
291
+ const messages: AgentMessage[] = [
292
+ { role: "user", content: "do the thing", timestamp: 1 } as AgentMessage,
293
+ {
294
+ role: "assistant",
295
+ content: [{ type: "toolCall", id: "a", name: "read", arguments: { path: "x.ts" } }],
296
+ timestamp: 2,
297
+ } as unknown as AgentMessage,
298
+ {
299
+ role: "toolResult",
300
+ toolCallId: "a",
301
+ toolName: "read",
302
+ content: [{ type: "text", text: "ok" }],
303
+ isError: false,
304
+ timestamp: 3,
305
+ } as AgentMessage,
306
+ {
307
+ role: "assistant",
308
+ content: [{ type: "toolCall", id: "b", name: "search", arguments: { pattern: "y" } }],
309
+ timestamp: 4,
310
+ } as unknown as AgentMessage,
311
+ {
312
+ role: "toolResult",
313
+ toolCallId: "b",
314
+ toolName: "search",
315
+ content: [{ type: "text", text: "ok" }],
316
+ isError: false,
317
+ timestamp: 5,
318
+ } as AgentMessage,
319
+ ];
320
+ const host: AdvisorRuntimeHost = {
321
+ snapshotMessages: () => messages,
322
+ enqueueAdvice: () => {},
323
+ };
324
+ const runtime = new AdvisorRuntime(agent, host);
325
+ runtime.onTurnEnd();
326
+ expect(promptInputs).toHaveLength(1);
327
+ const prompt = promptInputs[0];
328
+ expect(prompt).toContain("### Session update");
329
+ expect(prompt).toContain("**user**:");
330
+ expect(prompt).toContain("**agent**:");
331
+ // Inner role headings would collide with the advisor's own turns in the dump.
332
+ expect(prompt).not.toContain("## assistant");
333
+ expect(prompt).not.toContain("## user");
334
+ // Consecutive assistant tool-call messages collapse under a single label.
335
+ expect(prompt.split("**agent**:").length - 1).toBe(1);
336
+ });
337
+
282
338
  it("handles compaction shrink without prompting", () => {
283
339
  const promptInputs: string[] = [];
284
340
  const agent = makeAgent(promptInputs);
@@ -584,47 +640,91 @@ describe("advisor", () => {
584
640
  expect(text).toContain("truncated.");
585
641
  });
586
642
  });
587
- describe("formatSessionDumpText raw thinking", () => {
588
- it("does not nest literal thinking envelopes", () => {
589
- const md = formatSessionDumpText({
590
- messages: [
591
- {
592
- role: "assistant",
593
- content: [
594
- {
595
- type: "thinking",
596
- thinking: "<thinking>\nCheck logs before accepting container health.\n</thinking>",
597
- },
598
- ],
599
- timestamp: Date.now(),
600
- } as AgentMessage,
601
- ],
602
- thinkingLevel: "high",
603
- });
604
643
 
605
- expect(md).toContain("Assistant: <thinking>\nCheck logs before accepting container health.\n</thinking>");
606
- expect(md).not.toContain("<thinking>\n<thinking>");
644
+ // Regression: the advisor must not withhold interrupting advice from a turn
645
+ // that is actively streaming again after a user interrupt. The latch only
646
+ // guards auto-resume of a stopped/idle run; parking a note mid-stream stranded
647
+ // it (the agent never heard it) and dumped the backlog as one burst at the next
648
+ // user prompt. See the 7-concern same-instant burst in session 019ed1dd.
649
+ //
650
+ // `streaming` here means the live agent-CORE loop (agent.state.isStreaming) —
651
+ // NOT session `isStreaming`, which also counts `#promptInFlightCount` during
652
+ // post-turn unwind. Only a running core loop consumes a steer; in the unwind
653
+ // window (`streaming: false`) a suppressed note must `preserve`, never `steer`,
654
+ // or it strands and #drainStrandedQueuedMessages auto-resumes it. Do not swap
655
+ // the call site back to session `isStreaming`.
656
+ describe("resolveAdvisorDeliveryChannel", () => {
657
+ it("routes a non-interrupting nit to the aside queue regardless of state", () => {
658
+ expect(
659
+ resolveAdvisorDeliveryChannel({
660
+ severity: "nit",
661
+ autoResumeSuppressed: true,
662
+ streaming: true,
663
+ aborting: true,
664
+ }),
665
+ ).toBe("aside");
666
+ expect(
667
+ resolveAdvisorDeliveryChannel({
668
+ severity: undefined,
669
+ autoResumeSuppressed: false,
670
+ streaming: false,
671
+ aborting: false,
672
+ }),
673
+ ).toBe("aside");
607
674
  });
608
675
 
609
- it("unwraps sibling literal thinking envelopes independently", () => {
610
- const md = formatSessionDumpText({
611
- messages: [
612
- {
613
- role: "assistant",
614
- content: [
615
- { type: "thinking", thinking: "<thinking>\nfirst\n</thinking>" },
616
- { type: "toolCall", id: "tc-1", name: "read", arguments: { path: "file.ts" } },
617
- { type: "thinking", thinking: "<thinking>\nsecond\n</thinking>" },
618
- ],
619
- timestamp: Date.now(),
620
- } as AgentMessage,
621
- ],
622
- tools: [{ name: "read", description: "Read a file", parameters: { type: "object" } }],
623
- thinkingLevel: "high",
624
- });
676
+ it("steers concern/blocker when no user interrupt is in effect", () => {
677
+ for (const severity of ["concern", "blocker"] as const) {
678
+ for (const streaming of [true, false]) {
679
+ expect(
680
+ resolveAdvisorDeliveryChannel({
681
+ severity,
682
+ autoResumeSuppressed: false,
683
+ streaming,
684
+ aborting: false,
685
+ }),
686
+ ).toBe("steer");
687
+ }
688
+ }
689
+ });
690
+
691
+ it("preserves an interrupting note while suppressed AND idle (no auto-resume of a stopped run)", () => {
692
+ for (const severity of ["concern", "blocker"] as const) {
693
+ expect(
694
+ resolveAdvisorDeliveryChannel({
695
+ severity,
696
+ autoResumeSuppressed: true,
697
+ streaming: false,
698
+ aborting: false,
699
+ }),
700
+ ).toBe("preserve");
701
+ }
702
+ });
703
+
704
+ it("preserves an interrupting note while suppressed AND aborting, even though the turn still reports streaming", () => {
705
+ // Mid-abort teardown: steering would land after #extractQueuedAdvisorCards
706
+ // and could auto-resume on the stranded steer. Keep parking it.
707
+ expect(
708
+ resolveAdvisorDeliveryChannel({
709
+ severity: "blocker",
710
+ autoResumeSuppressed: true,
711
+ streaming: true,
712
+ aborting: true,
713
+ }),
714
+ ).toBe("preserve");
715
+ });
625
716
 
626
- expect(md).toContain("Assistant: <thinking>\nfirst\nsecond\n</thinking>");
627
- expect(md).not.toContain("first\n</thinking>\n<thinking>\nsecond");
717
+ it("steers an interrupting note while suppressed once a turn is streaming again and not aborting (the fix)", () => {
718
+ for (const severity of ["concern", "blocker"] as const) {
719
+ expect(
720
+ resolveAdvisorDeliveryChannel({
721
+ severity,
722
+ autoResumeSuppressed: true,
723
+ streaming: true,
724
+ aborting: false,
725
+ }),
726
+ ).toBe("steer");
727
+ }
628
728
  });
629
729
  });
630
730
  });
@@ -1,4 +1,5 @@
1
1
  import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
2
+ import { escapeXmlText } from "@oh-my-pi/pi-utils";
2
3
  import { z } from "zod/v4";
3
4
  import adviseDescription from "../prompts/advisor/advise-tool.md" with { type: "text" };
4
5
 
@@ -33,15 +34,26 @@ export interface AdvisorMessageDetails {
33
34
  }
34
35
 
35
36
  /**
36
- * Prose framing prepended to every batched advisor message. Kept here so the
37
- * non-interrupting YieldQueue dispatcher and the interrupting steer path build
38
- * byte-identical content.
37
+ * Behavioral framing for the watched agent advice, not orders. Carried as a
38
+ * tag attribute (rather than a prose header) so the rendered agent-facing output
39
+ * stays a clean `<advisory>` block. The primary agent's system prompt never
40
+ * mentions advisories, so this is its only cue for how to treat them.
39
41
  */
40
- const ADVISOR_BATCH_PREFIX = "Advisor (a senior reviewer watching your work — weigh it, don't blindly obey):";
42
+ const ADVISOR_GUIDANCE = "weigh, don't blindly obey";
41
43
 
42
- /** Render one advisor card body from a batch of notes (prefix + one bullet per note). */
44
+ /**
45
+ * Render a batch of advisor notes as the agent-facing message body: one
46
+ * `<advisory>` element per note, severity as an attribute. Shared by the
47
+ * non-interrupting YieldQueue dispatcher and the interrupting steer path so both
48
+ * build byte-identical content.
49
+ */
43
50
  export function formatAdvisorBatchContent(notes: readonly AdvisorNote[]): string {
44
- return `${ADVISOR_BATCH_PREFIX}\n${notes.map(n => `- ${n.severity ? `[${n.severity}] ` : ""}${n.note}`).join("\n")}`;
51
+ return notes
52
+ .map(n => {
53
+ const severity = n.severity ? ` severity="${n.severity}"` : "";
54
+ return `<advisory${severity} guidance="${ADVISOR_GUIDANCE}">\n${escapeXmlText(n.note)}\n</advisory>`;
55
+ })
56
+ .join("\n");
45
57
  }
46
58
 
47
59
  /**
@@ -54,6 +66,36 @@ export function isInterruptingSeverity(severity: AdvisorSeverity | undefined): b
54
66
  return severity === "concern" || severity === "blocker";
55
67
  }
56
68
 
69
+ /** How an advisor note is routed to the primary. */
70
+ export type AdvisorDeliveryChannel = "aside" | "steer" | "preserve";
71
+
72
+ /**
73
+ * Decide how one advisor note reaches the primary agent.
74
+ *
75
+ * - A non-interrupting `nit` always rides the non-interrupting aside queue.
76
+ * - An interrupting `concern`/`blocker` is normally steered into the agent: into
77
+ * the live turn while one is streaming, or (when idle) a triggered turn so the
78
+ * advice is acted on immediately.
79
+ * - After a deliberate user interrupt (`autoResumeSuppressed`) the advisor must
80
+ * not auto-resume the stopped run. While the agent is idle — or still tearing
81
+ * the interrupted turn down (`aborting`) — the note is preserved as a visible
82
+ * card instead of restarting the run. But once a turn is actively streaming
83
+ * again (a resume the user already drove), steering the note in does NOT
84
+ * auto-resume anything, so it is delivered live. Parking it during an active
85
+ * run instead strands it (it never reaches the running agent) and the withheld
86
+ * notes dump as one burst at the next user prompt — the bug this guards.
87
+ */
88
+ export function resolveAdvisorDeliveryChannel(opts: {
89
+ severity: AdvisorSeverity | undefined;
90
+ autoResumeSuppressed: boolean;
91
+ streaming: boolean;
92
+ aborting: boolean;
93
+ }): AdvisorDeliveryChannel {
94
+ if (!isInterruptingSeverity(opts.severity)) return "aside";
95
+ if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
96
+ return "steer";
97
+ }
98
+
57
99
  /**
58
100
  * Side-effect-free investigation tools handed to the advisor agent so it can
59
101
  * inspect the workspace before weighing in. Names match the primary session's
@@ -157,8 +157,13 @@ export class AdvisorRuntime {
157
157
  .filter(m => !(m.role === "custom" && (m as { customType?: string }).customType === "advisor"));
158
158
  this.#lastCount = all.length;
159
159
  if (delta.length === 0) return null;
160
- const md = formatSessionHistoryMarkdown(delta, { includeThinking: true, includeToolIntent: true });
161
- return md.trim() ? md : null;
160
+ const md = formatSessionHistoryMarkdown(delta, {
161
+ includeThinking: true,
162
+ includeToolIntent: true,
163
+ watchedRoles: true,
164
+ });
165
+ if (!md.trim()) return null;
166
+ return `### Session update\n\n${md}`;
162
167
  }
163
168
 
164
169
  #notifyWaiters(): void {
@@ -182,7 +187,9 @@ export class AdvisorRuntime {
182
187
  try {
183
188
  while (!this.disposed && this.#pending.length) {
184
189
  const popped = this.#pending.splice(0);
185
- const candidateBatch = popped.map(b => b.text).join("\n\n---\n\n");
190
+ // Each delta already opens with a `### Session update` heading, so
191
+ // join with a blank line rather than a `---` rule.
192
+ const candidateBatch = popped.map(b => b.text).join("\n\n");
186
193
  const turnsCovered = popped.reduce((sum, b) => sum + b.turns, 0);
187
194
  const incomingTokens = estimateTokens({
188
195
  role: "user",
@@ -22,7 +22,11 @@ import type { Settings } from "../config/settings";
22
22
  import difficultySystemPrompt from "../prompts/system/auto-thinking-difficulty.md" with { type: "text" };
23
23
  import difficultyLocalPrompt from "../prompts/system/auto-thinking-difficulty-local.md" with { type: "text" };
24
24
  import { clampAutoThinkingEffort } from "../thinking";
25
- import { isTinyMemoryLocalModelKey, ONLINE_AUTO_THINKING_MODEL_KEY } from "../tiny/models";
25
+ import {
26
+ isTinyMemoryLocalModelKey,
27
+ isTinyMemoryReasoningModelKey,
28
+ ONLINE_AUTO_THINKING_MODEL_KEY,
29
+ } from "../tiny/models";
26
30
  import { tinyModelClient } from "../tiny/title-client";
27
31
 
28
32
  const DIFFICULTY_SYSTEM_PROMPT = prompt.render(difficultySystemPrompt);
@@ -31,8 +35,10 @@ const DIFFICULTY_SYSTEM_PROMPT = prompt.render(difficultySystemPrompt);
31
35
  const MAX_INPUT_CHARS = 6000;
32
36
  const HEAD_CHARS = 4000;
33
37
  const TAIL_CHARS = 2000;
34
- /** The answer is a single word; keep budgets tiny for non-reasoning backends. */
38
+ /** The online answer is a single word; keep budgets tiny for non-reasoning backends. */
35
39
  const ANSWER_MAX_TOKENS = 8;
40
+ /** Local classifiers occasionally need more room for chat-template boilerplate. */
41
+ const LOCAL_ANSWER_MAX_TOKENS = 16;
36
42
  /**
37
43
  * Reasoning backends ignore `disableReasoning` on some providers, so reserve
38
44
  * enough output room for the keyword to still land after unavoidable thinking.
@@ -107,9 +113,12 @@ async function classifyLocal(input: string, modelKey: string, deps: ClassifyDiff
107
113
  if (!isTinyMemoryLocalModelKey(modelKey)) {
108
114
  throw new Error(`auto-thinking: unsupported local classifier model: ${modelKey}`);
109
115
  }
116
+ const maxTokens = isTinyMemoryReasoningModelKey(modelKey)
117
+ ? Math.max(LOCAL_ANSWER_MAX_TOKENS, REASONING_SAFE_MAX_TOKENS)
118
+ : LOCAL_ANSWER_MAX_TOKENS;
110
119
  const builtPrompt = prompt.render(difficultyLocalPrompt, { prompt: input });
111
120
  const text = await tinyModelClient.complete(modelKey, builtPrompt, {
112
- maxTokens: ANSWER_MAX_TOKENS,
121
+ maxTokens,
113
122
  signal: deps.signal,
114
123
  });
115
124
  if (!text) {
package/src/cli/args.ts CHANGED
@@ -33,6 +33,7 @@ export interface Args {
33
33
  appendSystemPrompt?: string;
34
34
  thinking?: Effort;
35
35
  hideThinking?: boolean;
36
+ advisor?: boolean;
36
37
  continue?: boolean;
37
38
  resume?: string | true;
38
39
  help?: boolean;
@@ -194,6 +195,8 @@ export function parseArgs(inputArgs: string[], extensionFlags?: Map<string, { ty
194
195
  result.noPty = true;
195
196
  } else if (arg === "--hide-thinking") {
196
197
  result.hideThinking = true;
198
+ } else if (arg === "--advisor") {
199
+ result.advisor = true;
197
200
  } else if (arg === "--print" || arg === "-p") {
198
201
  result.print = true;
199
202
  } else if (arg === "--no-extensions") {
@@ -260,6 +260,7 @@ export const VALUELESS_FLAGS: ReadonlySet<string> = new Set([
260
260
  "--no-lsp",
261
261
  "--no-pty",
262
262
  "--hide-thinking",
263
+ "--advisor",
263
264
  "--print",
264
265
  "--no-extensions",
265
266
  "--no-skills",
package/src/cli.ts CHANGED
@@ -109,8 +109,8 @@ async function runWorkerEntrypoint(arg: string | undefined): Promise<boolean> {
109
109
  // this dispatch completes — so anything the parent posted right after
110
110
  // spawning (the smoke ping, the first parse request) would be dropped.
111
111
  // Park early events and replay them once the module's handler is live.
112
- // (The tab/eval workers are immune: `parentPort.on("message")` queues
113
- // until a listener attaches.)
112
+ // Worker-thread entries using `parentPort` need the same sync-prefix
113
+ // buffering; the tab/eval cases install that inbox below before import.
114
114
  const scope = globalThis as unknown as { onmessage: ((event: MessageEvent) => void) | null };
115
115
  const pending: MessageEvent[] = [];
116
116
  const buffer = (event: MessageEvent): void => {
@@ -28,13 +28,13 @@ import { initTheme } from "../modes/theme/theme";
28
28
  * Heuristic used to decide whether `omp install <target>` should `link` a
29
29
  * local directory or `install` a remote spec. Exported for tests.
30
30
  */
31
- export function looksLikeLocalPath(target: string): boolean {
31
+ export function looksLikeLocalPath(target: string, cwd?: string): boolean {
32
32
  if (target.startsWith(".") || target.startsWith("/") || target.startsWith("~")) return true;
33
33
  // Windows drive prefix (e.g. `C:\foo`).
34
34
  if (/^[a-zA-Z]:[\\/]/.test(target)) return true;
35
- // Bare names that happen to exist as a local directory.
35
+ // Bare names that happen to exist as a local directory (relative to `cwd`).
36
36
  try {
37
- return existsSync(path.resolve(target));
37
+ return existsSync(cwd ? path.resolve(cwd, target) : path.resolve(target));
38
38
  } catch {
39
39
  return false;
40
40
  }
@@ -106,6 +106,9 @@ export default class Index extends Command {
106
106
  "hide-thinking": Flags.boolean({
107
107
  description: "Hide thinking blocks in TUI output (display only, does not disable model thinking)",
108
108
  }),
109
+ advisor: Flags.boolean({
110
+ description: "Enable the advisor runtime (passively reviews each turn and injects notes)",
111
+ }),
109
112
  hook: Flags.string({
110
113
  description: "Load a hook/extension file (can be used multiple times)",
111
114
  multiple: true,
@@ -34,17 +34,36 @@ import { isAuthenticated, kNoAuth, type ModelRegistry } from "./model-registry";
34
34
  import { MODEL_ROLE_IDS, type ModelRole } from "./model-roles";
35
35
  import type { Settings } from "./settings";
36
36
 
37
+ function isKnownProvider(provider: string): provider is KnownProvider {
38
+ return provider in DEFAULT_MODEL_PER_PROVIDER;
39
+ }
40
+
37
41
  /**
38
- * Pick the first available model matching a known provider's default id
39
- * (catalog table order), falling back to the first available model.
42
+ * Pick the first provider-default model in availability order.
43
+ *
44
+ * If multiple providers expose that same default id, rank only that shared-id
45
+ * group by canonical provider priority so native/OAuth transports beat mirrors
46
+ * without changing unrelated provider fallback precedence.
40
47
  */
41
- function pickDefaultAvailableModel(availableModels: Model<Api>[]): Model<Api> | undefined {
42
- for (const provider of Object.keys(DEFAULT_MODEL_PER_PROVIDER) as KnownProvider[]) {
43
- const defaultId = DEFAULT_MODEL_PER_PROVIDER[provider];
44
- const match = availableModels.find(m => m.provider === provider && m.id === defaultId);
45
- if (match) return match;
46
- }
47
- return availableModels[0];
48
+ export function pickDefaultAvailableModel(availableModels: Model<Api>[]): Model<Api> | undefined {
49
+ const firstDefault = availableModels.find(
50
+ model => isKnownProvider(model.provider) && DEFAULT_MODEL_PER_PROVIDER[model.provider] === model.id,
51
+ );
52
+ if (!firstDefault) return availableModels[0];
53
+
54
+ const providerPriority = buildModelProviderPriorityRank();
55
+ const sharedDefaultMatches = availableModels.filter(
56
+ model =>
57
+ model.id === firstDefault.id &&
58
+ isKnownProvider(model.provider) &&
59
+ DEFAULT_MODEL_PER_PROVIDER[model.provider] === model.id,
60
+ );
61
+ return [...sharedDefaultMatches].sort((a, b) => {
62
+ const aRank = providerPriority.get(a.provider.toLowerCase()) ?? Number.POSITIVE_INFINITY;
63
+ const bRank = providerPriority.get(b.provider.toLowerCase()) ?? Number.POSITIVE_INFINITY;
64
+ if (aRank !== bRank) return aRank - bRank;
65
+ return availableModels.indexOf(a) - availableModels.indexOf(b);
66
+ })[0];
48
67
  }
49
68
 
50
69
  export interface ScopedModel {
@@ -464,12 +483,10 @@ function matchModel(
464
483
  context: ModelPreferenceContext,
465
484
  options?: { modelRegistry?: CanonicalModelRegistry },
466
485
  ): Model<Api> | undefined {
467
- // Explicit provider/model selectors always bypass canonical coalescing.
468
486
  const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
469
487
  if (exactRefMatch) {
470
488
  return exactRefMatch;
471
489
  }
472
-
473
490
  // Exact canonical ids coalesce provider variants before bare-id matching.
474
491
  const exactCanonicalMatch = findExactCanonicalModelMatch(modelPattern, availableModels, options?.modelRegistry);
475
492
  if (exactCanonicalMatch) {
@@ -2658,7 +2658,7 @@ export const SETTINGS_SCHEMA = {
2658
2658
  group: "Read Summaries",
2659
2659
  label: "Read Summary Unfold Ceiling",
2660
2660
  description:
2661
- "Hard ceiling on summary size while BFS-unfolding. An unfold that would exceed this is reverted and unfolding stops.",
2661
+ "Hard ceiling on summary size while BFS-unfolding. An unfold whose revealed lines would exceed this is skipped (that span stays folded) and unfolding continues with the remaining spans.",
2662
2662
  },
2663
2663
  },
2664
2664
 
@@ -3070,17 +3070,6 @@ export const SETTINGS_SCHEMA = {
3070
3070
 
3071
3071
  // Optional tools
3072
3072
 
3073
- "renderMermaid.enabled": {
3074
- type: "boolean",
3075
- default: false,
3076
- ui: {
3077
- tab: "tools",
3078
- group: "Available Tools",
3079
- label: "Render Mermaid",
3080
- description: "Enable the render_mermaid tool for Mermaid-to-ASCII rendering",
3081
- },
3082
- },
3083
-
3084
3073
  "debug.enabled": {
3085
3074
  type: "boolean",
3086
3075
  default: true,
@@ -116,6 +116,17 @@ export function parseSeenLinesFromHashlineBody(body: string): number[] {
116
116
  return seen;
117
117
  }
118
118
 
119
+ /** Merge explicit 1-indexed displayed lines into a recorded hashline snapshot. */
120
+ export function recordSeenLines(
121
+ session: FileSnapshotStoreOwner,
122
+ absolutePath: string,
123
+ tag: string,
124
+ lines: readonly number[],
125
+ ): void {
126
+ if (lines.length === 0) return;
127
+ getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, lines);
128
+ }
129
+
119
130
  /**
120
131
  * Attach the lines a read displayed to the snapshot it minted, so the patcher
121
132
  * can reject edits anchored on lines the model never saw. Best-effort: a no-op
@@ -128,7 +139,5 @@ export function recordSeenLinesFromBody(
128
139
  tag: string,
129
140
  body: string,
130
141
  ): void {
131
- const seen = parseSeenLinesFromHashlineBody(body);
132
- if (seen.length === 0) return;
133
- getFileSnapshotStore(session).recordSeenLines(canonicalSnapshotKey(absolutePath), tag, seen);
142
+ recordSeenLines(session, absolutePath, tag, parseSeenLinesFromHashlineBody(body));
134
143
  }
@@ -10,6 +10,7 @@ import { resolveAgentModelPatterns } from "../config/model-resolver";
10
10
  import type { LocalProtocolOptions } from "../internal-urls";
11
11
  import { MCPManager } from "../mcp/manager";
12
12
  import subagentUserPromptTemplate from "../prompts/system/subagent-user-prompt.md" with { type: "text" };
13
+ import { MAIN_AGENT_ID } from "../registry/agent-registry";
13
14
  import * as taskDiscovery from "../task/discovery";
14
15
  import * as taskExecutor from "../task/executor";
15
16
  import { AgentOutputManager } from "../task/output-manager";
@@ -288,6 +289,7 @@ export async function runEvalAgent(args: unknown, options: EvalAgentBridgeOption
288
289
  parentHindsightSessionState: options.session.getHindsightSessionState?.(),
289
290
  parentMnemopiSessionState: options.session.getMnemopiSessionState?.(),
290
291
  parentTelemetry: options.session.getTelemetry?.(),
292
+ parentAgentId: options.session.getAgentId?.() ?? MAIN_AGENT_ID,
291
293
  // Deliberately omit parentEvalSessionId: the parent's Python kernel is
292
294
  // blocked on this bridge call, so sharing the eval session would deadlock
293
295
  // (subagent queues behind the parent's in-flight execution, parent waits