jeopi-cli 16.4.3 → 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 (34) hide show
  1. package/dist/cli.js +3094 -3091
  2. package/dist/types/advisor/advise-tool.d.ts +5 -1
  3. package/dist/types/advisor/runtime.d.ts +15 -0
  4. package/dist/types/config/model-registry.d.ts +3 -3
  5. package/dist/types/config/models-config.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +0 -10
  7. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  8. package/dist/types/modes/rpc/rpc-mode.d.ts +27 -7
  9. package/dist/types/modes/utils/transcript-render-helpers.d.ts +9 -0
  10. package/dist/types/session/agent-session.d.ts +1 -0
  11. package/package.json +12 -12
  12. package/src/advisor/advise-tool.ts +6 -1
  13. package/src/advisor/runtime.ts +127 -0
  14. package/src/autolearn/controller.ts +1 -1
  15. package/src/config/config-file.ts +30 -12
  16. package/src/config/model-registry.ts +7 -7
  17. package/src/config/models-config.ts +1 -1
  18. package/src/config/settings-schema.ts +0 -12
  19. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +9 -1
  20. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +30 -3
  21. package/src/internal-urls/docs-index.generated.txt +1 -1
  22. package/src/modes/components/chat-transcript-builder.ts +31 -11
  23. package/src/modes/controllers/event-controller.ts +41 -12
  24. package/src/modes/rpc/host-tools.ts +13 -0
  25. package/src/modes/rpc/rpc-mode.ts +130 -56
  26. package/src/modes/utils/transcript-render-helpers.ts +44 -0
  27. package/src/modes/utils/ui-helpers.ts +16 -1
  28. package/src/session/agent-session.ts +95 -11
  29. package/src/task/render.ts +51 -44
  30. package/src/tools/bash.ts +0 -11
  31. package/src/tools/path-utils.ts +6 -1
  32. package/src/utils/title-generator.ts +5 -1
  33. package/dist/types/tools/bash-command-fixup.d.ts +0 -11
  34. package/src/tools/bash-command-fixup.ts +0 -37
