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
@@ -9,13 +9,13 @@
9
9
  * Precedence (highest first):
10
10
  * 1. `JEOPI_AUTH_BROKER_URL` / `JEOPI_AUTH_BROKER_TOKEN` env vars.
11
11
  * 2. `auth.broker.url` / `auth.broker.token` in `~/.jeopi/agent/config.yml`
12
- * (hidden from the settings UI; `!command` resolution supported).
12
+ * or `~/.jeopi/agent/config.yaml` (hidden from the settings UI; `!command` resolution supported).
13
13
  * 3. Token file `~/.jeopi/auth-broker.token` (paired with URL from env or config).
14
14
  *
15
15
  * Returns null when no broker URL is configured — caller falls back to the
16
16
  * local SQLite store.
17
17
  *
18
- * Reads config.yml directly (instead of going through `Settings.init`) because
18
+ * Reads config YAML directly (instead of going through `Settings.init`) because
19
19
  * `discoverAuthStorage` runs before the settings singleton is initialized in
20
20
  * `runRootCommand`, and we want hand-edited config entries to be honoured at
21
21
  * boot without forcing a startup reorder.
@@ -36,10 +36,10 @@ export { type AuthBrokerClientConfig, getAuthBrokerTokenFilePath };
36
36
 
37
37
  /**
38
38
  * Process-lifetime memo for {@link resolveAuthBrokerConfig}. Keyed on the env
39
- * inputs (plus agent dir, which decides which config.yml is read) so tests
39
+ * inputs (plus agent dir, which decides which config YAML is read) so tests
40
40
  * that flip `JEOPI_AUTH_BROKER_*` between cases still observe the change, while
41
41
  * repeated resolution within one CLI invocation (startup, subagent sessions)
42
- * skips the config.yml read and any `!command` token resolution.
42
+ * skips the config YAML read and any `!command` token resolution.
43
43
  */
44
44
  let cachedConfigKey: string | null = null;
45
45
  let cachedConfigPromise: Promise<AuthBrokerClientConfig | null> | null = null;
@@ -16,7 +16,6 @@
16
16
 
17
17
  import { countTokens } from "jeopi-agent-core";
18
18
  import type { Context, ImageContent, Model, TextContent, ToolResultMessage, UserMessage } from "jeopi-ai";
19
- import { isPersonalGitHubCopilotBaseUrl } from "jeopi-catalog/wire/github-copilot";
20
19
  import * as snapcompact from "jeopi-snapcompact";
21
20
  import contextFramesNote from "../prompts/system/snapcompact-context-frames-note.md" with { type: "text" };
22
21
  import contextStub from "../prompts/system/snapcompact-context-stub.md" with { type: "text" };
@@ -78,19 +77,6 @@ function passesSavingsGate(frames: number, shape: snapcompact.Shape, textTokens:
78
77
  return frames * shape.frameTokenEstimate <= textTokens * SAVINGS_MARGIN;
79
78
  }
80
79
 
