jeopi-cli 16.4.2 → 16.4.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 (148) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/dist/cli.js +3033 -2991
  3. package/dist/types/advisor/advise-tool.d.ts +5 -1
  4. package/dist/types/advisor/config.d.ts +4 -3
  5. package/dist/types/advisor/runtime.d.ts +15 -0
  6. package/dist/types/cli/args.d.ts +2 -0
  7. package/dist/types/cli/update-cli.d.ts +4 -1
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/commit/agentic/agent.d.ts +1 -0
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +7 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/config/settings-schema.d.ts +3 -13
  14. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  15. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/internal-urls/memory-protocol.d.ts +6 -6
  19. package/dist/types/internal-urls/types.d.ts +7 -0
  20. package/dist/types/irc/bus.d.ts +4 -0
  21. package/dist/types/lsp/deferred-diagnostics.d.ts +11 -0
  22. package/dist/types/modes/components/move-overlay.d.ts +1 -1
  23. package/dist/types/modes/components/plan-review-overlay.d.ts +2 -0
  24. package/dist/types/modes/components/welcome.d.ts +4 -0
  25. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  26. package/dist/types/modes/orchestrate.d.ts +1 -1
  27. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  28. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  29. package/dist/types/modes/ultrathink.d.ts +1 -1
  30. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  31. package/dist/types/modes/workflow.d.ts +1 -1
  32. package/dist/types/session/agent-session.d.ts +6 -0
  33. package/dist/types/session/auth-broker-config.d.ts +2 -2
  34. package/dist/types/tools/bash-skill-urls.d.ts +3 -2
  35. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +3 -1
  36. package/dist/types/tools/browser/cmux/rpc.d.ts +20 -0
  37. package/dist/types/tools/browser/launch.d.ts +1 -0
  38. package/dist/types/tools/browser/run-cancellation.d.ts +17 -2
  39. package/dist/types/tools/browser/run-output.d.ts +25 -0
  40. package/dist/types/tools/browser/tab-worker.d.ts +22 -0
  41. package/dist/types/utils/changelog.d.ts +35 -1
  42. package/package.json +12 -12
  43. package/src/advisor/__tests__/advisor.test.ts +13 -0
  44. package/src/advisor/__tests__/config.test.ts +36 -0
  45. package/src/advisor/advise-tool.ts +6 -1
  46. package/src/advisor/config.ts +11 -8
  47. package/src/advisor/runtime.ts +127 -0
  48. package/src/autolearn/controller.ts +15 -3
  49. package/src/cli/args.ts +12 -0
  50. package/src/cli/flag-tables.ts +21 -7
  51. package/src/cli/update-cli.ts +63 -13
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/commands/acp.ts +11 -2
  54. package/src/commands/launch.ts +12 -3
  55. package/src/commit/agentic/agent.ts +4 -0
  56. package/src/commit/agentic/index.ts +46 -21
  57. package/src/config/config-file.ts +30 -12
  58. package/src/config/model-registry.ts +7 -7
  59. package/src/config/model-resolver.ts +24 -6
  60. package/src/config/models-config.ts +1 -1
  61. package/src/config/settings-schema.ts +3 -15
  62. package/src/edit/index.ts +10 -76
  63. package/src/edit/renderer.ts +13 -10
  64. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  65. package/src/eval/__tests__/bridge-timeout.test.ts +60 -0
  66. package/src/eval/executor-base.ts +84 -7
  67. package/src/eval/js/shared/rewrite-imports.ts +31 -13
  68. package/src/eval/py/tool-bridge.ts +13 -14
  69. package/src/exec/non-interactive-env.ts +0 -1
  70. package/src/export/ttsr.ts +0 -3
  71. package/src/extensibility/custom-tools/loader.ts +70 -37
  72. package/src/extensibility/custom-tools/types.ts +3 -0
  73. package/src/extensibility/extensions/runner.ts +3 -0
  74. package/src/extensibility/extensions/types.ts +3 -0
  75. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  76. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  77. package/src/extensibility/plugins/manager.ts +21 -0
  78. package/src/internal-urls/docs-index.generated.txt +1 -1
  79. package/src/internal-urls/memory-protocol.ts +13 -8
  80. package/src/internal-urls/skill-protocol.ts +1 -1
  81. package/src/internal-urls/types.ts +7 -0
  82. package/src/irc/bus.ts +48 -18
  83. package/src/lsp/client.ts +7 -1
  84. package/src/lsp/deferred-diagnostics.ts +66 -0
  85. package/src/main.ts +13 -13
  86. package/src/mcp/tool-bridge.ts +57 -6
  87. package/src/mcp/transports/stdio.test.ts +45 -1
  88. package/src/mcp/transports/stdio.ts +6 -3
  89. package/src/modes/acp/acp-agent.ts +73 -2
  90. package/src/modes/acp/acp-event-mapper.ts +5 -0
  91. package/src/modes/acp/acp-mode.ts +11 -0
  92. package/src/modes/components/__tests__/move-overlay.test.ts +16 -1
  93. package/src/modes/components/advisor-config.ts +15 -7
  94. package/src/modes/components/chat-transcript-builder.ts +31 -11
  95. package/src/modes/components/move-overlay.ts +2 -3
  96. package/src/modes/components/plan-review-overlay.ts +7 -0
  97. package/src/modes/components/tips.txt +1 -1
  98. package/src/modes/components/welcome.ts +13 -14
  99. package/src/modes/controllers/command-controller.ts +8 -10
  100. package/src/modes/controllers/event-controller.ts +58 -42
  101. package/src/modes/controllers/input-controller.ts +9 -6
  102. package/src/modes/controllers/selector-controller.ts +3 -2
  103. package/src/modes/controllers/tan-command-controller.ts +40 -1
  104. package/src/modes/interactive-mode.ts +13 -0
  105. package/src/modes/magic-keyword-boundary.ts +23 -0
  106. package/src/modes/orchestrate.ts +6 -5
  107. package/src/modes/print-mode.ts +9 -0
  108. package/src/modes/rpc/host-tools.ts +13 -0
  109. package/src/modes/rpc/rpc-mode.ts +130 -56
  110. package/src/modes/ultrathink.ts +6 -5
  111. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  112. package/src/modes/utils/ui-helpers.ts +16 -1
  113. package/src/modes/workflow.ts +5 -4
  114. package/src/prompts/system/eager-task.md +2 -2
  115. package/src/prompts/system/tan-context-switch.md +17 -0
  116. package/src/prompts/tools/browser.md +5 -5
  117. package/src/sdk.ts +3 -0
  118. package/src/session/agent-session.ts +267 -36
  119. package/src/session/auth-broker-config.ts +4 -4
  120. package/src/session/snapcompact-inline.ts +3 -19
  121. package/src/session/streaming-output.ts +40 -12
  122. package/src/slash-commands/builtin-registry.ts +8 -8
  123. package/src/task/executor.ts +8 -0
  124. package/src/task/render.ts +51 -44
  125. package/src/tools/bash-skill-urls.ts +7 -4
  126. package/src/tools/bash.ts +30 -33
  127. package/src/tools/browser/cmux/cmux-tab.ts +49 -50
  128. package/src/tools/browser/cmux/rpc.ts +50 -0
  129. package/src/tools/browser/launch.ts +8 -4
  130. package/src/tools/browser/run-cancellation.ts +44 -5
  131. package/src/tools/browser/run-output.ts +76 -0
  132. package/src/tools/browser/tab-worker.ts +280 -133
  133. package/src/tools/eval-render.ts +16 -14
  134. package/src/tools/glob.ts +20 -6
  135. package/src/tools/grep.ts +1 -0
  136. package/src/tools/image-gen.ts +3 -4
  137. package/src/tools/irc.ts +27 -13
  138. package/src/tools/path-utils.ts +6 -1
  139. package/src/tools/read.ts +14 -7
  140. package/src/tools/write.ts +22 -5
  141. package/src/tools/yield.ts +29 -1
  142. package/src/utils/changelog.ts +106 -9
  143. package/src/utils/git.ts +0 -1
  144. package/src/utils/title-generator.ts +72 -7
  145. package/src/web/search/index.ts +14 -6
  146. package/src/web/search/providers/perplexity.ts +18 -2
  147. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  148. package/src/tools/bash-command-fixup.ts +0 -37