@@ -51,6 +51,9 @@ export declare function isAdvisorInterruptImmuneTurnActive(opts: {
51
51
  * - An interrupting `concern`/`blocker` is normally steered into the agent: into
52
52
  * the live turn while one is streaming, or (when idle) a triggered turn so the
53
53
  * advice is acted on immediately.
54
+ * - If the primary tail is already a terminal text answer and there is no queued
55
+ * work, late interrupting advice is preserved as a visible card instead of
56
+ * waking the primary to restate completion.
54
57
  * - After a deliberate user interrupt (`autoResumeSuppressed`) the advisor must
55
58
  * not auto-resume the stopped run. While the agent is idle — or still tearing
56
59
  * the interrupted turn down (`aborting`) — the note is preserved as a visible
@@ -60,13 +63,14 @@ export declare function isAdvisorInterruptImmuneTurnActive(opts: {
60
63
  * run instead strands it (it never reaches the running agent) and the withheld
61
64
  * notes dump as one burst at the next user prompt — the bug this guards.
62
65
  * - During the post-interrupt immune-turn window, further `concern`/`blocker`
63
- * notes are downgraded to asides; suppression preservation still wins.
66
+ * notes are downgraded to asides; preservation still wins.
64
67
  */
65
68
  export declare function resolveAdvisorDeliveryChannel(opts: {
66
69
  severity: AdvisorSeverity | undefined;
67
70
  autoResumeSuppressed: boolean;
68
71
  streaming: boolean;
69
72
  aborting: boolean;
73
+ terminalAnswerNoQueuedWork?: boolean;
70
74
  interruptImmuneTurnActive?: boolean;
71
75
  }): AdvisorDeliveryChannel;
72
76
  /**
@@ -1,4 +1,5 @@
1
1
  import type { AgentMessage } from "jeopi-agent-core";
2
+ import type { AssistantMessage } from "jeopi-ai";
2
3
  import { type SecretObfuscator } from "../secrets/obfuscator";
3
4
  /**
4
5
  * Minimal slice of `Agent` the runtime drives — satisfied by pi-agent-core
@@ -58,6 +59,20 @@ export interface AdvisorRuntimeOptions {
58
59
  /** Render the primary's thinking blocks into advisor deltas (default true). */
59
60
  includeThinking?: boolean;
60
61
  }
62
+ /** Signals that an advisor response was discarded before it could become model-visible context. */
63
+ export declare class AdvisorOutputQuarantinedError extends Error {
64
+ constructor(message: string);
65
+ }
66
+ /**
67
+ * Replaces an advisor assistant turn that requested unavailable tools or generated
68
+ * output-only destructive directives with a sanitized error before dispatch.
69
+ */
70
+ export declare function quarantineAdvisorUnsafeOutput(message: AssistantMessage, availableToolNames: ReadonlySet<string>, sourceText?: string): string | undefined;
71
+ /**
72
+ * Builds the provenance text used to decide whether hazardous advisor output was
73
+ * generated by the advisor or came from model-visible primary/tool context.
74
+ */
75
+ export declare function buildAdvisorQuarantineSourceText(currentInput: string, messages: readonly AgentMessage[]): string;
61
76
  export declare function isRefusalError(error: unknown): boolean;
62
77
  export declare class AdvisorRuntime {
63
78
  #private;
@@ -74,7 +74,7 @@ export declare class ModelRegistry {
74
74
  fetch?: FetchImpl;
75
75
  });
76
76
  /**
77
- * Reload models from disk (built-in + custom from models.json).
77
+ * Reload models from disk (built-in + custom config).
78
78
  */
79
79
  refresh(strategy?: ModelRefreshStrategy): Promise<void>;
80
80
  refreshInBackground(strategy?: ModelRefreshStrategy): void;
@@ -94,12 +94,12 @@ export declare class ModelRegistry {
94
94
  */
95
95
  refreshRuntimeProviders(strategy?: ModelRefreshStrategy): Promise<void>;
96
96
  /**
97
- * Get any error from loading models.json (undefined if no error).
97
+ * Get any error from loading custom models config (undefined if no error).
98
98
  */
99
99
  getError(): ConfigError | undefined;
100
100
  /**
101
101
  * Get all models (built-in + custom).
102
- * If models.json had errors, returns only built-in models.
102
+ * If custom config had errors, returns only built-in models.
103
103
  */
104
104
  getAll(): Model<Api>[];
105
105
  /**
@@ -1,5 +1,5 @@
1
1
  /**
2
- * models.json config file handle and provider configuration validation.
2
+ * Custom model/provider config file handle and validation.
3
3
  */
4
4
  import type { Api, ModelSpec } from "jeopi-ai/types";
5
5
  import { ConfigFile } from "./config-file";
@@ -3332,16 +3332,6 @@ export declare const SETTINGS_SCHEMA: {
3332
3332
  readonly type: "array";
3333
3333
  readonly default: BashInterceptorRule[];
3334
3334
  };
3335
- readonly "bash.stripTrailingHeadTail": {
3336
- readonly type: "boolean";
3337
- readonly default: true;
3338
- readonly ui: {
3339
- readonly tab: "shell";
3340
- readonly group: "Bash";
3341
- readonly label: "Strip head/tail Pipes";
3342
- readonly description: "Silently drop trailing `| head`/`| tail` pipes from single-line bash commands. Output is already truncated automatically.";
3343
- };
3344
- };
3345
3335
  readonly "shellMinimizer.enabled": {
3346
3336
  readonly type: "boolean";
3347
3337
  readonly default: true;
@@ -12,5 +12,7 @@ export declare class RpcHostToolBridge {
12
12
  handleUpdate(frame: RpcHostToolUpdate): boolean;
13
13
  requestExecution(definition: RpcHostToolDefinition, toolCallId: string, args: Record<string, unknown>, signal?: AbortSignal, onUpdate?: AgentToolUpdateCallback<unknown>): Promise<AgentToolResult<unknown>>;
14
14
  rejectAllPending(message: string): void;
15
+ /** Reject active and future host tool requests after the RPC client disconnects. */
16
+ close(message: string): void;
15
17
  }
16
18
  export {};
@@ -8,6 +8,13 @@ export type PendingExtensionRequest = {
8
8
  resolve: (response: RpcExtensionUIResponse) => void;
9
9
  reject: (error: Error) => void;
10
10
  };
11
+ /** Pending extension UI request map that can fail closed when the RPC client disconnects. */
12
+ export declare class RpcPendingExtensionRequests extends Map<string, PendingExtensionRequest> {
13
+ #private;
14
+ set(id: string, request: PendingExtensionRequest): this;
15
+ /** Reject every active and future extension UI request. */
16
+ rejectAll(message: string): void;
17
+ }
11
18
  type RpcOutput = (obj: RpcResponse | RpcExtensionUIRequest | RpcHostToolCallRequest | RpcHostToolCancelRequest | RpcHostUriRequest | RpcHostUriCancelRequest | object) => void;
12
19
  export type RpcSessionChangeCommand = Extract<RpcCommand, {
13
20
  type: "new_session";
@@ -38,7 +45,7 @@ export type RpcSkillCommandSession = Pick<AgentSession, "promptCustomMessage" |
38
45
  export type RpcSkillCommandResult = {
39
46
  agentInvoked: true;
40
47
  };
41
- export declare function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string): Promise<RpcSkillCommandResult | false>;
48
+ export declare function tryRunRpcSkillCommand(session: RpcSkillCommandSession, text: string, streamingBehavior?: "steer" | "followUp"): Promise<RpcSkillCommandResult | false>;
42
49
  export declare function reportLocalOnlyPromptResult(input: {
43
50
  id: string | undefined;
44
51
  prompt: Promise<boolean>;
@@ -84,15 +91,16 @@ export interface RpcInputFrameDeps {
84
91
  onHostToolUpdate: (frame: RpcHostToolUpdate) => void;
85
92
  onHostUriResult: (frame: RpcHostUriResult) => void;
86
93
  }
94
+ /** Dispatch side-channel frames that must overtake the serialized command queue. */
95
+ export declare function dispatchRpcControlFrame(parsed: unknown, deps: RpcInputFrameDeps): boolean;
87
96
  /**
88
97
  * Dispatch a single parsed frame from the RPC input stream.
89
98
  *
90
- * Bash commands are dispatched in the background so the caller (the stdin loop
91
- * in {@link runRpcMode}) can keep reading subsequent frames while a shell
92
- * command is still running. This lets a client send `abort_bash` (or any other
93
- * command) while a long-running `bash` is in flight. Response correlation is
94
- * preserved via each command's `id`; ordering across concurrent commands is
95
- * not guaranteed and clients MUST match on `id`.
99
+ * Bash commands are dispatched in the background so the caller can keep reading
100
+ * subsequent frames while a shell command is still running. This lets a client
101
+ * send `abort_bash` while a long-running `bash` is in flight. Response
102
+ * correlation is preserved via each command's `id`; ordering across concurrent
103
+ * commands is not guaranteed and clients MUST match on `id`.
96
104
  *
97
105
  * @returns `undefined` when the frame was routed to a side-channel handler
98
106
  * (extension UI response, host tool/URI frames) or dispatched in the
@@ -101,6 +109,18 @@ export interface RpcInputFrameDeps {
101
109
  * on non-`bash` commands propagate; the caller is expected to wrap them.
102
110
  */
103
111
  export declare function dispatchRpcInputFrame(parsed: unknown, deps: RpcInputFrameDeps): Promise<void> | undefined;
112
+ /** Serializes ordinary RPC commands while allowing control frames to dispatch immediately. */
113
+ export declare class RpcInputDispatcher {
114
+ #private;
115
+ constructor(options: {
116
+ deps: RpcInputFrameDeps;
117
+ afterSerialCommand?: () => Promise<void>;
118
+ });
119
+ /** Accept a parsed input frame without blocking the stdin reader. */
120
+ dispatch(parsed: unknown): void;
121
+ /** Await every accepted serial command, including commands queued before EOF. */
122
+ drain(): Promise<void>;
123
+ }
104
124
  /**
105
125
  * Coordinates deferred shutdown with in-flight background input tasks.
106
126
  *
@@ -38,6 +38,15 @@ export declare function buildFileMentionBlock(files: FileMentionMessage["files"]
38
38
  * canonicalization) — i.e. content that closes the current read-tool run.
39
39
  */
40
40
  export declare function assistantHasVisibleContent(message: AssistantAgentMessage): boolean;
41
+ /**
42
+ * Split mixed assistant turns into visible text before tool execution and
43
+ * visible text segments that must render immediately after the preceding tool.
44
+ */
45
+ export declare function splitAssistantMessageToolTimeline(message: AssistantAgentMessage): {
46
+ beforeTools: AssistantAgentMessage;
47
+ afterToolCalls: ReadonlyMap<string, AssistantAgentMessage>;
48
+ hasToolCalls: boolean;
49
+ };
41
50
  /**
42
51
  * Normalize raw tool-call arguments to a plain record, collapsing non-object or
43
52
  * array values to an empty object.
@@ -816,6 +816,7 @@ export declare class AgentSession {
816
816
  triggerTurn?: boolean;
817
817
  deliverAs?: "steer" | "followUp" | "nextTurn";
818
818
  queueChipText?: string;
819
+ acceptTerminalEmptyStop?: boolean;
819
820
  }): Promise<boolean>;
820
821
  /**
821
822
  * Send a user message to the agent.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "jeopi-cli",
4
- "version": "16.4.3",
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.3",
61
- "jeopi-stats": "16.4.3",
62
- "jeopi-agent-core": "16.4.3",
63
- "jeopi-ai": "16.4.3",
64
- "jeopi-catalog": "16.4.3",
65
- "jeopi-mnemopi": "16.4.3",
66
- "jeopi-natives": "16.4.3",
67
- "jeopi-tui": "16.4.3",
68
- "jeopi-utils": "16.4.3",
69
- "jeopi-wire": "16.4.3",
70
- "jeopi-snapcompact": "16.4.3",
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",
@@ -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
  }
@@ -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);
@@ -160,7 +160,7 @@ export class AutoLearnController {
160
160
  display: false,
161
161
  attribution: "user",
162
162
  },
163
- { deliverAs: "nextTurn", triggerTurn: true },
163
+ { deliverAs: "nextTurn", triggerTurn: true, acceptTerminalEmptyStop: true },
164
164
  )
165
165
  .then(started => {
166
166
  if (!started) this.#suppressNext = false;
@@ -11,9 +11,9 @@ interface ConfigSchemaError {
11
11
  }
12
12
 
13
13
  /**
14
- * Module-private cache of (jsonPath, ymlPath) pairs we already migrated this
15
- * process. Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from
16
- * re-running the migration over and over on the boot path.
14
+ * Module-private cache of JSON → YAML migrations this process already ran.
15
+ * Prevents `ConfigFile.relocate()` / repeated `tryLoad()` calls from re-running
16
+ * the migration over and over on the boot path.
17
17
  */
18
18
  const migratedPaths = new Set<string>();
19
19
 
@@ -123,6 +123,7 @@ export type LoadResult<T> =
123
123
 
124
124
  export class ConfigFile<T> implements IConfigFile<T> {
125
125
  readonly #basePath: string;
126
+ readonly #yamlFallbackPath: string | null;
126
127
  readonly #jsonMigrationPath: string | null;
127
128
  #cache?: LoadResult<T>;
128
129
  #auxValidate?: (value: T) => void;
@@ -134,13 +135,17 @@ export class ConfigFile<T> implements IConfigFile<T> {
134
135
  ) {
135
136
  this.#basePath = configPath;
136
137
  if (configPath.endsWith(".yml")) {
138
+ this.#yamlFallbackPath = `${configPath.slice(0, -4)}.yaml`;
137
139
  this.#jsonMigrationPath = `${configPath.slice(0, -4)}.json`;
138
140
  } else if (configPath.endsWith(".yaml")) {
141
+ this.#yamlFallbackPath = null;
139
142
  this.#jsonMigrationPath = `${configPath.slice(0, -5)}.json`;
140
143
  } else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
144
+ this.#yamlFallbackPath = null;
141
145
  // JSON configs are still supported without migration.
142
146
  this.#jsonMigrationPath = null;
143
147
  } else {
148
+ this.#yamlFallbackPath = null;
144
149
  throw new Error(`Invalid config file path: ${configPath}`);
145
150
  }
146
151
  }
@@ -150,9 +155,21 @@ export class ConfigFile<T> implements IConfigFile<T> {
150
155
  * Sync callers (tests, settings init) hit this implicitly via {@link tryLoad}.
151
156
  */
152
157
  #ensureMigrated(): void {
153
- if (this.#jsonMigrationPath) {
154
- migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
158
+ if (!this.#jsonMigrationPath) return;
159
+ if (this.#yamlFallbackPath && !fs.existsSync(this.#basePath) && fs.existsSync(this.#yamlFallbackPath)) {
160
+ return;
155
161
  }
162
+ migrateJsonToYml(this.#jsonMigrationPath, this.#basePath);
163
+ }
164
+
165
+ #resolveReadPath(): string {
166
+ if (fs.existsSync(this.#basePath)) {
167
+ return this.#basePath;
168
+ }
169
+ if (this.#yamlFallbackPath && fs.existsSync(this.#yamlFallbackPath)) {
170
+ return this.#yamlFallbackPath;
171
+ }
172
+ return this.#basePath;
156
173
  }
157
174
 
158
175
  relocate(configPath?: string): ConfigFile<T> {
@@ -165,7 +182,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
165
182
 
166
183
  getMtimeMs(): number | null {
167
184
  try {
168
- return fs.statSync(this.path()).mtimeMs;
185
+ return fs.statSync(this.#resolveReadPath()).mtimeMs;
169
186
  } catch (err) {
170
187
  if (isEnoent(err)) return null;
171
188
  throw err;
@@ -211,12 +228,13 @@ export class ConfigFile<T> implements IConfigFile<T> {
211
228
  #parseContent(content: string): LoadResult<T> {
212
229
  try {
213
230
  let parsed: unknown;
214
- if (this.#basePath.endsWith(".json") || this.#basePath.endsWith(".jsonc")) {
231
+ const readPath = this.#resolveReadPath();
232
+ if (readPath.endsWith(".json") || readPath.endsWith(".jsonc")) {
215
233
  parsed = JSONC.parse(content);
216
- } else if (this.#basePath.endsWith(".yml") || this.#basePath.endsWith(".yaml")) {
234
+ } else if (readPath.endsWith(".yml") || readPath.endsWith(".yaml")) {
217
235
  parsed = YAML.parse(content);
218
236
  } else {
219
- throw new Error(`Invalid config file path: ${this.#basePath}`);
237
+ throw new Error(`Invalid config file path: ${readPath}`);
220
238
  }
221
239
 
222
240
  const checked = this.schema(parsed);
@@ -255,7 +273,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
255
273
 
256
274
  let content: string;
257
275
  try {
258
- content = fs.readFileSync(this.path(), "utf-8").trim();
276
+ content = fs.readFileSync(this.#resolveReadPath(), "utf-8").trim();
259
277
  } catch (error) {
260
278
  if (isEnoent(error)) {
261
279
  return this.#storeCache({ status: "not-found" });
@@ -275,7 +293,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
275
293
 
276
294
  let content: string;
277
295
  try {
278
- content = (await Bun.file(this.path()).text()).trim();
296
+ content = (await Bun.file(this.#resolveReadPath()).text()).trim();
279
297
  } catch (error) {
280
298
  if (isEnoent(error)) {
281
299
  return this.#storeCache({ status: "not-found" });
@@ -306,7 +324,7 @@ export class ConfigFile<T> implements IConfigFile<T> {
306
324
  }
307
325
 
308
326
  path(): string {
309
- return this.#basePath;
327
+ return this.#resolveReadPath();
310
328
  }
311
329
 
312
330
  invalidate() {
@@ -234,7 +234,7 @@ export interface ProviderDiscoveryState {
234
234
  error?: string;
235
235
  }
236
236
 
237
- /** Result of loading custom models from models.json */
237
+ /** Result of loading custom models config. */
238
238
  interface CustomModelsResult {
239
239
  models?: CustomModelOverlay[];
240
240
  overrides?: Map<string, ProviderOverride>;
@@ -271,7 +271,7 @@ interface CommandApiKeyResolution {
271
271
  value?: string;
272
272
  }
273
273
  /**
274
- * Resolve a models.yml secret/config value to an actual value.
274
+ * Resolve a models.yml/models.yaml secret/config value to an actual value.
275
275
  * `!cmd` runs a shell command and returns trimmed stdout, otherwise env vars are
276
276
  * checked first and the input falls back to a literal value.
277
277
  */
@@ -784,7 +784,7 @@ export class ModelRegistry {
784
784
  }
785
785
 
786
786
  /**
787
- * Reload models from disk (built-in + custom from models.json).
787
+ * Reload models from disk (built-in + custom config).
788
788
  */
789
789
  async refresh(strategy: ModelRefreshStrategy = "online-if-uncached"): Promise<void> {
790
790
  this.#reloadStaticModels();
@@ -889,7 +889,7 @@ export class ModelRegistry {
889
889
  #reloadStaticModels(): void {
890
890
  const currentMtime = this.#modelsConfigFile.getMtimeMs();
891
891
  if (currentMtime !== null && currentMtime === this.#lastStaticLoadMtime) {
892
- // models.json unchanged since last load; reloading would be redundant.
892
+ // Models config unchanged since last load; reloading would be redundant.
893
893
  return;
894
894
  }
895
895
  this.#modelsConfigFile.invalidate();
@@ -913,14 +913,14 @@ export class ModelRegistry {
913
913
  }
914
914
 
915
915
  /**
916
- * Get any error from loading models.json (undefined if no error).
916
+ * Get any error from loading custom models config (undefined if no error).
917
917
  */
918
918
  getError(): ConfigError | undefined {
919
919
  return this.#configError;
920
920
  }
921
921
 
922
922
  #loadModels() {
923
- // Load custom models from models.json first (to know which providers to override)
923
+ // Load custom config first (to know which providers to override).
924
924
  const {
925
925
  models: customModels = [],
926
926
  overrides = new Map(),
@@ -1823,7 +1823,7 @@ export class ModelRegistry {
1823
1823
 
1824
1824
  /**
1825
1825
  * Get all models (built-in + custom).
1826
- * If models.json had errors, returns only built-in models.
1826
+ * If custom config had errors, returns only built-in models.
1827
1827
  */
1828
1828
  getAll(): Model<Api>[] {
1829
1829
  return this.#models;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * models.json config file handle and provider configuration validation.
2
+ * Custom model/provider config file handle and validation.
3
3
  */
4
4
 
5
5
  import type { Api, ModelSpec } from "jeopi-ai/types";
@@ -3189,18 +3189,6 @@ export const SETTINGS_SCHEMA = {
3189
3189
  },
3190
3190
  "bashInterceptor.patterns": { type: "array", default: DEFAULT_BASH_INTERCEPTOR_RULES },
3191
3191
 
3192
- "bash.stripTrailingHeadTail": {
3193
- type: "boolean",
3194
- default: true,
3195
- ui: {
3196
- tab: "shell",
3197
- group: "Bash",
3198
- label: "Strip head/tail Pipes",
3199
- description:
3200
- "Silently drop trailing `| head`/`| tail` pipes from single-line bash commands. Output is already truncated automatically.",
3201
- },
3202
- },
3203
-
3204
3192
  // Shell output minimizer
3205
3193
  "shellMinimizer.enabled": {
3206
3194
  type: "boolean",