81
- /**
82
- * The model is vision-capable for the endpoint we're actually about to hit.
83
- * GitHub Copilot business and enterprise hosts respond `400 vision is not
84
- * supported` on image inputs (issue #3387), so even if a stale cached spec
85
- * still advertises `["text","image"]` we MUST not rasterize transcripts when
86
- * the resolved `baseUrl` is non-personal.
87
- */
88
- function canSendImages(model: Model): boolean {
89
- if (!model.input.includes("image")) return false;
90
- if (model.provider === "github-copilot" && !isPersonalGitHubCopilotBaseUrl(model.baseUrl)) return false;
91
- return true;
92
- }
93
-
94
80
  interface SystemPromptImageTarget {
95
81
  scope: Exclude<SnapcompactSystemPromptMode, "none">;
96
82
  text: string;
@@ -291,7 +277,7 @@ export function estimateInlineSavings(input: {
291
277
  messages: readonly InlineMessageView[];
292
278
  }): SnapcompactSavingsEstimate {
293
279
  const { options, model } = input;
294
- if (!model || !canSendImages(model)) {
280
+ if (!model?.input.includes("image")) {
295
281
  return { visionCapable: false, savedTokens: 0 };
296
282
  }
297
283
 
@@ -430,10 +416,8 @@ export class SnapcompactInlineTransformer {
430
416
 
431
417
  async transform(context: Context, model: Model): Promise<Context> {
432
418
  // Vision gate: providers silently DROP images on text-only models —
433
- // rendering would lose the content entirely. Also short-circuits when the
434
- // resolved endpoint rejects vision regardless of the model's input list
435
- // (issue #3387: Copilot business endpoint).
436
- if (!canSendImages(model)) return context;
419
+ // rendering would lose the content entirely.
420
+ if (!model.input.includes("image")) return context;
437
421
 
438
422
  const shape = snapcompact.resolveShape(model, this.options.shape);
439
423
  const budget = snapcompact.providerImageBudget(model.provider) - countContextImages(context);
@@ -735,6 +735,7 @@ export class OutputSink {
735
735
  #truncated = false;
736
736
  #lastChunkTime = 0;
737
737
  #pendingChunk = "";
738
+ #pendingChunkTimer: Timer | undefined;
738
739
 
739
740
  // Per-line column cap streaming state (persists across `push` calls so a
740
741
  // long line split across chunks still trips the same trigger).
@@ -806,20 +807,18 @@ export class OutputSink {
806
807
  push(chunk: string): void {
807
808
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
808
809
 
809
- // Throttled onChunk: coalesce chunks arriving inside the throttle window
810
- // and flush the buffered concatenation on the next eligible tick (plus a
811
- // final flush in dump()) so the preview never has silent gaps.
810
+ // Throttled onChunk: coalesce chunks arriving inside the throttle window.
811
+ // A timer flushes quiet tails at the throttle boundary; dump() catches a
812
+ // final pending chunk when the process exits before that timer fires.
812
813
  // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
813
814
  // what reached the sink — the column cap is for the persisted LLM view.
814
815
  if (this.#onChunk) {
815
816
  const now = Date.now();
816
817
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
817
- this.#lastChunkTime = now;
818
- const merged = this.#pendingChunk + chunk;
819
- this.#pendingChunk = "";
820
- this.#onChunk(merged);
818
+ this.#emitPendingChunkWith(chunk, now);
821
819
  } else {
822
820
  this.#pendingChunk += chunk;
821
+ this.#schedulePendingChunkFlush();
823
822
  }
824
823
  }
825
824
 
@@ -1121,6 +1120,7 @@ export class OutputSink {
1121
1120
  * branch in `dump()` against stale totals.
1122
1121
  */
1123
1122
  replace(text: string): void {
1123
+ this.#clearPendingChunkTimer();
1124
1124
  this.#buffer = text;
1125
1125
  this.#bufferBytes = Buffer.byteLength(text, "utf-8");
1126
1126
  this.#head = "";
@@ -1138,6 +1138,38 @@ export class OutputSink {
1138
1138
  this.#pendingChunk = "";
1139
1139
  }
1140
1140
 
1141
+ #clearPendingChunkTimer(): void {
1142
+ if (!this.#pendingChunkTimer) return;
1143
+ clearTimeout(this.#pendingChunkTimer);
1144
+ this.#pendingChunkTimer = undefined;
1145
+ }
1146
+
1147
+ #emitPendingChunkWith(chunk: string, now: number): void {
1148
+ this.#clearPendingChunkTimer();
1149
+ this.#lastChunkTime = now;
1150
+ const merged = this.#pendingChunk + chunk;
1151
+ this.#pendingChunk = "";
1152
+ this.#onChunk?.(merged);
1153
+ }
1154
+
1155
+ #flushPendingChunk(): void {
1156
+ if (this.#pendingChunk.length === 0) {
1157
+ this.#clearPendingChunkTimer();
1158
+ return;
1159
+ }
1160
+ this.#emitPendingChunkWith("", Date.now());
1161
+ }
1162
+
1163
+ #schedulePendingChunkFlush(): void {
1164
+ if (this.#chunkThrottleMs <= 0 || this.#pendingChunkTimer) return;
1165
+ const elapsed = Date.now() - this.#lastChunkTime;
1166
+ const delay = Math.max(0, this.#chunkThrottleMs - elapsed);
1167
+ this.#pendingChunkTimer = setTimeout(() => {
1168
+ this.#pendingChunkTimer = undefined;
1169
+ this.#flushPendingChunk();
1170
+ }, delay);
1171
+ }
1172
+
1141
1173
  /**
1142
1174
  * Replay the rolling tail ring back into the artifact sink. When bytes
1143
1175
  * were actually dropped from the middle (the head budget was exhausted
@@ -1179,11 +1211,7 @@ export class OutputSink {
1179
1211
 
1180
1212
  // Flush any chunk still held back by the throttle so the live preview
1181
1213
  // ends with the complete stream.
1182
- if (this.#onChunk && this.#pendingChunk.length > 0) {
1183
- const pending = this.#pendingChunk;
1184
- this.#pendingChunk = "";
1185
- this.#onChunk(pending);
1186
- }
1214
+ this.#flushPendingChunk();
1187
1215
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
1188
1216
 
1189
1217
  if (this.#file) {
@@ -34,7 +34,12 @@ import { resolveResumableSession } from "../session/session-listing";
34
34
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
35
35
  import { expandTilde, resolveToCwd } from "../tools/path-utils";
36
36
  import { urlHyperlinkAlways } from "../tui";
37
- import { getChangelogPath, parseChangelog } from "../utils/changelog";
37
+ import {
38
+ getChangelogPath,
39
+ parseChangelog,
40
+ RECENT_CHANGELOG_ENTRY_LIMIT,
41
+ renderChangelogEntries,
42
+ } from "../utils/changelog";
38
43
  import { copyToClipboard } from "../utils/clipboard";
39
44
  import { CollabQrCodeComponent } from "./helpers/collab-qrcode";
40
45
  import { buildContextReportText } from "./helpers/context-report";
@@ -1100,17 +1105,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
1100
1105
  const changelogPath = getChangelogPath();
1101
1106
  const allEntries = await parseChangelog(changelogPath);
1102
1107
  const showFull = command.args.trim().toLowerCase() === "full";
1103
- const entriesToShow = showFull ? allEntries : allEntries.slice(0, 3);
1108
+ const entriesToShow = showFull ? allEntries : allEntries.slice(0, RECENT_CHANGELOG_ENTRY_LIMIT);
1104
1109
  if (entriesToShow.length === 0) {
1105
1110
  await runtime.output("No changelog entries found.");
1106
1111
  return commandConsumed();
1107
1112
  }
1108
- await runtime.output(
1109
- [...entriesToShow]
1110
- .reverse()
1111
- .map(entry => entry.content)
1112
- .join("\n\n"),
1113
- );
1113
+ await runtime.output(renderChangelogEntries(entriesToShow).markdown);
1114
1114
  return commandConsumed();
1115
1115
  },
1116
1116
  handleTui: async (command, runtime) => {
@@ -2038,14 +2038,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2038
2038
  thinkingLevel: resolvedThinkingLevel,
2039
2039
  explicitThinkingLevel,
2040
2040
  authFallbackUsed,
2041
+ warning: modelResolutionWarning,
2041
2042
  } = await awaitAbortable(
2042
2043
  resolveModelOverrideWithAuthFallback(
2043
2044
  modelPatterns,
2044
2045
  options.parentActiveModelPattern,
2045
2046
  modelRegistry,
2046
2047
  settings,
2048
+ id,
2047
2049
  ),
2048
2050
  );
2051
+ if (modelResolutionWarning) {
2052
+ logger.warn("Subagent model resolution warning", {
2053
+ warning: modelResolutionWarning,
2054
+ requested: modelPatterns,
2055
+ });
2056
+ }
2049
2057
  if (authFallbackUsed && model) {
2050
2058
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
2051
2059
  requested: modelPatterns,
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { Component } from "jeopi-tui";
9
9
  import { Container, Markdown, Text } from "jeopi-tui";
10
- import { formatNumber } from "jeopi-utils";
10
+ import { formatNumber, sanitizeText } from "jeopi-utils";
11
11
  import { settings } from "../config/settings";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
@@ -249,11 +249,11 @@ function getRenderYieldLabels(type: RenderYieldItem["type"]): string[] {
249
249
  function formatYieldPreview(item: RenderYieldItem): string {
250
250
  if (item.useLastTurn === true && item.data === undefined) return "last assistant turn";
251
251
  if (item.data === undefined) return "last assistant turn";
252
- if (typeof item.data === "string") return previewLine(replaceTabs(item.data), 70);
252
+ if (typeof item.data === "string") return previewLine(replaceTabs(sanitizeText(item.data)), 70);
253
253
  try {
254
- return previewLine(replaceTabs(JSON.stringify(item.data) ?? "null"), 70);
254
+ return previewLine(replaceTabs(sanitizeText(JSON.stringify(item.data) ?? "null")), 70);
255
255
  } catch {
256
- return previewLine(replaceTabs(String(item.data)), 70);
256
+ return previewLine(replaceTabs(sanitizeText(String(item.data))), 70);
257
257
  }
258
258
  }
259
259
 
@@ -281,7 +281,7 @@ function renderTypedYieldSections(value: unknown, continuePrefix: string, expand
281
281
  function formatJsonScalar(value: unknown, _theme: Theme): string {
282
282
  if (value === null) return "null";
283
283
  if (typeof value === "string") {
284
- const trimmed = truncateToWidth(value, 70);
284
+ const trimmed = truncateToWidth(sanitizeText(value), 70);
285
285
  return `"${trimmed}"`;
286
286
  }
287
287
  if (typeof value === "number" || typeof value === "boolean") return String(value);
@@ -291,8 +291,9 @@ function formatJsonScalar(value: unknown, _theme: Theme): string {
291
291
  export function formatTaskId(id: string): string {
292
292
  // Ids are name-based (e.g. "Anna", "Anna-2"); a "." separates nesting levels
293
293
  // (e.g. "Anna.Bob"). Render the hierarchy with a ">" breadcrumb.
294
- const segments = id.split(".");
295
- return segments.length < 2 ? id : segments.join(">");
294
+ const sanitizedId = sanitizeText(id);
295
+ const segments = sanitizedId.split(".");
296
+ return segments.length < 2 ? sanitizedId : segments.join(">");
296
297
  }
297
298
 
298
299
  const MISSING_YIELD_WARNING_PREFIX = "SYSTEM WARNING: Subagent exited without calling yield tool";
@@ -347,13 +348,13 @@ function renderJsonTreeLines(
347
348
  const scalar = formatJsonScalar(val, theme);
348
349
 
349
350
  if (scalar) {
350
- const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
351
+ const label = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "value");
351
352
  pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", scalar)}`);
352
353
  return;
353
354
  }
354
355
 
355
356
  if (Array.isArray(val)) {
356
- const header = key ? theme.fg("muted", key) : theme.fg("muted", "array");
357
+ const header = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "array");
357
358
  pushLine(`${prefix}${iconArray} ${header}`);
358
359
  if (val.length === 0) {
359
360
  pushLine(
@@ -385,7 +386,7 @@ function renderJsonTreeLines(
385
386
  }
386
387
 
387
388
  if (val && typeof val === "object") {
388
- const header = key ? theme.fg("muted", key) : theme.fg("muted", "object");
389
+ const header = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "object");
389
390
  pushLine(`${prefix}${iconObject} ${header}`);
390
391
  const entries = Object.entries(val as Record<string, unknown>);
391
392
  if (entries.length === 0) {
@@ -418,8 +419,8 @@ function renderJsonTreeLines(
418
419
  return;
419
420
  }
420
421
 
421
- const label = key ? theme.fg("muted", key) : theme.fg("muted", "value");
422
- pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", String(val))}`);
422
+ const label = key ? theme.fg("muted", sanitizeText(key)) : theme.fg("muted", "value");
423
+ pushLine(`${prefix}${iconScalar} ${label}: ${theme.fg("dim", sanitizeText(String(val)))}`);
423
424
  };
424
425
 
425
426
  const renderRoot = (val: unknown) => {
@@ -466,7 +467,7 @@ function stripRecentOutputNoticeLine(text: string): string {
466
467
  }
467
468
 
468
469
  function sanitizeRecentOutput(output: string): string {
469
- let text = output.trimEnd();
470
+ let text = sanitizeText(output).trimEnd();
470
471
  while (text) {
471
472
  const withoutArtifactNotice = stripRawOutputArtifactNotice(text).text;
472
473
  if (withoutArtifactNotice !== text) {
@@ -498,7 +499,8 @@ function renderOutputSection(
498
499
  warning?: string,
499
500
  ): string[] {
500
501
  const lines: string[] = [];
501
- const trimmedOutput = output.trimEnd();
502
+ const sanitizedOutput = sanitizeText(output);
503
+ const trimmedOutput = sanitizedOutput.trimEnd();
502
504
  if (!trimmedOutput && !warning) return lines;
503
505
 
504
506
  if (warning) {
@@ -506,7 +508,7 @@ function renderOutputSection(
506
508
  lines.push(
507
509
  `${continuePrefix} ${theme.fg("warning", theme.status.warning)} ${theme.fg(
508
510
  "dim",
509
- truncateToWidth(warning, 80),
511
+ truncateToWidth(sanitizeText(warning), 80),
510
512
  )}`,
511
513
  );
512
514
 
@@ -537,8 +539,7 @@ function renderOutputSection(
537
539
  // Fall back to raw output
538
540
  }
539
541
  }
540
-
541
- const outputLines = output.trimEnd().split("\n");
542
+ const outputLines = trimmedOutput.split("\n");
542
543
  const previewCount = expanded ? maxExpanded : maxCollapsed;
543
544
  for (const line of outputLines.slice(0, previewCount)) {
544
545
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
@@ -581,8 +582,7 @@ function renderOutputSection(
581
582
  }
582
583
 
583
584
  lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
584
-
585
- const outputLines = output.trimEnd().split("\n");
585
+ const outputLines = trimmedOutput.split("\n");
586
586
  const previewCount = expanded ? maxExpanded : maxCollapsed;
587
587
  for (const line of outputLines.slice(0, previewCount)) {
588
588
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
@@ -603,7 +603,7 @@ function renderTaskSection(
603
603
  maxExpanded = 20,
604
604
  ): string[] {
605
605
  const lines: string[] = [];
606
- const trimmed = task.trim();
606
+ const trimmed = sanitizeText(task).trim();
607
607
  if (!expanded || !trimmed) return lines;
608
608
 
609
609
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
@@ -624,10 +624,11 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri
624
624
  if (typeof value === "boolean") return String(value);
625
625
  if (typeof value === "number") return String(value);
626
626
  if (typeof value === "string") {
627
- const firstLine = value.split("\n")[0].trim();
628
- if (firstLine.length === 0) return `"" (${value.split("\n").length} lines)`;
627
+ const sanitizedValue = sanitizeText(value);
628
+ const firstLine = sanitizedValue.split("\n")[0].trim();
629
+ if (firstLine.length === 0) return `"" (${sanitizedValue.split("\n").length} lines)`;
629
630
  const preview = truncateToWidth(firstLine, maxLen);
630
- if (value.includes("\n")) return `"${preview}…" (${value.split("\n").length} lines)`;
631
+ if (sanitizedValue.includes("\n")) return `"${preview}…" (${sanitizedValue.split("\n").length} lines)`;
631
632
  return `"${preview}"`;
632
633
  }
633
634
  if (Array.isArray(value)) return `[${value.length} items]`;
@@ -635,7 +636,7 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri
635
636
  const keys = Object.keys(value);
636
637
  return `{${keys.length} keys}`;
637
638
  }
638
- return String(value);
639
+ return sanitizeText(String(value));
639
640
  }
640
641
 
641
642
  function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string {
@@ -662,7 +663,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
662
663
 
663
664
  for (const [key, value] of entries) {
664
665
  const valueStr = formatScalarInline(value, 24, theme);
665
- const pairStr = `${key}=${valueStr}`;
666
+ const pairStr = `${sanitizeText(key)}=${valueStr}`;
666
667
  const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; // +2 for ", "
667
668
 
668
669
  if (totalLen + addLen > maxWidth && pairs.length > 0) {
@@ -760,7 +761,9 @@ function createAssignmentSectionRenderer(
760
761
  // `renderResult` receives the raw tool args (unlike `renderCall`, which is
761
762
  // fed through `repairTaskParams`), so undo any per-field double-encoding
762
763
  // here too. The repair is idempotent on already-clean text.
763
- const assignment = repairDoubleEncodedJsonString(typeof args?.assignment === "string" ? args.assignment : "").trim();
764
+ const assignment = sanitizeText(
765
+ repairDoubleEncodedJsonString(typeof args?.assignment === "string" ? args.assignment : ""),
766
+ ).trim();
764
767
  if (!assignment) return undefined;
765
768
  return createMarkdownSectionRenderer(assignment, theme);
766
769
  }
@@ -774,7 +777,9 @@ function createContextSectionRenderer(
774
777
  args: Partial<TaskParams> | undefined,
775
778
  theme: Theme,
776
779
  ): AssignmentSectionRenderer | undefined {
777
- const context = repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : "").trim();
780
+ const context = sanitizeText(
781
+ repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : ""),
782
+ ).trim();
778
783
  if (!context) return undefined;
779
784
  return createMarkdownSectionRenderer(context, theme);
780
785
  }
@@ -858,7 +863,7 @@ function renderAgentProgress(
858
863
  : "accent";
859
864
 
860
865
  // Main status line: id: description [status] · stats · ⟨agent⟩
861
- const trimmedDescription = progress.description?.trim();
866
+ const trimmedDescription = progress.description ? sanitizeText(progress.description).trim() : undefined;
862
867
  const description = trimmedDescription ? previewLine(trimmedDescription, 64) : undefined;
863
868
  const displayId = formatTaskId(progress.id);
864
869
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
@@ -900,7 +905,7 @@ function renderAgentProgress(
900
905
  const showBadge = settings.get("task.showResolvedModelBadge");
901
906
  if (progress.status === "running") {
902
907
  if (!description) {
903
- const taskPreview = previewLine(progress.assignment ?? progress.task, 40);
908
+ const taskPreview = previewLine(sanitizeText(progress.assignment ?? progress.task), 40);
904
909
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
905
910
  }
906
911
  statusLine = appendAgentStats(statusLine, { ...progress, showResolvedModelBadge: showBadge }, theme);
@@ -915,10 +920,10 @@ function renderAgentProgress(
915
920
  // Current tool (if running) or most recent completed tool
916
921
  if (progress.status === "running") {
917
922
  if (progress.currentTool) {
918
- let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", progress.currentTool)}`;
923
+ let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
919
924
  const toolDetail = progress.lastIntent ?? progress.currentToolArgs;
920
925
  if (toolDetail) {
921
- toolLine += `: ${theme.fg("dim", previewLine(toolDetail, 40))}`;
926
+ toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
922
927
  }
923
928
  if (progress.currentToolStartMs) {
924
929
  const elapsed = Date.now() - progress.currentToolStartMs;
@@ -930,10 +935,10 @@ function renderAgentProgress(
930
935
  } else if (progress.recentTools.length > 0) {
931
936
  // Show most recent completed tool when idle between tools
932
937
  const recent = progress.recentTools[0];
933
- let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", recent.tool)}`;
938
+ let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", sanitizeText(recent.tool))}`;
934
939
  const toolDetail = progress.lastIntent ?? recent.args;
935
940
  if (toolDetail) {
936
- toolLine += `: ${theme.fg("dim", previewLine(toolDetail, 40))}`;
941
+ toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
937
942
  }
938
943
  lines.push(toolLine);
939
944
  }
@@ -947,12 +952,12 @@ function renderAgentProgress(
947
952
  const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
948
953
  const summary =
949
954
  `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
950
- previewLine(progress.retryState.errorMessage, 60);
955
+ previewLine(sanitizeText(progress.retryState.errorMessage), 60);
951
956
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
952
957
  } else if (progress.retryFailure && progress.status !== "running") {
953
958
  const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
954
959
  progress.retryFailure.attempt === 1 ? "" : "s"
955
- }: ${previewLine(progress.retryFailure.errorMessage, 80)}`;
960
+ }: ${previewLine(sanitizeText(progress.retryFailure.errorMessage), 80)}`;
956
961
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("error", summary)}`);
957
962
  }
958
963
 
@@ -1098,13 +1103,13 @@ function renderReviewResult(
1098
1103
  if (summary.explanation) {
1099
1104
  if (expanded) {
1100
1105
  lines.push(`${continuePrefix}${theme.fg("dim", "Summary")}`);
1101
- const explanationLines = summary.explanation.split("\n");
1106
+ const explanationLines = sanitizeText(summary.explanation).split("\n");
1102
1107
  for (const line of explanationLines) {
1103
1108
  lines.push(`${continuePrefix} ${theme.fg("dim", replaceTabs(line))}`);
1104
1109
  }
1105
1110
  } else {
1106
1111
  // Preview: first sentence or ~100 chars (flatten tabs/newlines first)
1107
- const flat = replaceTabs(summary.explanation).replace(/[\r\n]+/g, " ");
1112
+ const flat = replaceTabs(sanitizeText(summary.explanation)).replace(/[\r\n]+/g, " ");
1108
1113
  const firstSentence = flat.split(/[.!?]/)[0].trim();
1109
1114
  const preview = truncateToWidth(`${firstSentence}.`, 100);
1110
1115
  lines.push(`${continuePrefix}${theme.fg("dim", preview)}`);
@@ -1145,9 +1150,9 @@ function renderFindings(
1145
1150
  const findingContinue = isLastFinding ? " " : `${theme.tree.vertical} `;
1146
1151
 
1147
1152
  const { color } = getPriorityInfo(finding.priority);
1148
- const rawTitle = finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled";
1153
+ const rawTitle = sanitizeText(finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled");
1149
1154
  const titleText = replaceTabs(rawTitle).replace(/[\r\n]+/g, " ");
1150
- const loc = `${path.basename(finding.file_path || "<unknown>")}:${finding.line_start}`;
1155
+ const loc = `${path.basename(sanitizeText(finding.file_path || "<unknown>"))}:${finding.line_start}`;
1151
1156
 
1152
1157
  lines.push(
1153
1158
  `${continuePrefix}${findingPrefix} ${theme.fg(color, `[${finding.priority}]`)} ${titleText} ${theme.fg("dim", loc)}`,
@@ -1156,7 +1161,7 @@ function renderFindings(
1156
1161
  // Show body when expanded
1157
1162
  if (expanded && finding.body) {
1158
1163
  // Wrap body text
1159
- const bodyLines = finding.body.split("\n");
1164
+ const bodyLines = sanitizeText(finding.body).split("\n");
1160
1165
  for (const bodyLine of bodyLines) {
1161
1166
  lines.push(`${continuePrefix}${findingContinue}${theme.fg("dim", replaceTabs(bodyLine))}`);
1162
1167
  }
@@ -1208,7 +1213,7 @@ function renderAgentResult(
1208
1213
  : "failed";
1209
1214
 
1210
1215
  // Main status line: id: description [status] · stats · ⟨agent⟩
1211
- const trimmedDescription = result.description?.trim();
1216
+ const trimmedDescription = result.description ? sanitizeText(result.description).trim() : undefined;
1212
1217
  const description = trimmedDescription ? previewLine(trimmedDescription, 64) : undefined;
1213
1218
  const displayId = formatTaskId(result.id);
1214
1219
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
@@ -1242,7 +1247,7 @@ function renderAgentResult(
1242
1247
 
1243
1248
  if (aborted && result.abortReason) {
1244
1249
  lines.push(
1245
- `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", previewLine(result.abortReason, 80))}`,
1250
+ `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", previewLine(sanitizeText(result.abortReason), 80))}`,
1246
1251
  );
1247
1252
  }
1248
1253
  // Check for review result, preferring incremental yield sections and falling
@@ -1346,7 +1351,7 @@ function renderAgentResult(
1346
1351
  lines.push(
1347
1352
  `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg(
1348
1353
  "dim",
1349
- truncateToWidth(missingCompleteWarning, 80),
1354
+ truncateToWidth(sanitizeText(missingCompleteWarning), 80),
1350
1355
  )}`,
1351
1356
  );
1352
1357
  }
@@ -1370,7 +1375,9 @@ function renderAgentResult(
1370
1375
 
1371
1376
  // Error message
1372
1377
  if (result.error && (!success || mergeFailed) && (!aborted || result.error !== result.abortReason)) {
1373
- lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", previewLine(result.error, 70))}`);
1378
+ lines.push(
1379
+ `${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", previewLine(sanitizeText(result.error), 70))}`,
1380
+ );
1374
1381
  }
1375
1382
 
1376
1383
  return lines;
@@ -3,7 +3,7 @@ import * as path from "node:path";
3
3
  import type { Skill } from "../extensibility/skills";
4
4
  import { type LocalProtocolOptions, resolveLocalUrlToPath } from "../internal-urls";
5
5
  import { validateRelativePath } from "../internal-urls/skill-protocol";
6
- import type { InternalResource } from "../internal-urls/types";
6
+ import type { InternalResource, ResolveContext } from "../internal-urls/types";
7
7
  import { normalizeLocalScheme } from "./path-utils";
8
8
  import { ToolError } from "./tool-errors";
9
9
 
@@ -19,7 +19,7 @@ type SupportedInternalScheme = (typeof SUPPORTED_INTERNAL_SCHEMES)[number];
19
19
 
20
20
  interface InternalUrlResolver {
21
21
  canHandle(input: string): boolean;
22
- resolve(input: string): Promise<InternalResource>;
22
+ resolve(input: string, context?: ResolveContext): Promise<InternalResource>;
23
23
  }
24
24
 
25
25
  export interface InternalUrlExpansionOptions {
@@ -27,6 +27,7 @@ export interface InternalUrlExpansionOptions {
27
27
  noEscape?: boolean;
28
28
  internalRouter?: InternalUrlResolver;
29
29
  localOptions?: LocalProtocolOptions;
30
+ cwd?: string;
30
31
  ensureLocalParentDirs?: boolean;
31
32
  }
32
33
 
@@ -66,7 +67,7 @@ export function resolveSkillUrlToPath(url: string, skills: readonly Skill[]): st
66
67
  const hasRelativePath = rawPath !== "" && rawPath !== "/";
67
68
 
68
69
  if (!hasRelativePath) {
69
- return path.resolve(skill.filePath);
70
+ return path.resolve(skill.baseDir);
70
71
  }
71
72
 
72
73
  let relativePath: string;
@@ -151,6 +152,7 @@ async function resolveInternalUrlToPath(
151
152
  internalRouter?: InternalUrlResolver,
152
153
  localOptions?: LocalProtocolOptions,
153
154
  ensureLocalParentDirs?: boolean,
155
+ cwd?: string,
154
156
  ): Promise<string> {
155
157
  const url = normalizeLocalScheme(rawUrl);
156
158
  const scheme = extractScheme(url);
@@ -184,7 +186,7 @@ async function resolveInternalUrlToPath(
184
186
 
185
187
  let resource: InternalResource;
186
188
  try {
187
- resource = await internalRouter.resolve(url);
189
+ resource = await internalRouter.resolve(url, { cwd });
188
190
  } catch (error) {
189
191
  const message = error instanceof Error ? error.message : String(error);
190
192
  throw new ToolError(`Failed to resolve ${scheme}:// URL in bash command: ${url}\n${message}`);
@@ -239,6 +241,7 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
239
241
  options.internalRouter,
240
242
  options.localOptions,
241
243
  options.ensureLocalParentDirs,
244
+ options.cwd,
242
245
  );
243
246
  const replacement = options.noEscape ? resolvedPath : shellEscape(resolvedPath);
244
247
  expanded = `${expanded.slice(0, index)}${replacement}${expanded.slice(index + token.length)}`;