@oh-my-pi/pi-coding-agent 17.0.6 → 17.0.8

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 (83) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/cli.js +4967 -4948
  3. package/dist/types/config/model-registry.d.ts +1 -1
  4. package/dist/types/config/model-resolver.d.ts +5 -1
  5. package/dist/types/config/settings-schema.d.ts +16 -0
  6. package/dist/types/dap/client.d.ts +19 -0
  7. package/dist/types/extensibility/extensions/runner.d.ts +4 -2
  8. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  9. package/dist/types/extensibility/typebox.d.ts +4 -0
  10. package/dist/types/hindsight/client.d.ts +10 -0
  11. package/dist/types/hindsight/config.d.ts +8 -0
  12. package/dist/types/mcp/tool-bridge.d.ts +9 -0
  13. package/dist/types/mnemopi/state.d.ts +2 -0
  14. package/dist/types/modes/components/user-message.d.ts +25 -1
  15. package/dist/types/modes/controllers/extension-ui-controller.d.ts +8 -0
  16. package/dist/types/modes/interactive-mode.d.ts +7 -1
  17. package/dist/types/modes/types.d.ts +14 -1
  18. package/dist/types/modes/utils/ui-helpers.d.ts +12 -8
  19. package/dist/types/sdk.d.ts +1 -0
  20. package/dist/types/session/agent-session-error-log.test.d.ts +1 -0
  21. package/dist/types/session/agent-session.d.ts +78 -2
  22. package/dist/types/task/executor.d.ts +10 -0
  23. package/dist/types/task/index.d.ts +1 -0
  24. package/dist/types/task/prewalk.d.ts +3 -0
  25. package/dist/types/tools/ask.d.ts +10 -0
  26. package/dist/types/tools/tool-timeouts.d.ts +8 -2
  27. package/dist/types/utils/changelog.d.ts +4 -6
  28. package/package.json +12 -13
  29. package/src/cli/models-cli.ts +37 -17
  30. package/src/cli/update-cli.ts +14 -1
  31. package/src/config/model-discovery.ts +3 -2
  32. package/src/config/model-registry.ts +53 -26
  33. package/src/config/model-resolver.ts +56 -31
  34. package/src/config/settings-schema.ts +5 -0
  35. package/src/config/settings.ts +68 -5
  36. package/src/dap/client.ts +86 -7
  37. package/src/edit/diff.ts +4 -4
  38. package/src/eval/py/prelude.py +5 -5
  39. package/src/export/html/template.js +23 -9
  40. package/src/extensibility/extensions/runner.ts +9 -4
  41. package/src/extensibility/extensions/types.ts +3 -0
  42. package/src/extensibility/typebox.ts +22 -9
  43. package/src/hindsight/client.test.ts +42 -0
  44. package/src/hindsight/client.ts +40 -3
  45. package/src/hindsight/config.ts +18 -0
  46. package/src/launch/broker.ts +31 -5
  47. package/src/lsp/index.ts +1 -1
  48. package/src/main.ts +15 -5
  49. package/src/mcp/tool-bridge.ts +9 -0
  50. package/src/mnemopi/state.ts +70 -3
  51. package/src/modes/components/agent-dashboard.ts +6 -2
  52. package/src/modes/components/chat-transcript-builder.ts +13 -2
  53. package/src/modes/components/diff.ts +2 -2
  54. package/src/modes/components/plan-review-overlay.ts +20 -4
  55. package/src/modes/components/user-message.ts +100 -1
  56. package/src/modes/controllers/command-controller.ts +22 -5
  57. package/src/modes/controllers/event-controller.ts +4 -1
  58. package/src/modes/controllers/extension-ui-controller.ts +18 -0
  59. package/src/modes/controllers/input-controller.ts +47 -12
  60. package/src/modes/controllers/selector-controller.ts +82 -6
  61. package/src/modes/interactive-mode.ts +36 -4
  62. package/src/modes/types.ts +16 -3
  63. package/src/modes/utils/ui-helpers.ts +40 -20
  64. package/src/prompts/system/workflow-notice.md +1 -1
  65. package/src/sdk.ts +134 -48
  66. package/src/session/agent-session-error-log.test.ts +59 -0
  67. package/src/session/agent-session.ts +300 -26
  68. package/src/system-prompt.ts +1 -2
  69. package/src/task/executor.ts +41 -27
  70. package/src/task/index.ts +11 -0
  71. package/src/task/prewalk.ts +6 -0
  72. package/src/task/worktree.ts +25 -11
  73. package/src/tools/ask.ts +15 -0
  74. package/src/tools/bash.ts +14 -6
  75. package/src/tools/browser.ts +1 -1
  76. package/src/tools/debug.ts +1 -1
  77. package/src/tools/eval.ts +4 -5
  78. package/src/tools/fetch.ts +1 -1
  79. package/src/tools/hub/launch.ts +7 -0
  80. package/src/tools/tool-timeouts.ts +10 -3
  81. package/src/tools/write.ts +31 -0
  82. package/src/utils/changelog.ts +54 -58
  83. package/src/utils/git.ts +57 -49
