@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.1

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 (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. package/src/web/search/providers/codex.ts +68 -21
@@ -9,6 +9,17 @@ function normalizeIdentityValue(value: unknown): string | undefined {
9
9
  * True when a single usage-limit column belongs to the given OAuth identity.
10
10
  *
11
11
  * Single definition of the matching rules for both `/usage` renderers:
12
+ * - `orgId` ↔ report metadata `orgId` — a GATE that QUALIFIES the base
13
+ * identity, never a replacement for it. Mismatched org presence or
14
+ * different orgs never match: two subscriptions (orgs) can share one
15
+ * email, so an org-scoped identity matches only its own org's reports and
16
+ * an org-less legacy identity never claims an org-attributed report via
17
+ * the shared email. A SHARED org still requires the base-identity match
18
+ * below — Anthropic Team seats have per-user pools yet share the org id
19
+ * in report metadata. Only an org-only identity (no base identifiers
20
+ * recovered at all) matches on the org alone. When neither side carries
21
+ * an org, the base fallback applies unchanged (providers without orgs
22
+ * keep their former behavior).
12
23
  * - `accountId` ↔ report metadata `accountId`/`account_id` or `limit.scope.accountId`
13
24
  * - `email` ↔ report metadata `email`
14
25
  * - `projectId` ↔ report metadata `projectId` or `limit.scope.projectId`
@@ -22,14 +33,23 @@ export function limitMatchesActiveAccount(
22
33
  if (!identity) return false;
23
34
  const metadata = report.metadata ?? {};
24
35
  const activeAccountId = normalizeIdentityValue(identity.accountId);
36
+ const activeEmail = normalizeIdentityValue(identity.email);
37
+ const activeProjectId = normalizeIdentityValue(identity.projectId);
38
+ const activeOrgId = normalizeIdentityValue(identity.orgId);
39
+ const reportOrgId = normalizeIdentityValue(metadata.orgId);
40
+ // Org gate (see doc comment above): different/mismatched-presence orgs
41
+ // never match; a shared org falls through to the base checks unless the
42
+ // identity is org-only.
43
+ if (activeOrgId || reportOrgId) {
44
+ if (activeOrgId !== reportOrgId) return false;
45
+ if (!activeAccountId && !activeEmail && !activeProjectId) return true;
46
+ }
25
47
  if (activeAccountId) {
26
48
  const reportAccountId = normalizeIdentityValue(metadata.accountId) ?? normalizeIdentityValue(metadata.account_id);
27
49
  if (reportAccountId === activeAccountId) return true;
28
50
  if (normalizeIdentityValue(limit.scope.accountId) === activeAccountId) return true;
29
51
  }
30
- const activeEmail = normalizeIdentityValue(identity.email);
31
52
  if (activeEmail && normalizeIdentityValue(metadata.email) === activeEmail) return true;
32
- const activeProjectId = normalizeIdentityValue(identity.projectId);
33
53
  if (activeProjectId) {
34
54
  if (normalizeIdentityValue(metadata.projectId) === activeProjectId) return true;
35
55
  if (normalizeIdentityValue(limit.scope.projectId) === activeProjectId) return true;
@@ -22,13 +22,16 @@ function nonEmpty(value: string | undefined): string | undefined {
22
22
  function oauthLabel(row: StoredAuthCredential): string {
23
23
  const credential = row.credential;
24
24
  if (credential.type !== "oauth") return `API key #${row.id}`;
25
- return (
25
+ const base =
26
26
  nonEmpty(credential.email) ??
27
27
  nonEmpty(credential.accountId) ??
28
28
  nonEmpty(credential.projectId) ??
29
29
  nonEmpty(credential.enterpriseUrl) ??
30
- `OAuth credential #${row.id}`
31
- );
30
+ `OAuth credential #${row.id}`;
31
+ // Two subscriptions (orgs) can share one email — the org is the only
32
+ // user-visible way to tell which row a logout will remove.
33
+ const org = nonEmpty(credential.orgName) ?? nonEmpty(credential.orgId);
34
+ return org && org !== base ? `${base} (${org})` : base;
32
35
  }
33
36
 
34
37
  function oauthDetail(row: StoredAuthCredential, label: string): string {
@@ -53,6 +56,23 @@ function oauthMatchesActiveIdentity(
53
56
  ): boolean {
54
57
  if (!activeIdentity || row.credential.type !== "oauth") return false;
55
58
  const credential = row.credential;
59
+ // The org GATES the base identity rather than replacing it: mismatched org
60
+ // presence or different orgs never match — an org-scoped active session
61
+ // must not preselect the bare-email legacy row, and a bare-email active
62
+ // row must not mark org-scoped siblings active via the shared email. A
63
+ // SHARED org still requires the base-identity match below: two Team seats
64
+ // share one orgId yet own distinct rows. Only an org-only active identity
65
+ // (no base identifiers recovered at all) matches on the org alone.
66
+ if (activeIdentity.orgId !== undefined || credential.orgId !== undefined) {
67
+ if (credential.orgId !== activeIdentity.orgId) return false;
68
+ if (
69
+ activeIdentity.accountId === undefined &&
70
+ activeIdentity.email === undefined &&
71
+ activeIdentity.projectId === undefined
72
+ ) {
73
+ return true;
74
+ }
75
+ }
56
76
  return (
57
77
  (activeIdentity.accountId !== undefined && credential.accountId === activeIdentity.accountId) ||
58
78
  (activeIdentity.email !== undefined && credential.email === activeIdentity.email) ||
@@ -25,14 +25,26 @@ function formatUsageAmount(limit: UsageLimit): string {
25
25
  }
26
26
 
27
27
  function formatUsageReportAccount(report: UsageReport, limit: UsageLimit, index: number): string {
28
+ const metaOrgName = report.metadata?.orgName;
29
+ const metaOrgId = report.metadata?.orgId;
30
+ const org =
31
+ typeof metaOrgName === "string" && metaOrgName
32
+ ? metaOrgName
33
+ : typeof metaOrgId === "string" && metaOrgId
34
+ ? metaOrgId
35
+ : undefined;
36
+ // Two subscriptions (orgs) can share one email — suffix the org so the rows
37
+ // are tellable apart.
28
38
  const email = report.metadata?.email;
29
- if (typeof email === "string" && email) return email;
39
+ if (typeof email === "string" && email) return org ? `${email} (${org})` : email;
30
40
  // Guard metadata values for truthiness before using, then fall back to scope.
31
41
  // ?? won't help here: empty string is not null/undefined, so it would suppress
32
42
  // a valid scoped fallback (e.g. metadata.accountId="" hides limit.scope.accountId).
33
43
  const metaAccountId = report.metadata?.accountId;
34
44
  const accountId = typeof metaAccountId === "string" && metaAccountId ? metaAccountId : limit.scope.accountId;
35
- if (typeof accountId === "string" && accountId) return accountId;
45
+ if (typeof accountId === "string" && accountId) {
46
+ return org && org !== accountId ? `${accountId} (${org})` : accountId;
47
+ }
36
48
  const metaProjectId = report.metadata?.projectId;
37
49
  const projectId = typeof metaProjectId === "string" && metaProjectId ? metaProjectId : limit.scope.projectId;
38
50
  if (typeof projectId === "string" && projectId) return projectId;
@@ -159,6 +159,12 @@ export function createWorkerSubprocess<Outbound>(options: {
159
159
  spawnCommand: WorkerSpawnCommand;
160
160
  env: Record<string, string>;
161
161
  exitLabel: string;
162
+ /** Start the child as a new process-group/session leader where Bun supports it. */
163
+ detached?: boolean;
164
+ /** Treat exit code 0 as unexpected; eval cells can call process.exit(0). */
165
+ reportCleanExit?: boolean;
166
+ /** Whether an idle worker should stop keeping the parent event loop alive. */
167
+ unref?: boolean;
162
168
  }): SpawnedSubprocess<Outbound> {
163
169
  const inbound = new Set<(message: Outbound) => void>();
164
170
  const errors = new Set<(error: Error) => void>();
@@ -175,6 +181,7 @@ export function createWorkerSubprocess<Outbound>(options: {
175
181
  const proc = Bun.spawn({
176
182
  cmd: options.spawnCommand.cmd,
177
183
  cwd: options.spawnCommand.cwd,
184
+ detached: options.detached,
178
185
  env: options.env,
179
186
  stdin: "ignore",
180
187
  stdout: "ignore",
@@ -186,7 +193,7 @@ export function createWorkerSubprocess<Outbound>(options: {
186
193
  },
187
194
  onExit(_proc, exitCode, signalCode) {
188
195
  startStderrDrain();
189
- if (exitCode === 0) return;
196
+ if (exitCode === 0 && !options.reportCleanExit) return;
190
197
  // Swallow only the expected SIGKILL from `terminate()`; every other
191
198
  // signal exit (SIGSEGV from a native fault, OOM SIGKILL, operator
192
199
  // `kill -9`) is a real worker death that must fault in-flight
@@ -206,7 +213,7 @@ export function createWorkerSubprocess<Outbound>(options: {
206
213
  // Don't keep the parent event loop alive on an idle worker; the dispose
207
214
  // path calls `terminate()` explicitly. Bun's test runner starves IPC for
208
215
  // unref'd subprocesses, so keep it referenced only under tests.
209
- if (!isBunTestRuntime()) proc.unref();
216
+ if (!isBunTestRuntime() && options.unref !== false) proc.unref();
210
217
  return { proc, inbound, errors, intentionalExit, stderrDrained: stderrDrained.promise };
211
218
  }
212
219
 
@@ -2325,14 +2325,22 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2325
2325
  thinkingLevel: resolvedThinkingLevel,
2326
2326
  explicitThinkingLevel,
2327
2327
  authFallbackUsed,
2328
+ warning: modelResolutionWarning,
2328
2329
  } = await awaitAbortable(
2329
2330
  resolveModelOverrideWithAuthFallback(
2330
2331
  modelPatterns,
2331
2332
  options.parentActiveModelPattern,
2332
2333
  modelRegistry,
2333
2334
  settings,
2335
+ id,
2334
2336
  ),
2335
2337
  );
2338
+ if (modelResolutionWarning) {
2339
+ logger.warn("Subagent model resolution warning", {
2340
+ warning: modelResolutionWarning,
2341
+ requested: modelPatterns,
2342
+ });
2343
+ }
2336
2344
  if (authFallbackUsed && model) {
2337
2345
  logger.warn("Subagent model has no working credentials; falling back to parent session model", {
2338
2346
  requested: modelPatterns,
@@ -184,6 +184,42 @@ describe("task live progress rendering", () => {
184
184
  expect(collapsedText).not.toContain("line 1");
185
185
  expect(collapsedText).not.toContain("raw output:");
186
186
  });
187
+ it("sanitizes control sequences from expanded subagent recent output", () => {
188
+ setViewportRows(40);
189
+ const progress = makeProgress(["safe after \x1b[2Kclear", "raw\rprompt"]);
190
+
191
+ const text = renderProgressText(progress, true, uiTheme);
192
+
193
+ expect(text).toContain("safe after clear");
194
+ expect(text).toContain("rawprompt");
195
+ expect(text).not.toContain("\x1b[2K");
196
+ expect(text).not.toContain("\r");
197
+ });
198
+ it("sanitizes control sequences from finalized subagent results", () => {
199
+ const output = JSON.stringify({ "\x1b[2Kkey": "safe value" });
200
+ const details: TaskToolDetails = {
201
+ projectAgentsDir: null,
202
+ results: [
203
+ makeSingleResult(0, {
204
+ id: "Final\x1b[2KAgent",
205
+ description: "description\rtext",
206
+ aborted: true,
207
+ abortReason: "aborted \x1b[2Kreason",
208
+ output,
209
+ }),
210
+ ],
211
+ totalDurationMs: 1,
212
+ };
213
+
214
+ const text = renderResultText(details, true, uiTheme);
215
+
216
+ expect(text).toContain("FinalAgent");
217
+ expect(text).toContain("descriptiontext");
218
+ expect(text).toContain("aborted reason");
219
+ expect(text).toContain("key");
220
+ expect(text).not.toContain("\x1b[2K");
221
+ expect(text).not.toContain("\r");
222
+ });
187
223
 
188
224
  it("caps collapsed nested task progress at four rows plus an elision line", () => {
189
225
  setViewportRows(40);
@@ -7,7 +7,7 @@
7
7
  import path from "node:path";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
9
  import { Container, Markdown, Text } from "@oh-my-pi/pi-tui";
10
- import { formatNumber } from "@oh-my-pi/pi-utils";
10
+ import { formatNumber, sanitizeText } from "@oh-my-pi/pi-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
 
@@ -538,7 +540,7 @@ function renderOutputSection(
538
540
  }
539
541
  }
540
542
 
541
- const outputLines = output.trimEnd().split("\n");
543
+ const outputLines = trimmedOutput.split("\n");
542
544
  const previewCount = expanded ? maxExpanded : maxCollapsed;
543
545
  for (const line of outputLines.slice(0, previewCount)) {
544
546
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
@@ -582,7 +584,7 @@ function renderOutputSection(
582
584
 
583
585
  lines.push(`${continuePrefix}${theme.fg("dim", "Output")}`);
584
586
 
585
- const outputLines = output.trimEnd().split("\n");
587
+ const outputLines = trimmedOutput.split("\n");
586
588
  const previewCount = expanded ? maxExpanded : maxCollapsed;
587
589
  for (const line of outputLines.slice(0, previewCount)) {
588
590
  lines.push(`${continuePrefix} ${theme.fg("dim", truncateToWidth(replaceTabs(line), 70))}`);
@@ -603,7 +605,7 @@ function renderTaskSection(
603
605
  maxExpanded = 20,
604
606
  ): string[] {
605
607
  const lines: string[] = [];
606
- const trimmed = task.trim();
608
+ const trimmed = sanitizeText(task).trim();
607
609
  if (!expanded || !trimmed) return lines;
608
610
 
609
611
  lines.push(`${continuePrefix}${theme.fg("dim", "Task")}`);
@@ -624,10 +626,11 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri
624
626
  if (typeof value === "boolean") return String(value);
625
627
  if (typeof value === "number") return String(value);
626
628
  if (typeof value === "string") {
627
- const firstLine = value.split("\n")[0].trim();
628
- if (firstLine.length === 0) return `"" (${value.split("\n").length} lines)`;
629
+ const sanitizedValue = sanitizeText(value);
630
+ const firstLine = sanitizedValue.split("\n")[0].trim();
631
+ if (firstLine.length === 0) return `"" (${sanitizedValue.split("\n").length} lines)`;
629
632
  const preview = truncateToWidth(firstLine, maxLen);
630
- if (value.includes("\n")) return `"${preview}…" (${value.split("\n").length} lines)`;
633
+ if (sanitizedValue.includes("\n")) return `"${preview}…" (${sanitizedValue.split("\n").length} lines)`;
631
634
  return `"${preview}"`;
632
635
  }
633
636
  if (Array.isArray(value)) return `[${value.length} items]`;
@@ -635,7 +638,7 @@ function formatScalarInline(value: unknown, maxLen: number, _theme: Theme): stri
635
638
  const keys = Object.keys(value);
636
639
  return `{${keys.length} keys}`;
637
640
  }
638
- return String(value);
641
+ return sanitizeText(String(value));
639
642
  }
640
643
 
641
644
  function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string {
@@ -662,7 +665,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
662
665
 
663
666
  for (const [key, value] of entries) {
664
667
  const valueStr = formatScalarInline(value, 24, theme);
665
- const pairStr = `${key}=${valueStr}`;
668
+ const pairStr = `${sanitizeText(key)}=${valueStr}`;
666
669
  const addLen = pairs.length > 0 ? pairStr.length + 2 : pairStr.length; // +2 for ", "
667
670
 
668
671
  if (totalLen + addLen > maxWidth && pairs.length > 0) {
@@ -683,7 +686,7 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
683
686
  */
684
687
  function taskFirstLine(task: unknown): string {
685
688
  if (typeof task !== "string") return "";
686
- const trimmed = task.trim();
689
+ const trimmed = sanitizeText(task).trim();
687
690
  const newline = trimmed.indexOf("\n");
688
691
  return newline === -1 ? trimmed : trimmed.slice(0, newline);
689
692
  }
@@ -791,7 +794,9 @@ function createAssignmentSectionRenderer(
791
794
  // `renderResult` receives the raw tool args (unlike `renderCall`, which is
792
795
  // fed through `repairTaskParams`), so undo any per-field double-encoding
793
796
  // here too. The repair is idempotent on already-clean text.
794
- const assignment = repairDoubleEncodedJsonString(typeof args?.task === "string" ? args.task : "").trim();
797
+ const assignment = sanitizeText(
798
+ repairDoubleEncodedJsonString(typeof args?.task === "string" ? args.task : ""),
799
+ ).trim();
795
800
  if (!assignment) return undefined;
796
801
  return createMarkdownSectionRenderer(assignment, theme);
797
802
  }
@@ -805,7 +810,9 @@ function createContextSectionRenderer(
805
810
  args: Partial<TaskParams> | undefined,
806
811
  theme: Theme,
807
812
  ): AssignmentSectionRenderer | undefined {
808
- const context = repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : "").trim();
813
+ const context = sanitizeText(
814
+ repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : ""),
815
+ ).trim();
809
816
  if (!context) return undefined;
810
817
  return createMarkdownSectionRenderer(context, theme);
811
818
  }
@@ -894,7 +901,7 @@ function renderAgentProgress(
894
901
 
895
902
  // Main status line: id: description [status] · stats · ⟨agent⟩
896
903
  const trimmedDescription = progress.description?.trim();
897
- const description = trimmedDescription ? previewLine(trimmedDescription, 64) : undefined;
904
+ const description = trimmedDescription ? previewLine(sanitizeText(trimmedDescription), 64) : undefined;
898
905
  const displayId = formatTaskId(progress.id);
899
906
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
900
907
  const indent = prefix ? `${prefix} ` : "";
@@ -936,7 +943,7 @@ function renderAgentProgress(
936
943
  const showBadge = settings.get("task.showResolvedModelBadge");
937
944
  if (progress.status === "running") {
938
945
  if (!description) {
939
- const taskPreview = previewLine(progress.assignment ?? progress.task, 40);
946
+ const taskPreview = previewLine(sanitizeText(progress.assignment ?? progress.task), 40);
940
947
  statusLine += ` ${theme.fg("muted", taskPreview)}`;
941
948
  }
942
949
  statusLine = appendAgentStats(statusLine, { ...progress, showResolvedModelBadge: showBadge }, theme);
@@ -951,10 +958,10 @@ function renderAgentProgress(
951
958
  // Current tool (if running) or most recent completed tool
952
959
  if (progress.status === "running") {
953
960
  if (progress.currentTool) {
954
- let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", progress.currentTool)}`;
961
+ let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
955
962
  const toolDetail = progress.lastIntent ?? progress.currentToolArgs;
956
963
  if (toolDetail) {
957
- toolLine += `: ${theme.fg("dim", previewLine(toolDetail, 40))}`;
964
+ toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
958
965
  }
959
966
  if (progress.currentToolStartMs) {
960
967
  const elapsed = Date.now() - progress.currentToolStartMs;
@@ -966,10 +973,10 @@ function renderAgentProgress(
966
973
  } else if (progress.recentTools.length > 0) {
967
974
  // Show most recent completed tool when idle between tools
968
975
  const recent = progress.recentTools[0];
969
- let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", recent.tool)}`;
976
+ let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("dim", sanitizeText(recent.tool))}`;
970
977
  const toolDetail = progress.lastIntent ?? recent.args;
971
978
  if (toolDetail) {
972
- toolLine += `: ${theme.fg("dim", previewLine(toolDetail, 40))}`;
979
+ toolLine += `: ${theme.fg("dim", previewLine(sanitizeText(toolDetail), 40))}`;
973
980
  }
974
981
  lines.push(toolLine);
975
982
  }
@@ -983,12 +990,12 @@ function renderAgentProgress(
983
990
  const waitLabel = remainingMs > 0 ? `in ${formatDuration(remainingMs)}` : "now";
984
991
  const summary =
985
992
  `retrying ${progress.retryState.attempt}/${progress.retryState.maxAttempts} ${waitLabel}: ` +
986
- previewLine(progress.retryState.errorMessage, 60);
993
+ previewLine(sanitizeText(progress.retryState.errorMessage), 60);
987
994
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("warning", summary)}`);
988
995
  } else if (progress.retryFailure && progress.status !== "running") {
989
996
  const summary = `auto-retry gave up after ${progress.retryFailure.attempt} attempt${
990
997
  progress.retryFailure.attempt === 1 ? "" : "s"
991
- }: ${previewLine(progress.retryFailure.errorMessage, 80)}`;
998
+ }: ${previewLine(sanitizeText(progress.retryFailure.errorMessage), 80)}`;
992
999
  lines.push(`${continuePrefix}${theme.tree.hook} ${theme.fg("error", summary)}`);
993
1000
  }
994
1001
 
@@ -1134,13 +1141,13 @@ function renderReviewResult(
1134
1141
  if (summary.explanation) {
1135
1142
  if (expanded) {
1136
1143
  lines.push(`${continuePrefix}${theme.fg("dim", "Summary")}`);
1137
- const explanationLines = summary.explanation.split("\n");
1144
+ const explanationLines = sanitizeText(summary.explanation).split("\n");
1138
1145
  for (const line of explanationLines) {
1139
1146
  lines.push(`${continuePrefix} ${theme.fg("dim", replaceTabs(line))}`);
1140
1147
  }
1141
1148
  } else {
1142
1149
  // Preview: first sentence or ~100 chars (flatten tabs/newlines first)
1143
- const flat = replaceTabs(summary.explanation).replace(/[\r\n]+/g, " ");
1150
+ const flat = replaceTabs(sanitizeText(summary.explanation)).replace(/[\r\n]+/g, " ");
1144
1151
  const firstSentence = flat.split(/[.!?]/)[0].trim();
1145
1152
  const preview = truncateToWidth(`${firstSentence}.`, 100);
1146
1153
  lines.push(`${continuePrefix}${theme.fg("dim", preview)}`);
@@ -1181,9 +1188,9 @@ function renderFindings(
1181
1188
  const findingContinue = isLastFinding ? " " : `${theme.tree.vertical} `;
1182
1189
 
1183
1190
  const { color } = getPriorityInfo(finding.priority);
1184
- const rawTitle = finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled";
1191
+ const rawTitle = sanitizeText(finding.title?.replace(/^\[P\d\]\s*/, "") ?? "Untitled");
1185
1192
  const titleText = replaceTabs(rawTitle).replace(/[\r\n]+/g, " ");
1186
- const loc = `${path.basename(finding.file_path || "<unknown>")}:${finding.line_start}`;
1193
+ const loc = `${path.basename(sanitizeText(finding.file_path || "<unknown>"))}:${finding.line_start}`;
1187
1194
 
1188
1195
  lines.push(
1189
1196
  `${continuePrefix}${findingPrefix} ${theme.fg(color, `[${finding.priority}]`)} ${titleText} ${theme.fg("dim", loc)}`,
@@ -1192,7 +1199,7 @@ function renderFindings(
1192
1199
  // Show body when expanded
1193
1200
  if (expanded && finding.body) {
1194
1201
  // Wrap body text
1195
- const bodyLines = finding.body.split("\n");
1202
+ const bodyLines = sanitizeText(finding.body).split("\n");
1196
1203
  for (const bodyLine of bodyLines) {
1197
1204
  lines.push(`${continuePrefix}${findingContinue}${theme.fg("dim", replaceTabs(bodyLine))}`);
1198
1205
  }
@@ -1244,7 +1251,7 @@ function renderAgentResult(
1244
1251
  : "failed";
1245
1252
 
1246
1253
  // Main status line: id: description [status] · stats · ⟨agent⟩
1247
- const trimmedDescription = result.description?.trim();
1254
+ const trimmedDescription = result.description ? sanitizeText(result.description).trim() : undefined;
1248
1255
  const description = trimmedDescription ? previewLine(trimmedDescription, 64) : undefined;
1249
1256
  const displayId = formatTaskId(result.id);
1250
1257
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
@@ -1278,7 +1285,10 @@ function renderAgentResult(
1278
1285
 
1279
1286
  if (aborted && result.abortReason) {
1280
1287
  lines.push(
1281
- `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg("dim", previewLine(result.abortReason, 80))}`,
1288
+ `${continuePrefix}${theme.fg("error", theme.status.aborted)} ${theme.fg(
1289
+ "dim",
1290
+ previewLine(sanitizeText(result.abortReason), 80),
1291
+ )}`,
1282
1292
  );
1283
1293
  }
1284
1294
  // Check for review result, preferring incremental yield sections and falling
@@ -1382,7 +1392,7 @@ function renderAgentResult(
1382
1392
  lines.push(
1383
1393
  `${continuePrefix}${theme.fg("warning", theme.status.warning)} ${theme.fg(
1384
1394
  "dim",
1385
- truncateToWidth(missingCompleteWarning, 80),
1395
+ truncateToWidth(sanitizeText(missingCompleteWarning), 80),
1386
1396
  )}`,
1387
1397
  );
1388
1398
  }
@@ -1406,7 +1416,9 @@ function renderAgentResult(
1406
1416
 
1407
1417
  // Error message
1408
1418
  if (result.error && (!success || mergeFailed) && (!aborted || result.error !== result.abortReason)) {
1409
- lines.push(`${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", previewLine(result.error, 70))}`);
1419
+ lines.push(
1420
+ `${continuePrefix}${theme.fg(mergeFailed ? "warning" : "error", previewLine(sanitizeText(result.error), 70))}`,
1421
+ );
1410
1422
  }
1411
1423
 
1412
1424
  return lines;
@@ -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
 
@@ -175,6 +176,7 @@ async function resolveInternalUrlToPath(
175
176
  internalRouter?: InternalUrlResolver,
176
177
  localOptions?: LocalProtocolOptions,
177
178
  ensureLocalParentDirs?: boolean,
179
+ cwd?: string,
178
180
  ): Promise<string> {
179
181
  const url = normalizeLocalScheme(rawUrl);
180
182
  const scheme = extractScheme(url);
@@ -208,7 +210,7 @@ async function resolveInternalUrlToPath(
208
210
 
209
211
  let resource: InternalResource;
210
212
  try {
211
- resource = await internalRouter.resolve(url, { pathOnly: true });
213
+ resource = await internalRouter.resolve(url, { cwd, pathOnly: true });
212
214
  } catch (error) {
213
215
  const message = error instanceof Error ? error.message : String(error);
214
216
  throw new ToolError(`Failed to resolve ${scheme}:// URL in bash command: ${url}\n${message}`);
@@ -268,6 +270,7 @@ export async function expandInternalUrls(command: string, options: InternalUrlEx
268
270
  options.internalRouter,
269
271
  options.localOptions,
270
272
  options.ensureLocalParentDirs,
273
+ options.cwd,
271
274
  );
272
275
  } catch {
273
276
  continue;
package/src/tools/bash.ts CHANGED
@@ -751,6 +751,7 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
751
751
  const internalUrlOptions: InternalUrlExpansionOptions = {
752
752
  skills: this.session.skills ?? [],
753
753
  internalRouter: InternalUrlRouter.instance(),
754
+ cwd: this.session.cwd,
754
755
  localOptions: {
755
756
  getArtifactsDir: this.session.getArtifactsDir,
756
757
  getSessionId: this.session.getSessionId,