@@ -13,6 +13,7 @@ export declare const DEFAULT_VIEWPORT: {
13
13
  export declare const BROWSER_PROTOCOL_TIMEOUT_MS = 60000;
14
14
  export declare function loadPuppeteer(): Promise<typeof Puppeteer>;
15
15
  export declare function loadPuppeteerInWorker(safeDir: string): Promise<typeof Puppeteer>;
16
+ export declare function ensureChromiumExecutable(): Promise<string | undefined>;
16
17
  export interface LaunchHeadlessOptions {
17
18
  headless: boolean;
18
19
  viewport?: {
@@ -1,4 +1,19 @@
1
- /** Sleeps inside evaluated browser code while honoring the owning run's cancellation signal. */
2
- export declare function waitForBrowserRun(ms: number, signal: AbortSignal): Promise<void>;
1
+ /** Options for the predicate form of the run-scoped `wait()` helper. */
2
+ export interface WaitPredicateOptions {
3
+ /** Max time to poll before failing, in ms (default 30_000). */
4
+ timeout?: number;
5
+ /** Poll interval in ms (default 100, floor 10). */
6
+ interval?: number;
7
+ }
8
+ /**
9
+ * Run-scoped `wait()` helper for evaluated browser code, honoring the owning run's
10
+ * cancellation signal.
11
+ *
12
+ * - `wait(ms)` sleeps for `ms` milliseconds.
13
+ * - `wait(fn, { timeout?, interval? })` polls `fn` (sync or async) until it returns a
14
+ * truthy value and resolves with that value; throws a named `ToolError` on timeout
15
+ * instead of stalling into the whole-cell deadline. Predicate errors propagate.
16
+ */
17
+ export declare function waitForBrowserRun(msOrPredicate: number | (() => unknown), signal: AbortSignal, opts?: WaitPredicateOptions): Promise<unknown>;
3
18
  /** Binds a long-lived browser facade to one evaluated run's abort signal. */
4
19
  export declare function bindBrowserRunFacade<T extends object>(target: T, signal: AbortSignal): T;
@@ -0,0 +1,25 @@
1
+ import type { ImageContent, TextContent } from "jeopi-ai";
2
+ import type { JsDisplayOutput } from "../../eval/js/shared/types";
3
+ /**
4
+ * Accumulates a browser run's result entries: explicit `display()` payloads,
5
+ * screenshot captions/images, and buffered stream text (`console.*`, `print`,
6
+ * `display()` of strings/primitives — `JsRuntime.displayValue` emits those via
7
+ * `onText`). Stream text is buffered and flushed as one entry before the next
8
+ * display/screenshot (and on `finish()`) so it reaches the tool result in
9
+ * order instead of vanishing into the debug log.
10
+ */
11
+ export declare class RunOutput {
12
+ #private;
13
+ /** Buffer a stream-text chunk; it joins the entries at the next push or on finish(). */
14
+ pushText(chunk: string): void;
15
+ /** Append a `display()` payload (image/json/status), flushing buffered text first. */
16
+ pushDisplay(output: JsDisplayOutput): void;
17
+ /** Append a pre-built entry (e.g. a screenshot caption/image), flushing buffered text first. */
18
+ push(entry: TextContent | ImageContent): void;
19
+ /** Flush any remaining stream text and return the ordered entries. */
20
+ finish(): Array<TextContent | ImageContent>;
21
+ }
22
+ /** JSON.stringify that never throws (cycles/BigInt → String(value)). */
23
+ export declare function safeJsonStringify(value: unknown): string;
24
+ /** Pass a return value across the run boundary: structured-cloneable as-is, else JSON round-trip, else String. */
25
+ export declare function cloneSafe(value: unknown): unknown;
@@ -1,6 +1,12 @@
1
1
  import type { HTMLElement } from "linkedom";
2
2
  import type { ElementHandle, ImageFormat } from "puppeteer-core";
3
3
  import type { Transport } from "./tab-protocol";
4
+ declare module "puppeteer-core" {
5
+ interface Frame {
6
+ /** Puppeteer's main JavaScript realm, retained by our pinned runtime patch. */
7
+ mainRealm(): Realm;
8
+ }
9
+ }
4
10
  declare global {
5
11
  interface Element extends HTMLElement {
6
12
  }
@@ -37,6 +43,22 @@ interface ScreenshotOptions {
37
43
  silent?: boolean;
38
44
  }
39
45
  export declare function normalizeSelector(selector: string): string;
46
+ /** ElementHandle enriched with the `fill()` the tool docs promise on handles from `tab.id()`/`tab.ref()`/`tab.waitFor()`. */
47
+ export type ActionableHandle = ElementHandle & {
48
+ fill(value: string): Promise<void>;
49
+ };
50
+ /**
51
+ * Attach `fill()` to a puppeteer ElementHandle before handing it to user code.
52
+ * Puppeteer handles expose `type()` but no `fill()`; the semantics mirror the
53
+ * selector-based `tab.fill()`: focus, clear any existing value, then type.
54
+ */
55
+ export declare function toActionableHandle(handle: ElementHandle): ActionableHandle;
56
+ /**
57
+ * Hint appended to a selector op's fail-fast timeout, given the selector's current
58
+ * match count: a missing element (consent wall, wrong page) reads differently from
59
+ * a present-but-unactionable one.
60
+ */
61
+ export declare function formatSelectorMatchHint(count: number): string;
40
62
  export interface InflightOp {
41
63
  label: string;
42
64
  startedAt: number;
@@ -4,6 +4,24 @@ export interface ChangelogEntry {
4
4
  patch: number;
5
5
  content: string;
6
6
  }
7
+ /** Number of changelog releases shown by automatic and default recent views. */
8
+ export declare const RECENT_CHANGELOG_ENTRY_LIMIT = 3;
9
+ /** Maximum Markdown source bytes allowed in automatic startup release notes. */
10
+ export declare const STARTUP_CHANGELOG_MAX_BYTES: number;
11
+ /** Hint appended when automatic startup release notes are truncated. */
12
+ export declare const STARTUP_CHANGELOG_FULL_HINT = "Use `/changelog full` to view the complete changelog.";
13
+ /** Markdown generated from selected changelog entries and whether it hit a size cap. */
14
+ export interface RenderedChangelog {
15
+ markdown: string;
16
+ truncated: boolean;
17
+ }
18
+ /** Automatic startup changelog decision, including whether the marker should advance. */
19
+ export interface StartupChangelogSelection {
20
+ markdown: string | undefined;
21
+ persistCurrentVersion: boolean;
22
+ truncated: boolean;
23
+ selectedEntries: number;
24
+ }
7
25
  /**
8
26
  * Parse changelog entries from the file at `changelogPath`. Scans for `## [x.y.z]`
9
27
  * headings and collects each block until the next heading or EOF.
@@ -19,9 +37,25 @@ export declare function parseChangelog(changelogPath: string | undefined): Promi
19
37
  */
20
38
  export declare function compareVersions(v1: ChangelogEntry, v2: ChangelogEntry): number;
21
39
  /**
22
- * Get entries newer than lastVersion
40
+ * Parse an omp changelog marker version into comparable parts.
41
+ */
42
+ export declare function parseChangelogVersion(version: string | undefined): ChangelogEntry | undefined;
43
+ /**
44
+ * Get entries newer than lastVersion.
23
45
  */
24
46
  export declare function getNewEntries(entries: ChangelogEntry[], lastVersion: string): ChangelogEntry[];
47
+ /**
48
+ * Render changelog entries oldest-first by default and optionally cap the Markdown source size.
49
+ */
50
+ export declare function renderChangelogEntries(entries: ChangelogEntry[], options?: {
51
+ maxBytes?: number;
52
+ truncationHint?: string;
53
+ oldestFirst?: boolean;
54
+ }): RenderedChangelog;
55
+ /**
56
+ * Select bounded release notes for interactive startup.
57
+ */
58
+ export declare function selectStartupChangelog(entries: ChangelogEntry[], lastVersion: string | undefined, currentVersion: string): StartupChangelogSelection;
25
59
  export { getChangelogPath } from "../config";
26
60
  /**
27
61
  * Last jeopi version whose changelog the user has seen. Stored as a plain-text
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-cli",
4
- "version": "16.4.2",
4
+ "version": "16.4.4",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/akillness/jeopi",
7
7
  "author": "Can Boluk",
@@ -57,17 +57,17 @@
57
57
  "@agentclientprotocol/sdk": "0.25.0",
58
58
  "@babel/parser": "^7.29.7",
59
59
  "@mozilla/readability": "^0.6.0",
60
- "jeopi-hashline": "16.4.2",
61
- "jeopi-stats": "16.4.2",
62
- "jeopi-agent-core": "16.4.2",
63
- "jeopi-ai": "16.4.2",
64
- "jeopi-catalog": "16.4.2",
65
- "jeopi-mnemopi": "16.4.2",
66
- "jeopi-natives": "16.4.2",
67
- "jeopi-tui": "16.4.2",
68
- "jeopi-utils": "16.4.2",
69
- "jeopi-wire": "16.4.2",
70
- "jeopi-snapcompact": "16.4.2",
60
+ "jeopi-hashline": "16.4.4",
61
+ "jeopi-stats": "16.4.4",
62
+ "jeopi-agent-core": "16.4.4",
63
+ "jeopi-ai": "16.4.4",
64
+ "jeopi-catalog": "16.4.4",
65
+ "jeopi-mnemopi": "16.4.4",
66
+ "jeopi-natives": "16.4.4",
67
+ "jeopi-tui": "16.4.4",
68
+ "jeopi-utils": "16.4.4",
69
+ "jeopi-wire": "16.4.4",
70
+ "jeopi-snapcompact": "16.4.4",
71
71
  "@opentelemetry/api": "^1.9.1",
72
72
  "@opentelemetry/context-async-hooks": "^2.7.1",
73
73
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -1839,6 +1839,19 @@ describe("advisor", () => {
1839
1839
  expect(text).toContain("read, grep, glob (default)");
1840
1840
  });
1841
1841
 
1842
+ it("renders an explicit no-tools advisor distinctly from the omitted default", async () => {
1843
+ const uiTheme = await getThemeByName("dark");
1844
+ if (!uiTheme) throw new Error("theme unavailable");
1845
+ setThemeInstance(uiTheme);
1846
+ const overlay = make({
1847
+ advisors: [{ name: "Blank", tools: [] }],
1848
+ });
1849
+
1850
+ const text = strip(overlay.render(200));
1851
+ expect(text.toLowerCase()).toContain("no tools");
1852
+ expect(text).not.toContain("read, grep, glob (default)");
1853
+ });
1854
+
1842
1855
  it("moves the preview with keyboard selection and preserves an explicit tool set", async () => {
1843
1856
  const uiTheme = await getThemeByName("dark");
1844
1857
  if (!uiTheme) throw new Error("theme unavailable");
@@ -55,6 +55,27 @@ describe("discoverAdvisorConfigs", () => {
55
55
  expect(sharedInstructions).toBe("Shared baseline for all advisors.");
56
56
  });
57
57
 
58
+ it("distinguishes omitted tools, explicit no-tools, and invalid-only lists", async () => {
59
+ const yaml = [
60
+ "advisors:",
61
+ " - name: No Tools",
62
+ " tools: []",
63
+ " - name: Default Tools",
64
+ " - name: Invalid Only",
65
+ " tools: [reed]",
66
+ ].join("\n");
67
+ await Bun.write(path.join(tmp, "WATCHDOG.yml"), yaml);
68
+
69
+ const { advisors } = await discoverAdvisorConfigs(tmp, agentDir);
70
+ const noTools = advisors.find(a => a.name === "No Tools");
71
+ const defaultTools = advisors.find(a => a.name === "Default Tools");
72
+ const invalidOnly = advisors.find(a => a.name === "Invalid Only");
73
+
74
+ expect(noTools?.tools).toEqual([]);
75
+ expect(defaultTools?.tools).toBeUndefined();
76
+ expect(invalidOnly?.tools).toBeUndefined();
77
+ });
78
+
58
79
  it("ignores a malformed YAML file without throwing", async () => {
59
80
  await Bun.write(path.join(tmp, "WATCHDOG.yml"), "advisors: [unclosed bracket");
60
81
  const result = await discoverAdvisorConfigs(tmp, agentDir);
@@ -122,6 +143,21 @@ describe("WATCHDOG.yml file round-trip", () => {
122
143
  expect(sharedInstructions).toContain("Shared baseline.");
123
144
  });
124
145
 
146
+ it("round-trips an explicit empty tools list without collapsing it into the default", async () => {
147
+ const file = path.join(tmp, "WATCHDOG.yml");
148
+ const explicitNoToolsDoc: WatchdogConfigDoc = {
149
+ advisors: [{ name: "No Tools", tools: [] }, { name: "Default Tools" }],
150
+ };
151
+
152
+ await saveWatchdogConfigFile(file, explicitNoToolsDoc);
153
+ const serializedDoc = await loadWatchdogConfigFile(file);
154
+ expect(serializedDoc).toEqual(explicitNoToolsDoc);
155
+
156
+ const { advisors } = await discoverAdvisorConfigs(tmp, tmp);
157
+ expect(advisors.find(a => a.name === "No Tools")?.tools).toEqual([]);
158
+ expect(advisors.find(a => a.name === "Default Tools")?.tools).toBeUndefined();
159
+ });
160
+
125
161
  it("removes the file when the doc is empty so legacy discovery resumes", async () => {
126
162
  const file = path.join(tmp, "WATCHDOG.yml");
127
163
  await saveWatchdogConfigFile(file, doc);
@@ -94,6 +94,9 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
94
94
  * - An interrupting `concern`/`blocker` is normally steered into the agent: into
95
95
  * the live turn while one is streaming, or (when idle) a triggered turn so the
96
96
  * advice is acted on immediately.
97
+ * - If the primary tail is already a terminal text answer and there is no queued
98
+ * work, late interrupting advice is preserved as a visible card instead of
99
+ * waking the primary to restate completion.
97
100
  * - After a deliberate user interrupt (`autoResumeSuppressed`) the advisor must
98
101
  * not auto-resume the stopped run. While the agent is idle — or still tearing
99
102
  * the interrupted turn down (`aborting`) — the note is preserved as a visible
@@ -103,17 +106,19 @@ export function isAdvisorInterruptImmuneTurnActive(opts: {
103
106
  * run instead strands it (it never reaches the running agent) and the withheld
104
107
  * notes dump as one burst at the next user prompt — the bug this guards.
105
108
  * - During the post-interrupt immune-turn window, further `concern`/`blocker`
106
- * notes are downgraded to asides; suppression preservation still wins.
109
+ * notes are downgraded to asides; preservation still wins.
107
110
  */
108
111
  export function resolveAdvisorDeliveryChannel(opts: {
109
112
  severity: AdvisorSeverity | undefined;
110
113
  autoResumeSuppressed: boolean;
111
114
  streaming: boolean;
112
115
  aborting: boolean;
116
+ terminalAnswerNoQueuedWork?: boolean;
113
117
  interruptImmuneTurnActive?: boolean;
114
118
  }): AdvisorDeliveryChannel {
115
119
  if (!isInterruptingSeverity(opts.severity)) return "aside";
116
120
  if (opts.autoResumeSuppressed && (opts.aborting || !opts.streaming)) return "preserve";
121
+ if (opts.terminalAnswerNoQueuedWork && !opts.streaming && !opts.aborting) return "preserve";
117
122
  if (opts.interruptImmuneTurnActive) return "aside";
118
123
  return "steer";
119
124
  }
@@ -12,9 +12,10 @@ import { collectConfigCandidates } from "./watchdog";
12
12
  * with an optional `:level` thinking suffix (e.g. `x-ai/grok-code-fast:high`),
13
13
  * resolved exactly like any other model override; `tools` is a subset of
14
14
  * `BUILTIN_TOOL_NAMES` — any built-in name, including mutating tools such as
15
- * `edit`/`write`/`bash` (the advisor is a full agent). Omitted or empty falls
16
- * back to the default `read`/`grep`/`glob` subset. `instructions` is the
17
- * advisor's specialization, appended to the shared baseline.
15
+ * `edit`/`write`/`bash` (the advisor is a full agent). Omitted falls back to
16
+ * the default `read`/`grep`/`glob` subset; an explicit empty list grants no
17
+ * tools. `instructions` is the advisor's specialization, appended to the shared
18
+ * baseline.
18
19
  */
19
20
  export interface AdvisorConfig {
20
21
  name: string;
@@ -65,11 +66,13 @@ const KNOWN_TOOL_NAMES = new Set<string>(BUILTIN_TOOL_NAMES);
65
66
  /**
66
67
  * Keep only valid tool names from an advisor's `tools` list, dropping unknowns
67
68
  * with a warning. The advisor is a full agent, so any built tool may be granted;
68
- * the runtime further filters to what's actually available this session. An empty
69
- * result (or no list) means "use the default subset" (read/grep/glob).
69
+ * the runtime further filters to what's actually available this session.
70
+ * `undefined` means "use the default subset" (read/grep/glob); only an explicit
71
+ * raw empty list means "no tools".
70
72
  */
71
73
  function filterAdvisorTools(tools: string[] | undefined, sourcePath: string): string[] | undefined {
72
- if (!tools || tools.length === 0) return undefined;
74
+ if (tools === undefined) return undefined;
75
+ if (tools.length === 0) return [];
73
76
  // Normalize legacy aliases (search→grep, find→glob) and dedupe before validating.
74
77
  const filtered = normalizeToolNames(tools).filter(name => {
75
78
  if (KNOWN_TOOL_NAMES.has(name)) return true;
@@ -210,7 +213,7 @@ export async function loadWatchdogConfigFile(filePath: string): Promise<Watchdog
210
213
  advisors: (result.advisors ?? []).map(a => ({
211
214
  name: a.name,
212
215
  model: a.model?.trim() || undefined,
213
- tools: a.tools?.length ? [...a.tools] : undefined,
216
+ tools: a.tools === undefined ? undefined : [...a.tools],
214
217
  instructions: a.instructions?.trim() ? a.instructions : undefined,
215
218
  })),
216
219
  };
@@ -229,7 +232,7 @@ export function serializeWatchdogConfig(doc: WatchdogConfigDoc): string {
229
232
  out.advisors = doc.advisors.map(a => {
230
233
  const entry: AdvisorConfig = { name: a.name };
231
234
  if (a.model?.trim()) entry.model = a.model;
232
- if (a.tools?.length) entry.tools = [...a.tools];
235
+ if (a.tools !== undefined) entry.tools = [...a.tools];
233
236
  if (a.instructions?.trim()) entry.instructions = a.instructions;
234
237
  return entry;
235
238
  });
@@ -65,6 +65,94 @@ export interface AdvisorRuntimeOptions {
65
65
  includeThinking?: boolean;
66
66
  }
67
67
 
68
+ const ADVISOR_QUARANTINE_PREFIX = "Advisor response quarantined";
69
+
70
+ /** Signals that an advisor response was discarded before it could become model-visible context. */
71
+ export class AdvisorOutputQuarantinedError extends Error {
72
+ constructor(message: string) {
73
+ super(message);
74
+ this.name = "AdvisorOutputQuarantinedError";
75
+ }
76
+ }
77
+
78
+ interface AdvisorOutputHazard {
79
+ label: string;
80
+ pattern: RegExp;
81
+ }
82
+
83
+ const ADVISOR_OUTPUT_ONLY_HAZARDS: readonly AdvisorOutputHazard[] = [
84
+ { label: "account-deletion claim", pattern: /\buser\b.{0,80}\b(?:deleted|erased)\b.{0,80}\baccount\b/i },
85
+ {
86
+ label: "instruction override",
87
+ pattern: /\bignore\s+(?:all\s+)?(?:prior|previous|earlier)\s+(?:user\s+)?instructions\b/i,
88
+ },
89
+ { label: "destructive shell command", pattern: /\brm\s+-[a-z]*r[a-z]*f[a-z]*(?:\s|$)/i },
90
+ { label: "denial instruction", pattern: /\bdeny\s+(?:this|it|the\s+request)\s+if\s+(?:asked|questioned)\b/i },
91
+ ];
92
+
93
+ /**
94
+ * Replaces an advisor assistant turn that requested unavailable tools or generated
95
+ * output-only destructive directives with a sanitized error before dispatch.
96
+ */
97
+ export function quarantineAdvisorUnsafeOutput(
98
+ message: AssistantMessage,
99
+ availableToolNames: ReadonlySet<string>,
100
+ sourceText = "",
101
+ ): string | undefined {
102
+ const reasons: string[] = [];
103
+ const unavailableToolNames = new Set<string>();
104
+ const generatedParts: string[] = [];
105
+ for (const block of message.content) {
106
+ if (block.type === "toolCall" && !availableToolNames.has(block.name)) unavailableToolNames.add(block.name);
107
+ if (block.type === "toolCall" && block.name === "advise" && typeof block.arguments.note === "string") {
108
+ generatedParts.push(block.arguments.note);
109
+ }
110
+ if (block.type === "text") generatedParts.push(block.text);
111
+ }
112
+ if (unavailableToolNames.size > 0) {
113
+ const names = [...unavailableToolNames].sort();
114
+ const toolLabel = names.length === 1 ? "tool" : "tools";
115
+ reasons.push(`requested unavailable ${toolLabel} ${names.join(", ")}`);
116
+ }
117
+
118
+ const generatedText = generatedParts.join("\n");
119
+ if (generatedText) {
120
+ const labels: string[] = [];
121
+ for (const hazard of ADVISOR_OUTPUT_ONLY_HAZARDS) {
122
+ if (hazard.pattern.test(generatedText) && !hazard.pattern.test(sourceText)) labels.push(hazard.label);
123
+ }
124
+ if (labels.includes("destructive shell command") || labels.length >= 3) {
125
+ reasons.push(`generated output-only destructive directives: ${labels.join(", ")}`);
126
+ }
127
+ }
128
+
129
+ if (reasons.length === 0) return undefined;
130
+
131
+ const messageText = `${ADVISOR_QUARANTINE_PREFIX}: ${reasons.join("; ")}`;
132
+ message.content = [{ type: "text", text: messageText }];
133
+ message.stopReason = "error";
134
+ message.stopDetails = undefined;
135
+ message.providerPayload = undefined;
136
+ message.errorMessage = messageText;
137
+ return messageText;
138
+ }
139
+
140
+ /**
141
+ * Builds the provenance text used to decide whether hazardous advisor output was
142
+ * generated by the advisor or came from model-visible primary/tool context.
143
+ */
144
+ export function buildAdvisorQuarantineSourceText(currentInput: string, messages: readonly AgentMessage[]): string {
145
+ const parts: string[] = [];
146
+ if (currentInput) parts.push(currentInput);
147
+ for (const message of messages) {
148
+ if (message.role !== "toolResult") continue;
149
+ for (const block of message.content) {
150
+ if (block.type === "text") parts.push(block.text);
151
+ }
152
+ }
153
+ return parts.join("\n");
154
+ }
155
+
68
156
  /**
69
157
  * Match the stable `Refusal` / `Refusal (…)` message prefix pi-ai emits for a
70
158
  * provider classifier refusal (`stop_details.type === "refusal"` mapped in
@@ -384,10 +472,18 @@ export class AdvisorRuntime {
384
472
  // successful empty cycle.
385
473
  const promptError = this.agent.state.error;
386
474
  if (promptError) throw new Error(promptError);
475
+ const emptyResponseError = getAdvisorEmptyResponseError(
476
+ this.agent.state.messages.slice(messageSnapshot),
477
+ );
478
+ if (emptyResponseError) throw emptyResponseError;
387
479
  success = true;
388
480
  this.#consecutiveFailures = 0;
389
481
  this.#failureNotified = false;
390
482
  } catch (err) {
483
+ if (err instanceof AdvisorOutputQuarantinedError) {
484
+ this.#resetAdvisorContext(true, true);
485
+ continue;
486
+ }
391
487
  // reset()/dispose() aborts the in-flight prompt; the rejection is the
392
488
  // reset itself, not a transient advisor failure. Drop the stale batch
393
489
  // (reset already cleared #pending and rewound the cursor) instead of
@@ -457,6 +553,37 @@ export class AdvisorRuntime {
457
553
  }
458
554
 
459
555
  type TextualContent = string | readonly (TextContent | ImageContent)[];
556
+ function getAdvisorEmptyResponseError(messages: readonly AgentMessage[]): Error | undefined {
557
+ let sawAssistant = false;
558
+ for (const message of messages) {
559
+ if (message.role !== "assistant") continue;
560
+ sawAssistant = true;
561
+ if (message.stopReason !== "stop") return undefined;
562
+ if (hasAdvisorResponseContent(message)) return undefined;
563
+ }
564
+ if (sawAssistant) return new Error("Advisor turn returned an empty stop response without advice");
565
+ if (messages.length > 0) return new Error("Advisor turn ended without an assistant response");
566
+ return undefined;
567
+ }
568
+
569
+ function hasAdvisorResponseContent(message: AssistantMessage): boolean {
570
+ return message.content.some(block => {
571
+ switch (block.type) {
572
+ case "text":
573
+ return block.text.trim().length > 0;
574
+ case "thinking":
575
+ return block.thinking.trim().length > 0;
576
+ case "redactedThinking":
577
+ return block.data.length > 0;
578
+ case "toolCall":
579
+ return block.name.trim().length > 0;
580
+ case "fallback":
581
+ return false;
582
+ default:
583
+ return false;
584
+ }
585
+ });
586
+ }
460
587
 
461
588
  function obfuscateTextualContent(obfuscator: SecretObfuscator, content: TextualContent): TextualContent {
462
589
  if (typeof content === "string") return obfuscator.obfuscate(content);
@@ -84,11 +84,11 @@ export class AutoLearnController {
84
84
  return;
85
85
  }
86
86
  if (event.type === "agent_end") {
87
- this.#onAgentEnd();
87
+ this.#onAgentEnd(event);
88
88
  }
89
89
  }
90
90
 
91
- #onAgentEnd(): void {
91
+ #onAgentEnd(event: Extract<AgentSessionEvent, { type: "agent_end" }>): void {
92
92
  // Snapshot and reset every turn: the counter describes only the
93
93
  // just-finished turn, so below-threshold, disabled, and plan-mode stops
94
94
  // must not let tool calls accumulate into a later turn.
@@ -107,6 +107,18 @@ export class AutoLearnController {
107
107
  this.#suppressNext = false;
108
108
  return;
109
109
  }
110
+ // Never nudge a turn that ended in an abort (ESC, cancel, etc.). The
111
+ // abort flag on the session is unreliable by the time agent_end is
112
+ // deferred to subscribers; read stopReason from the event messages.
113
+ for (let i = event.messages.length - 1; i >= 0; i--) {
114
+ const message = event.messages[i];
115
+ if (message && typeof message === "object" && "role" in message && message.role === "assistant") {
116
+ if ("stopReason" in message && message.stopReason === "aborted") {
117
+ return;
118
+ }
119
+ break;
120
+ }
121
+ }
110
122
  // Honor a live opt-out: the subscription outlives the setting, so re-check
111
123
  // the current flag rather than trusting install-time state.
112
124
  if (!this.#settings.get("autolearn.enabled")) return;
@@ -148,7 +160,7 @@ export class AutoLearnController {
148
160
  display: false,
149
161
  attribution: "user",
150
162
  },
151
- { deliverAs: "nextTurn", triggerTurn: true },
163
+ { deliverAs: "nextTurn", triggerTurn: true, acceptTerminalEmptyStop: true },
152
164
  )
153
165
  .then(started => {
154
166
  if (!started) this.#suppressNext = false;
package/src/cli/args.ts CHANGED
@@ -14,6 +14,7 @@ import {
14
14
  STRING_SETTERS,
15
15
  STRING_VALUE_FLAGS,
16
16
  } from "./flag-tables";
17
+ import { CliUsageError } from "./usage-error";
17
18
 
18
19
  export type Mode = "text" | "json" | "rpc" | "acp" | "rpc-ui";
19
20
 
@@ -299,6 +300,17 @@ export function reportUnrecognizedFlags(
299
300
  return true;
300
301
  }
301
302
 
303
+ /** Emit a clean CLI usage error without an internal stack trace. */
304
+ export function reportCliUsageError(
305
+ error: unknown,
306
+ write: (text: string) => void = text => process.stderr.write(text),
307
+ ): boolean {
308
+ if (!(error instanceof CliUsageError)) return false;
309
+ write(`${chalk.red(`Error: ${error.message}`)}\n`);
310
+ write(`Run \`${APP_NAME} --help\` for available flags.\n`);
311
+ return true;
312
+ }
313
+
302
314
  export function getExtraHelpText(): string {
303
315
  return `${chalk.bold("Environment Variables:")}
304
316
  ${chalk.dim("# Core Providers")}
@@ -32,6 +32,7 @@
32
32
 
33
33
  import type { ConfiguredThinkingLevel } from "../thinking";
34
34
  import type { Args } from "./args";
35
+ import { CliUsageError } from "./usage-error";
35
36
 
36
37
  /**
37
38
  * Runtime dependencies injected into setters that need to validate input or
@@ -85,6 +86,24 @@ const setResume: OptionalSetter = (result, value) => {
85
86
  result.resume = value !== undefined ? value : true;
86
87
  };
87
88
 
89
+ const MAX_TIME_DURATION_RE = /^(\d+(?:\.\d+)?)([smh])$/;
90
+
91
+ function maxTimeMultiplier(unit: string | undefined): number {
92
+ if (unit === "h") return 3600;
93
+ if (unit === "m") return 60;
94
+ return 1;
95
+ }
96
+
97
+ function parseMaxTimeSeconds(value: string): number {
98
+ const trimmed = value.trim();
99
+ const duration = MAX_TIME_DURATION_RE.exec(trimmed);
100
+ const seconds = duration ? Number(duration[1]) * maxTimeMultiplier(duration[2]) : Number(trimmed);
101
+ if (Number.isFinite(seconds) && seconds > 0) return seconds;
102
+ throw new CliUsageError(
103
+ `Invalid --max-time value: ${JSON.stringify(value)}. Expected a positive number of seconds or duration like "5s", "10m", "1h".`,
104
+ );
105
+ }
106
+
88
107
  /**
89
108
  * Setters for flags with string values. Most built-ins consume the next argv
90
109
  * token even when it starts with `-`; flags listed in
@@ -121,13 +140,8 @@ export const STRING_SETTERS: Record<string, StringSetter> = {
121
140
  "--plan": (result, value) => {
122
141
  result.plan = value;
123
142
  },
124
- "--max-time": (result, value, deps) => {
125
- const seconds = Number(value);
126
- if (Number.isFinite(seconds) && seconds > 0) {
127
- result.maxTime = seconds;
128
- } else {
129
- deps.logger.warn("Invalid seconds passed to --max-time", { value });
130
- }
143
+ "--max-time": (result, value) => {
144
+ result.maxTime = parseMaxTimeSeconds(value);
131
145
  },
132
146
  "--api-key": (result, value) => {
133
147
  result.apiKey = value;