@@ -30,7 +30,6 @@ import { getSessionSlashCommands } from "../extensibility/extensions/get-command
30
30
  import { buildSkillPromptMessage, type Skill } from "../extensibility/skills";
31
31
  import type { HindsightSessionState } from "../hindsight/state";
32
32
  import type { LocalProtocolOptions } from "../internal-urls";
33
- import { callTool } from "../mcp/client";
34
33
  import type { MCPManager } from "../mcp/manager";
35
34
  import type { MnemopiSessionState } from "../mnemopi/state";
36
35
  import subagentSystemPromptTemplate from "../prompts/system/subagent-system-prompt.md" with { type: "text" };
@@ -55,6 +54,7 @@ import type { EventBus } from "../utils/event-bus";
55
54
  import { buildNamedToolChoice } from "../utils/tool-choice";
56
55
  import type { WorkspaceTree } from "../workspace-tree";
57
56
  import { generateTaskLabel } from "./label";
57
+ import { resolveAgentPrewalkDefault } from "./prewalk";
58
58
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
59
59
  import {
60
60
  type AgentDefinition,
@@ -729,46 +729,54 @@ function getUsageTokens(usage: unknown): number {
729
729
 
730
730
  /**
731
731
  * Create proxy tools that reuse the parent's MCP connections.
732
+ *
733
+ * Each proxy delegates to the current source `MCPTool`/`DeferredMCPTool` rather
734
+ * than rebuilding a raw `tools/call` request, so the Task/subagent path shares
735
+ * the source tool's authoritative outbound boundary: harness-intent (`i`)
736
+ * stripping, optional-placeholder pruning, local-URL resolution, reconnect
737
+ * retry, abort handling, and result/provider metadata. The source tool is
738
+ * re-resolved on every call by raw MCP server/tool metadata (not the normalized
739
+ * display name), so a reconnect that swaps the instance in `getTools()` is
740
+ * always honored. The proxy adds only the Task-specific 60s call timeout,
741
+ * combining its abort signal with the caller's around source execution.
732
742
  */
733
743
  export function createMCPProxyTools(mcpManager: MCPManager): CustomTool[] {
734
744
  return mcpManager.getTools().map(tool => {
735
- const mcpTool = tool as { mcpToolName?: string; mcpServerName?: string };
745
+ const serverName = tool.mcpServerName ?? "";
746
+ const mcpToolName = tool.mcpToolName ?? "";
736
747
  return {
737
748
  name: tool.name,
738
749
  label: tool.label ?? tool.name,
739
750
  description: tool.description ?? "",
740
751
  parameters: tool.parameters,
741
- execute: async (_toolCallId, params, _onUpdate, _ctx, signal) => {
752
+ strict: tool.strict,
753
+ mcpServerName: serverName,
754
+ mcpToolName,
755
+ execute: async (toolCallId, params, onUpdate, ctx, signal) => {
742
756
  if (signal?.aborted) {
743
757
  throw new ToolAbortError();
744
758
  }
745
- const serverName = mcpTool.mcpServerName ?? "";
746
- const mcpToolName = mcpTool.mcpToolName ?? "";
759
+ // Re-resolve by raw MCP metadata so a reconnect that replaced the
760
+ // source instance is picked up; the display name alone is not enough.
761
+ const source = mcpManager
762
+ .getTools()
763
+ .find(t => t.mcpServerName === serverName && t.mcpToolName === mcpToolName);
764
+ if (!source?.execute) {
765
+ return {
766
+ content: [{ type: "text" as const, text: `MCP error: tool ${mcpToolName} no longer available` }],
767
+ details: { serverName, mcpToolName, isError: true },
768
+ };
769
+ }
747
770
  try {
748
771
  const timeoutController = new AbortController();
749
772
  const timeoutSignal = timeoutController.signal;
750
773
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
751
- const result = await withAbortTimeout(
752
- (async () => {
753
- const connection = await untilAborted(combinedSignal, () =>
754
- mcpManager.waitForConnection(serverName),
755
- );
756
- return callTool(connection, mcpToolName, params as Record<string, unknown>, {
757
- signal: combinedSignal,
758
- });
759
- })(),
774
+ return await withAbortTimeout(
775
+ Promise.resolve(source.execute(toolCallId, params, onUpdate, ctx, combinedSignal)),
760
776
  MCP_CALL_TIMEOUT_MS,
761
777
  signal,
762
778
  timeoutController,
763
779
  );
764
- return {
765
- content: (result.content ?? []).map(item =>
766
- item.type === "text"
767
- ? { type: "text" as const, text: item.text ?? "" }
768
- : { type: "text" as const, text: JSON.stringify(item) },
769
- ),
770
- details: { serverName, mcpToolName, isError: result.isError },
771
- };
772
780
  } catch (error) {
773
781
  if (error instanceof ToolAbortError) {
774
782
  throw error;
@@ -1519,9 +1527,16 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1519
1527
  scheduleProgress(flushProgress);
1520
1528
  };
1521
1529
 
1522
- const attach = (session: AgentSession): (() => void) =>
1523
- session.subscribe(event => {
1530
+ const attach = (session: AgentSession): (() => void) => {
1531
+ let activeModel = session.model ? formatModelStringWithRouting(session.model) : undefined;
1532
+ return session.subscribe(event => {
1524
1533
  emitSubagentEvent(event);
1534
+ const nextModel = session.model ? formatModelStringWithRouting(session.model) : undefined;
1535
+ if (nextModel && nextModel !== activeModel) {
1536
+ activeModel = nextModel;
1537
+ progress.resolvedModel = nextModel;
1538
+ scheduleProgress(true);
1539
+ }
1525
1540
  if (event.type === "auto_retry_start") {
1526
1541
  progress.retryState = {
1527
1542
  attempt: event.attempt,
@@ -1572,6 +1587,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
1572
1587
  return;
1573
1588
  }
1574
1589
  });
1590
+ };
1575
1591
 
1576
1592
  const captureSalvage = (session: AgentSession): void => {
1577
1593
  // Best-effort salvage: capture the last assistant text so
@@ -2451,11 +2467,9 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2451
2467
  // frontmatter default; the `task.prewalk` toggle (default off) arms it.
2452
2468
  // Resolution failures skip prewalk instead of failing the spawn.
2453
2469
  let prewalk: Prewalk | undefined;
2454
- const genericTaskPrewalk =
2455
- agent.source === "bundled" && agent.name === "task" && settings.get("task.prewalk") ? true : undefined;
2456
2470
  const prewalkPattern = resolveAgentPrewalkPattern({
2457
2471
  settingsOverride: settings.get("task.agentPrewalk")[agent.name],
2458
- agentPrewalk: agent.prewalk ?? genericTaskPrewalk,
2472
+ agentPrewalk: resolveAgentPrewalkDefault(agent, settings.get("task.prewalk")),
2459
2473
  });
2460
2474
  if (prewalkPattern) {
2461
2475
  const resolvedPrewalk = resolveModelOverride([prewalkPattern], modelRegistry, settings);
package/src/task/index.ts CHANGED
@@ -520,6 +520,17 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
520
520
  readonly summary = "Spawn subagents to complete delegated tasks";
521
521
  readonly strict = false;
522
522
  readonly loadMode = "essential";
523
+ // Arktype validates model calls against the active wire schema, but the flat
524
+ // single-spawn schema carries `"+": "delete"`: a batch `{ context, tasks[] }`
525
+ // payload has those keys stripped, then fails on the now-missing `task` with
526
+ // the misleading `task must be a string (was missing)`. That preempts the
527
+ // tool's own actionable shape checks (`validateShapeParams` /
528
+ // `validateSpawnParams`), which never run. Lenient validation forwards the
529
+ // raw args to `execute()` on any arktype failure so those checks surface the
530
+ // real reason ("enable task.batch, or use the flat `task` shape"). Valid
531
+ // calls still normalize through arktype; `execute()` resolves `agent`
532
+ // defaults independently, so the success path is unchanged.
533
+ readonly lenientArgValidation = true;
523
534
  readonly renderResult = renderResult;
524
535
  // Suppress the streaming call preview once a (partial or final) result exists
525
536
  // so the task renders as ONE block that transitions in place — not a pending
@@ -0,0 +1,6 @@
1
+ import type { AgentDefinition } from "./types";
2
+
3
+ /** Resolve an agent's prewalk default, including the bundled task opt-in. */
4
+ export function resolveAgentPrewalkDefault(agent: AgentDefinition, taskPrewalk: boolean): boolean | string | undefined {
5
+ return agent.prewalk ?? (taskPrewalk && agent.source === "bundled" && agent.name === "task" ? true : undefined);
6
+ }
@@ -116,7 +116,16 @@ async function captureRepoBaseline(repoRoot: string): Promise<RepoBaseline> {
116
116
  return { repoRoot, headCommit, staged, unstaged, untracked, untrackedPatch };
117
117
  }
118
118
 
119
- async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches: readonly string[]): Promise<string> {
119
+ interface SyntheticTreeOptions {
120
+ readonly threeWay?: boolean;
121
+ }
122
+
123
+ async function writeSyntheticTree(
124
+ repoDir: string,
125
+ baseTreeish: string,
126
+ patches: readonly string[],
127
+ options: SyntheticTreeOptions = {},
128
+ ): Promise<string> {
120
129
  const tempIndex = path.join(os.tmpdir(), `omp-task-index-${Snowflake.next()}`);
121
130
  try {
122
131
  await git.readTree(repoDir, baseTreeish, {
@@ -127,6 +136,7 @@ async function writeSyntheticTree(repoDir: string, baseTreeish: string, patches:
127
136
  await git.patch.applyText(repoDir, patch, {
128
137
  cached: true,
129
138
  env: { GIT_INDEX_FILE: tempIndex },
139
+ threeWay: options.threeWay,
130
140
  });
131
141
  }
132
142
  return await git.writeTree(repoDir, {
@@ -653,11 +663,12 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
653
663
  try {
654
664
  await git.worktree.add(opts.repoRoot, tmpDir, opts.branchName);
655
665
  const agentCommits = await git.revList.range(opts.isolationDir, baselineSha, opts.isolationHead);
656
- const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, [
657
- opts.baseline.root.staged,
658
- opts.baseline.root.unstaged,
659
- opts.baseline.root.untrackedPatch,
660
- ]);
666
+ const baselineWip = [opts.baseline.root.staged, opts.baseline.root.unstaged, opts.baseline.root.untrackedPatch];
667
+ // Seed the parent ODB with the dirty-side blobs needed by `git apply
668
+ // --3way`. Isolation repositories can read parent objects, but the parent
669
+ // cannot read objects created only inside isolation.
670
+ await writeSyntheticTree(opts.repoRoot, baselineSha, baselineWip);
671
+ const dirtyBaselineTree = await writeSyntheticTree(opts.isolationDir, baselineSha, baselineWip);
661
672
  let previousFilteredTree = baselineSha;
662
673
  let filteredCommitsApplied = 0;
663
674
 
@@ -666,7 +677,9 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
666
677
  allowFailure: true,
667
678
  binary: true,
668
679
  });
669
- const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch]);
680
+ const currentFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [taskStatePatch], {
681
+ threeWay: true,
682
+ });
670
683
  const commitPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, currentFilteredTree, {
671
684
  allowFailure: true,
672
685
  binary: true,
@@ -699,10 +712,11 @@ async function replayFilteredAgentCommits(opts: FilteredAgentReplayOptions): Pro
699
712
  await commitPatchToBranchWorktree(tmpDir, opts.taskId, opts.rootPatch, msg, undefined, opts.baseline.root);
700
713
  }
701
714
  } else {
702
- // A filtered commit landed; tmpDir has advanced past baselineSha and
703
- // previousFilteredTree is HEAD-derived, so writeSyntheticTree +
704
- // leftoverPatch stay HEAD-based and no WIP seed is needed.
705
- const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch]);
715
+ // A filtered commit landed; reconstruct the final HEAD-derived tree
716
+ // with the same dirty-side blobs and 3-way synthesis used above.
717
+ const finalFilteredTree = await writeSyntheticTree(opts.repoRoot, baselineSha, [opts.rootPatch], {
718
+ threeWay: true,
719
+ });
706
720
  const leftoverPatch = await git.diff.tree(opts.repoRoot, previousFilteredTree, finalFilteredTree, {
707
721
  allowFailure: true,
708
722
  binary: true,
package/src/tools/ask.ts CHANGED
@@ -81,6 +81,21 @@ const askSchema = arkType({
81
81
 
82
82
  export type AskToolInput = typeof askSchema.infer;
83
83
 
84
+ /**
85
+ * Recover a validated `questions` payload from a persisted `ask` toolCall's
86
+ * `arguments`. Used by `/tree` re-answer (issue #5642): selecting a past
87
+ * `ask` toolResult re-opens the picker with the *original* questions, so the
88
+ * new answer branches as a sibling instead of mutating the old one. Runs the
89
+ * same schema the live tool call validated against — legacy/corrupted
90
+ * persisted args fail closed (`undefined`) rather than feeding malformed
91
+ * data back into the picker.
92
+ */
93
+ export function recoverAskQuestions(toolCallArguments: unknown): AskToolInput["questions"] | undefined {
94
+ const parsed = askSchema(toolCallArguments);
95
+ if (parsed instanceof arkType.errors) return undefined;
96
+ return parsed.questions;
97
+ }
98
+
84
99
  /** Result for a single question */
85
100
  export interface QuestionResult {
86
101
  id: string;
package/src/tools/bash.ts CHANGED
@@ -312,10 +312,17 @@ function extractPartialBashEnv(partialJson: string | undefined): Record<string,
312
312
  return Object.keys(env).length > 0 ? env : undefined;
313
313
  }
314
314
 
315
- function formatTimeoutClampNotice(requestedTimeoutSec: number, effectiveTimeoutSec: number): string | undefined {
316
- return requestedTimeoutSec !== effectiveTimeoutSec
317
- ? `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s).`
318
- : undefined;
315
+ function formatTimeoutClampNotice(
316
+ requestedTimeoutSec: number,
317
+ effectiveTimeoutSec: number,
318
+ maxTimeout: number,
319
+ ): string | undefined {
320
+ if (requestedTimeoutSec === effectiveTimeoutSec) return undefined;
321
+ const cappedByGlobal = maxTimeout > 0 && effectiveTimeoutSec === maxTimeout && maxTimeout < TOOL_TIMEOUTS.bash.max;
322
+ const limit = cappedByGlobal
323
+ ? `global tools.maxTimeout ceiling ${maxTimeout}s`
324
+ : `allowed range ${TOOL_TIMEOUTS.bash.min}-${TOOL_TIMEOUTS.bash.max}s`;
325
+ return `Timeout clamped to ${effectiveTimeoutSec}s (requested ${requestedTimeoutSec}s; ${limit}).`;
319
326
  }
320
327
 
321
328
  function formatWallTimeSeconds(wallTimeMs: number): string {
@@ -831,11 +838,12 @@ export class BashTool implements AgentTool<typeof bashSchemaBase | typeof bashSc
831
838
  // must still cancel the call or job, but OMP does not impose a deadline.
832
839
  const requestedTimeoutSec = rawTimeout;
833
840
  const timeoutDisabled = requestedTimeoutSec === 0;
834
- const timeoutSec = timeoutDisabled ? undefined : clampTimeout("bash", requestedTimeoutSec);
841
+ const maxTimeout = this.session.settings.get("tools.maxTimeout");
842
+ const timeoutSec = timeoutDisabled ? undefined : clampTimeout("bash", requestedTimeoutSec, maxTimeout);
835
843
  const timeoutMs = timeoutSec === undefined ? undefined : timeoutSec * 1000;
836
844
  const pendingNotices: string[] = [];
837
845
  if (timeoutSec !== undefined) {
838
- const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec);
846
+ const timeoutClampNotice = formatTimeoutClampNotice(requestedTimeoutSec, timeoutSec, maxTimeout);
839
847
  if (timeoutClampNotice) pendingNotices.push(timeoutClampNotice);
840
848
  }
841
849
 
@@ -190,7 +190,7 @@ export class BrowserTool implements AgentTool<typeof browserSchema, BrowserToolD
190
190
  ): Promise<AgentToolResult<BrowserToolDetails>> {
191
191
  try {
192
192
  throwIfAborted(signal);
193
- const timeoutSeconds = clampTimeout("browser", params.timeout);
193
+ const timeoutSeconds = clampTimeout("browser", params.timeout, this.session.settings.get("tools.maxTimeout"));
194
194
  const timeoutMs = timeoutSeconds * 1000;
195
195
  const name = params.name ?? DEFAULT_TAB_NAME;
196
196
  const details: BrowserToolDetails = { action: params.action, name };
@@ -729,7 +729,7 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
729
729
  _onUpdate?: AgentToolUpdateCallback<DebugToolDetails>,
730
730
  _context?: AgentToolContext,
731
731
  ): Promise<AgentToolResult<DebugToolDetails>> {
732
- const timeoutSec = clampTimeout("debug", params.timeout);
732
+ const timeoutSec = clampTimeout("debug", params.timeout, this.session.settings.get("tools.maxTimeout"));
733
733
  const timeoutSignal = AbortSignal.timeout(timeoutSec * 1000);
734
734
  const combinedSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
735
735
  const details: DebugToolDetails = { action: params.action, success: true };
package/src/tools/eval.ts CHANGED
@@ -217,10 +217,6 @@ function detailsNotice(cells: ResolvedEvalCell[]): string | undefined {
217
217
  return notices.length > 0 ? notices.join(" ") : undefined;
218
218
  }
219
219
 
220
- function timeoutSecondsFromMs(timeoutMs: number): number {
221
- return clampTimeout("eval", timeoutMs / 1000);
222
- }
223
-
224
220
  async function resolveBackend(session: ToolSession, language: EvalLanguage): Promise<ResolvedBackend> {
225
221
  const backends = resolveEvalBackends(session);
226
222
  const allowPy = backends.python;
@@ -538,7 +534,10 @@ export class EvalTool implements AgentTool<typeof evalSchema> {
538
534
  // ordinary tool calls all count against the budget. The watchdog drives
539
535
  // `combinedSignal`; we pass no wall-clock deadline downstream so the
540
536
  // backends never arm a competing fixed timer.
541
- const idleTimeoutMs = cell.timeoutMs === 0 ? undefined : timeoutSecondsFromMs(cell.timeoutMs) * 1000;
537
+ const idleTimeoutMs =
538
+ cell.timeoutMs === 0
539
+ ? undefined
540
+ : clampTimeout("eval", cell.timeoutMs / 1000, session.settings.get("tools.maxTimeout")) * 1000;
542
541
  const idle = idleTimeoutMs === undefined ? undefined : new IdleTimeout(idleTimeoutMs);
543
542
  const combinedSignal =
544
543
  signal && idle
@@ -1622,7 +1622,7 @@ export async function fetchReadUrl(
1622
1622
  ): Promise<ReadUrlEntry> {
1623
1623
  const { path: url, raw = false } = params;
1624
1624
 
1625
- const effectiveTimeout = clampTimeout("fetch", 30);
1625
+ const effectiveTimeout = clampTimeout("fetch", 30, session.settings.get("tools.maxTimeout"));
1626
1626
 
1627
1627
  if (signal?.aborted) {
1628
1628
  throw new ToolAbortError();
@@ -74,6 +74,9 @@ const KEY_INPUT: Record<string, string> = {
74
74
  LEFT: "\u001b[D",
75
75
  };
76
76
 
77
+ /** Terminal daemon lifecycle states — the process is no longer running. */
78
+ const TERMINAL_STATES: Partial<Record<DaemonState, true>> = { exited: true, failed: true };
79
+
77
80
  /** Structured launch state retained for compact TUI rendering. */
78
81
  export interface LaunchToolDetails {
79
82
  op: LaunchParams["op"];
@@ -229,6 +232,8 @@ function toolContent(result: DaemonRpcResult, params: LaunchParams): string {
229
232
  lines.push(
230
233
  `NOT ready — readiness timed out after ${params.ready?.timeout ?? 30}s${cause}. The process is still running (state: ${daemon.state}); follow its logs or stop it.`,
231
234
  );
235
+ } else if (params.ready && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
236
+ lines.push("Process exited before readiness was observed.");
232
237
  }
233
238
  return lines.join("\n");
234
239
  }
@@ -437,6 +442,8 @@ export function launchRenderResult(
437
442
  : "Readiness timed out; the process is still running.",
438
443
  ),
439
444
  );
445
+ } else if (params.ready && daemon && daemon.readyAt === undefined && TERMINAL_STATES[daemon.state]) {
446
+ body.push(theme.fg("warning", "Process exited before readiness was observed."));
440
447
  }
441
448
  break;
442
449
  }
@@ -21,10 +21,17 @@ export type ToolWithTimeout = keyof typeof TOOL_TIMEOUTS;
21
21
 
22
22
  /**
23
23
  * Clamp a raw timeout to the allowed range for a tool.
24
- * If rawTimeout is undefined, returns the tool's default.
24
+ *
25
+ * When `rawTimeout` is undefined the tool's `default` is used. A positive
26
+ * `maxTimeout` (the `tools.maxTimeout` global ceiling) caps the *resolved*
27
+ * value — including the default-fallback path — before the per-tool `min`/`max`
28
+ * floor and ceiling apply, so a configured global cap governs calls where the
29
+ * agent omits `timeout`, not only explicitly-passed values. `maxTimeout <= 0`
30
+ * means no global cap.
25
31
  */
26
- export function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number): number {
32
+ export function clampTimeout(tool: ToolWithTimeout, rawTimeout?: number, maxTimeout?: number): number {
27
33
  const config = TOOL_TIMEOUTS[tool];
28
34
  const timeout = rawTimeout ?? config.default;
29
- return Math.max(config.min, Math.min(config.max, timeout));
35
+ const capped = maxTimeout !== undefined && maxTimeout > 0 ? Math.min(timeout, maxTimeout) : timeout;
36
+ return Math.max(config.min, Math.min(config.max, capped));
30
37
  }
@@ -85,6 +85,36 @@ import { renderXdevCall, renderXdevResult, type XdevDispatch } from "./xdev";
85
85
 
86
86
  const LOOSE_HASHLINE_HEADER_RE = /^\s*\[[^#\r\n]+#[^ \t\r\n]*\]\s*$/;
87
87
  const EXECUTABLE_NOTICE = "[Notice: Made executable via chmod +x]";
88
+ const URI_LIKE_WRITE_PATH_RE = /^([a-z][a-z0-9+.-]*):\/{1,2}(.*)$/i;
89
+ const XD_MISSING_DELIMITER_RE = /^xd\/+(.*)$/i;
90
+ const XD_SCHEME_NEAR_MISSES: Record<string, true> = { dx: true, xdd: true, xdt: true };
91
+
92
+ function assertWriteTargetAddressable(target: string, router: InternalUrlRouter): void {
93
+ const trimmed = target.trim();
94
+ if (path.win32.isAbsolute(trimmed) || router.canHandle(trimmed)) return;
95
+
96
+ const missingDelimiter = trimmed.match(XD_MISSING_DELIMITER_RE);
97
+ if (missingDelimiter) {
98
+ throw new ToolError(
99
+ `Unknown URI-like write target '${trimmed}'. Did you mean 'xd://${missingDelimiter[1]}'? Prefix the path with './' to write it as a filesystem path.`,
100
+ );
101
+ }
102
+
103
+ const uriLike = trimmed.match(URI_LIKE_WRITE_PATH_RE);
104
+ if (!uriLike) return;
105
+
106
+ const scheme = uriLike[1]!.toLowerCase();
107
+ // conflict:// has no router handler but is spliced downstream by
108
+ // parseConflictUri (which emits its own precise id/scope errors); let it pass.
109
+ if (scheme === "conflict") return;
110
+ const canonicalScheme = router.getHandler(scheme) ? scheme : XD_SCHEME_NEAR_MISSES[scheme] ? "xd" : undefined;
111
+ const suggestion = canonicalScheme
112
+ ? ` Did you mean '${canonicalScheme}://${uriLike[2]}'?`
113
+ : " Tool devices use 'xd://<tool>'.";
114
+ throw new ToolError(
115
+ `Unknown URI-like write target '${trimmed}'.${suggestion} Prefix the path with './' to write it as a filesystem path.`,
116
+ );
117
+ }
88
118
 
89
119
  const BULK_DIRECTIVE_RE = /^#?(\d+)\s*[:=]\s*(@ours|@theirs|@base|@both)$/;
90
120
  /**
@@ -987,6 +1017,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
987
1017
  // Strip hashline display prefixes ([PATH#HASH] + LINE:) if the model copied them from read output
988
1018
  const { text: cleanContent, stripped } = stripWriteContent(this.session, content);
989
1019
  const internalRouter = InternalUrlRouter.instance();
1020
+ assertWriteTargetAddressable(path, internalRouter);
990
1021
  if (internalRouter.canHandle(path)) {
991
1022
  const parsed = parseInternalUrl(path);
992
1023
  const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
@@ -1,4 +1,5 @@
1
1
  import { getLastChangelogVersionPath, isEnoent, logger } from "@oh-my-pi/pi-utils";
2
+ import bundledChangelog from "../../CHANGELOG.md" with { type: "text" };
2
3
 
3
4
  export interface ChangelogEntry {
4
5
  major: number;
@@ -29,73 +30,68 @@ export interface StartupChangelogSelection {
29
30
  }
30
31
 
31
32
  /**
32
- * Parse changelog entries from the file at `changelogPath`. Scans for `## [x.y.z]`
33
- * headings and collects each block until the next heading or EOF.
33
+ * Parse changelog entries from omp's package asset when available, falling back
34
+ * to the copy embedded in compiled binaries.
34
35
  *
35
- * Returns `[]` when `changelogPath` is `undefined` (package directory not
36
- * resolvable see `getChangelogPath`) or the file is missing. Callers MUST NOT
37
- * synthesize a fallback path from the host project's cwd; doing so caused issue
38
- * #1423 (the host project's `CHANGELOG.md` was rendered as omp's).
36
+ * The embedded fallback keeps standalone binaries self-contained without
37
+ * resolving relative to the host project's cwd, which caused issue #1423.
39
38
  */
40
39
  export async function parseChangelog(changelogPath: string | undefined): Promise<ChangelogEntry[]> {
41
- if (!changelogPath) {
42
- return [];
43
- }
44
- try {
45
- const content = await Bun.file(changelogPath).text();
46
- const lines = content.split("\n");
47
- const entries: ChangelogEntry[] = [];
48
-
49
- let currentLines: string[] = [];
50
- let currentVersion: { major: number; minor: number; patch: number } | null = null;
51
-
52
- for (const line of lines) {
53
- // Check if this is a version header (## [x.y.z] ...)
54
- if (line.startsWith("## ")) {
55
- // Save previous entry if exists
56
- if (currentVersion && currentLines.length > 0) {
57
- entries.push({
58
- ...currentVersion,
59
- content: currentLines.join("\n").trim(),
60
- });
61
- }
62
-
63
- // Try to parse version from this line
64
- const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
65
- if (versionMatch) {
66
- currentVersion = {
67
- major: Number.parseInt(versionMatch[1], 10),
68
- minor: Number.parseInt(versionMatch[2], 10),
69
- patch: Number.parseInt(versionMatch[3], 10),
70
- };
71
- currentLines = [line];
72
- } else {
73
- // Reset if we can't parse version
74
- currentVersion = null;
75
- currentLines = [];
76
- }
77
- } else if (currentVersion) {
78
- // Collect lines for current version
79
- currentLines.push(line);
40
+ let content = bundledChangelog;
41
+ if (changelogPath) {
42
+ try {
43
+ content = await Bun.file(changelogPath).text();
44
+ } catch (error) {
45
+ if (!isEnoent(error)) {
46
+ logger.error(`Warning: Could not parse changelog: ${error}`);
80
47
  }
81
48
  }
49
+ }
82
50
 
83
- // Save last entry
84
- if (currentVersion && currentLines.length > 0) {
85
- entries.push({
86
- ...currentVersion,
87
- content: currentLines.join("\n").trim(),
88
- });
89
- }
51
+ return parseChangelogContent(content);
52
+ }
90
53
 
91
- return entries;
92
- } catch (error) {
93
- if (isEnoent(error)) {
94
- return [];
54
+ function parseChangelogContent(content: string): ChangelogEntry[] {
55
+ const lines = content.split("\n");
56
+ const entries: ChangelogEntry[] = [];
57
+
58
+ let currentLines: string[] = [];
59
+ let currentVersion: { major: number; minor: number; patch: number } | null = null;
60
+
61
+ for (const line of lines) {
62
+ if (line.startsWith("## ")) {
63
+ if (currentVersion && currentLines.length > 0) {
64
+ entries.push({
65
+ ...currentVersion,
66
+ content: currentLines.join("\n").trim(),
67
+ });
68
+ }
69
+
70
+ const versionMatch = line.match(/##\s+\[?(\d+)\.(\d+)\.(\d+)\]?/);
71
+ if (versionMatch) {
72
+ currentVersion = {
73
+ major: Number.parseInt(versionMatch[1], 10),
74
+ minor: Number.parseInt(versionMatch[2], 10),
75
+ patch: Number.parseInt(versionMatch[3], 10),
76
+ };
77
+ currentLines = [line];
78
+ } else {
79
+ currentVersion = null;
80
+ currentLines = [];
81
+ }
82
+ } else if (currentVersion) {
83
+ currentLines.push(line);
95
84
  }
96
- logger.error(`Warning: Could not parse changelog: ${error}`);
97
- return [];
98
85
  }
86
+
87
+ if (currentVersion && currentLines.length > 0) {
88
+ entries.push({
89
+ ...currentVersion,
90
+ content: currentLines.join("\n").trim(),
91
+ });
92
+ }
93
+
94
+ return entries;
99
95
  }
100
96
 
101
97
  /**