@oh-my-pi/pi-coding-agent 15.1.6 → 15.1.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 (55) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/types/config/settings-schema.d.ts +15 -7
  3. package/dist/types/edit/streaming.d.ts +7 -0
  4. package/dist/types/hashline/hash.d.ts +4 -4
  5. package/dist/types/hashline/recovery.d.ts +5 -0
  6. package/dist/types/lsp/edits.d.ts +8 -1
  7. package/dist/types/session/agent-session.d.ts +16 -0
  8. package/dist/types/session/client-bridge.d.ts +1 -0
  9. package/dist/types/tools/find.d.ts +4 -0
  10. package/dist/types/tools/resolve.d.ts +5 -0
  11. package/dist/types/tools/tool-timeouts.d.ts +1 -1
  12. package/package.json +7 -7
  13. package/src/config/settings-schema.ts +22 -7
  14. package/src/dap/session.ts +58 -5
  15. package/src/edit/modes/patch.ts +46 -0
  16. package/src/edit/streaming.ts +145 -4
  17. package/src/eval/js/context-manager.ts +11 -7
  18. package/src/eval/js/shared/rewrite-imports.ts +21 -9
  19. package/src/eval/js/shared/runtime.ts +2 -1
  20. package/src/hashline/hash.ts +11 -8
  21. package/src/hashline/parser.ts +23 -6
  22. package/src/hashline/recovery.ts +44 -3
  23. package/src/lsp/edits.ts +92 -38
  24. package/src/lsp/index.ts +110 -7
  25. package/src/lsp/utils.ts +13 -0
  26. package/src/modes/acp/acp-client-bridge.ts +1 -0
  27. package/src/modes/components/status-line/segments.ts +1 -1
  28. package/src/modes/components/tool-execution.ts +46 -1
  29. package/src/modes/interactive-mode.ts +33 -7
  30. package/src/prompts/tools/bash.md +14 -0
  31. package/src/prompts/tools/debug.md +4 -1
  32. package/src/prompts/tools/find.md +10 -0
  33. package/src/prompts/tools/hashline.md +5 -3
  34. package/src/prompts/tools/resolve.md +1 -1
  35. package/src/prompts/tools/search.md +2 -1
  36. package/src/prompts/tools/task.md +4 -0
  37. package/src/prompts/tools/todo-write.md +2 -0
  38. package/src/session/agent-session.ts +116 -8
  39. package/src/session/client-bridge.ts +1 -0
  40. package/src/slash-commands/builtin-registry.ts +1 -1
  41. package/src/task/index.ts +33 -5
  42. package/src/task/render.ts +4 -1
  43. package/src/tools/browser/tab-supervisor.ts +23 -3
  44. package/src/tools/browser/tab-worker.ts +4 -2
  45. package/src/tools/browser.ts +1 -1
  46. package/src/tools/debug.ts +19 -2
  47. package/src/tools/find.ts +80 -24
  48. package/src/tools/read.ts +3 -6
  49. package/src/tools/resolve.ts +54 -22
  50. package/src/tools/search.ts +31 -0
  51. package/src/tools/todo-write.ts +11 -4
  52. package/src/tools/tool-timeouts.ts +1 -1
  53. package/src/utils/tools-manager.ts +29 -22
  54. package/src/web/search/providers/codex.ts +3 -0
  55. package/src/web/search/providers/perplexity.ts +24 -1
@@ -51,6 +51,49 @@ function cloneToolArgs<T>(args: T): T {
51
51
  }
52
52
  }
53
53
 
54
+ /**
55
+ * Drop trailing removal/hunk-header lines that appear in a streaming diff
56
+ * before the matching `+added` lines have arrived. Without this, a partial
57
+ * apply_patch / hashline preview shows `-old` first and then visibly grows
58
+ * the `+new` block beneath it — the "removals first, additions catching up"
59
+ * jitter. Once the next streaming tick brings the additions in, the trailing
60
+ * block reappears alongside them.
61
+ */
62
+ function stripTrailingUnbalancedRemoval(diff: string | undefined): string | undefined {
63
+ if (!diff) return diff;
64
+ const lines = diff.split("\n");
65
+ let lastAddIdx = -1;
66
+ for (let i = lines.length - 1; i >= 0; i--) {
67
+ if (lines[i].startsWith("+")) {
68
+ lastAddIdx = i;
69
+ break;
70
+ }
71
+ }
72
+ let hasTrailingUnbalanced = false;
73
+ for (let i = lastAddIdx + 1; i < lines.length; i++) {
74
+ const line = lines[i];
75
+ if (line.startsWith("-") || line.startsWith("@@")) {
76
+ hasTrailingUnbalanced = true;
77
+ break;
78
+ }
79
+ }
80
+ if (!hasTrailingUnbalanced) return diff;
81
+ if (lastAddIdx === -1) return "";
82
+ return lines.slice(0, lastAddIdx + 1).join("\n");
83
+ }
84
+
85
+ function stabilizeStreamingPreviews(previews: PerFileDiffPreview[]): PerFileDiffPreview[] {
86
+ let changed = false;
87
+ const next = previews.map(preview => {
88
+ if (!preview.diff) return preview;
89
+ const trimmed = stripTrailingUnbalancedRemoval(preview.diff);
90
+ if (trimmed === preview.diff) return preview;
91
+ changed = true;
92
+ return { ...preview, diff: trimmed ?? "" };
93
+ });
94
+ return changed ? next : previews;
95
+ }
96
+
54
97
  function isEditLikeToolName(toolName: string): boolean {
55
98
  return toolName === "edit" || toolName === "apply_patch";
56
99
  }
@@ -222,16 +265,18 @@ export class ToolExecutionComponent extends Container {
222
265
  this.#editDiffAbort = controller;
223
266
 
224
267
  try {
268
+ const isStreaming = !this.#argsComplete;
225
269
  const previews = await strategy.computeDiffPreview(effectiveArgs, {
226
270
  cwd: this.#cwd,
227
271
  signal: controller.signal,
228
272
  fuzzyThreshold: this.#editFuzzyThreshold,
229
273
  allowFuzzy: this.#editAllowFuzzy,
230
274
  hashlineAutoDropPureInsertDuplicates: this.#hashlineAutoDropPureInsertDuplicates,
275
+ isStreaming,
231
276
  });
232
277
  if (controller.signal.aborted) return;
233
278
  if (previews) {
234
- this.#editDiffPreview = previews;
279
+ this.#editDiffPreview = isStreaming ? stabilizeStreamingPreviews(previews) : previews;
235
280
  this.#updateDisplay();
236
281
  this.#ui.requestRender();
237
282
  }
@@ -584,11 +584,17 @@ export class InteractiveMode implements InteractiveModeContext {
584
584
  if (!this.loopModeEnabled || !this.loopPrompt) return;
585
585
  const prompt = this.loopPrompt;
586
586
  const loopAction = settings.get("loop.mode");
587
+ this.#deferLoopAutoSubmit(() => {
588
+ void this.#runLoopIteration(loopAction, prompt);
589
+ });
590
+ }
591
+
592
+ #deferLoopAutoSubmit(callback: () => void): void {
587
593
  // Brief delay so the user has a chance to press Esc between iterations.
588
594
  this.#loopAutoSubmitTimer = setTimeout(() => {
589
595
  this.#loopAutoSubmitTimer = undefined;
590
596
  if (!this.loopModeEnabled || !this.onInputCallback) return;
591
- void this.#runLoopIteration(loopAction, prompt);
597
+ callback();
592
598
  }, 800);
593
599
  }
594
600
 
@@ -641,7 +647,32 @@ export class InteractiveMode implements InteractiveModeContext {
641
647
  }
642
648
  }
643
649
 
650
+ #isLoopAutoSubmitBlocked(): boolean {
651
+ return this.session.isStreaming || this.session.isCompacting;
652
+ }
653
+
654
+ #submitLoopPromptWhenReady(prompt: string): void {
655
+ if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
656
+ if (isLoopDurationExpired(this.loopLimit)) {
657
+ this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
658
+ return;
659
+ }
660
+ if (this.#isLoopAutoSubmitBlocked()) {
661
+ this.#deferLoopAutoSubmit(() => this.#submitLoopPromptWhenReady(prompt));
662
+ return;
663
+ }
664
+ this.onInputCallback(this.startPendingSubmission({ text: prompt }));
665
+ }
666
+
644
667
  async #runLoopIteration(action: "prompt" | "compact" | "reset", prompt: string): Promise<void> {
668
+ if (!this.loopModeEnabled || this.loopPrompt !== prompt || !this.onInputCallback) return;
669
+ if (this.#isLoopAutoSubmitBlocked()) {
670
+ this.#deferLoopAutoSubmit(() => {
671
+ void this.#runLoopIteration(action, prompt);
672
+ });
673
+ return;
674
+ }
675
+
645
676
  if (!consumeLoopLimitIteration(this.loopLimit)) {
646
677
  this.disableLoopMode("Loop limit reached. Loop mode disabled.");
647
678
  return;
@@ -652,12 +683,7 @@ export class InteractiveMode implements InteractiveModeContext {
652
683
  } else if (action === "reset") {
653
684
  await this.handleClearCommand();
654
685
  }
655
- if (!this.loopModeEnabled || !this.onInputCallback) return;
656
- if (isLoopDurationExpired(this.loopLimit)) {
657
- this.disableLoopMode("Loop time limit reached. Loop mode disabled.");
658
- return;
659
- }
660
- this.onInputCallback(this.startPendingSubmission({ text: prompt }));
686
+ this.#submitLoopPromptWhenReady(prompt);
661
687
  }
662
688
 
663
689
  disableLoopMode(message = "Loop mode disabled."): void {
@@ -23,3 +23,17 @@ Executes bash command in shell session for terminal operations like git, bun, ca
23
23
  - Truncated output is retrievable from `artifact://<id>` (linked in metadata)
24
24
  - Exit codes shown on non-zero exit
25
25
  </output>
26
+
27
+ {{#if asyncEnabled}}
28
+ # Timeout and async
29
+
30
+ - `timeout` (seconds) caps the **wall-clock duration** of the command. When it elapses the process is killed and the call returns with a timeout annotation. Range: `1`–`3600`s; default `300`s (see `clampTimeout("bash", …)` in `tool-timeouts.ts`).
31
+ - `async: true` only defers **reporting** of the result — it does NOT disable, extend, or detach the timeout. A daemon started with `async: true` is still killed when `timeout` elapses, regardless of how long the agent waits before reading the result.
32
+ - For long-running daemons (dev servers, watchers): either pass an explicit large `timeout` (up to `3600`), or fully detach the process from this shell using `nohup … &` / `setsid … &` / `disown` so it survives independent of the bash call's lifecycle.
33
+ {{/if}}
34
+
35
+ # Output minimizer
36
+
37
+ - Bash stdout/stderr may be rewritten before you see it: long output is head/tail truncated, and test/lint runners (e.g. `bun test`, `cargo test`, ESLint) are passed through heuristic filters that drop noise and keep failures.
38
+ - When the minimizer changes the visible text, the tool appends a `[raw output: artifact://<id>]` footer pointing at the **full untouched capture**. If a run looks suspicious (e.g. only a version banner) or you need the exact bytes, read that artifact.
39
+ - If no footer is present, what you see is what the command actually emitted.
@@ -4,6 +4,7 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through
4
4
  <instruction>
5
5
  - Prefer over bash for program state, breakpoints, stepping, thread inspection, or interrupting a running process.
6
6
  - `action: "launch"` starts a session; `program` is required, `adapter` optional (auto-selected from target path and workspace).
7
+ For Python, set `adapter: "debugpy"` and `program` to the target `.py` file; put interpreter/script flags in `args`.
7
8
  - `action: "attach"` connects to an existing process: `pid` for local attach, `port` for remote attach (where the adapter supports it), `adapter` to force a specific debugger.
8
9
  - **Breakpoints**: `set_breakpoint`/`remove_breakpoint` with source (`file`+`line`) or function (`function`); optional `condition` for conditional breakpoints.
9
10
  - **Flow control**: `continue` (resumes; briefly waits to observe whether the program stops or keeps running), `step_over`/`step_in`/`step_out` (single-step), `pause` (interrupt a running program so you can inspect state).
@@ -15,6 +16,7 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through
15
16
  - Only one active debug session is supported at a time.
16
17
  - Some adapters require a launched session to receive `configurationDone` before the target actually runs; if the tool says configuration is pending, set breakpoints and then call `continue`.
17
18
  - Adapter availability depends on local binaries. Common built-ins: `gdb`, `lldb-dap`, `python -m debugpy.adapter`, `dlv dap`.
19
+ - `program` must be an executable file or debug target, not a directory or interpreter name that resolves to a workspace directory.
18
20
  </caution>
19
21
 
20
22
  <examples>
@@ -24,7 +26,8 @@ Use for launching or attaching debuggers, setting breakpoints, stepping through
24
26
  3. `debug(action: "continue")`
25
27
  4. If the program appears hung: `debug(action: "pause")`
26
28
  5. Inspect state with `threads`, `stack_trace`, `scopes`, and `variables`
27
-
29
+ # Launch a Python script with debugpy
30
+ `debug(action: "launch", adapter: "debugpy", program: "scripts/job.py", args: ["--flag"])`
28
31
  # Raw debugger command through repl
29
32
  `debug(action: "evaluate", expression: "info registers", context: "repl")`
30
33
  </examples>
@@ -2,6 +2,10 @@ Finds files using fast pattern matching that works with any codebase size.
2
2
 
3
3
  <instruction>
4
4
  - `paths` is required and accepts an array of globs, files, or directories
5
+ - Pass multiple targets as **separate array elements** (`paths: ["a", "b"]`), NEVER as a single comma-joined string (`paths: ["a,b"]` is rejected)
6
+ - `gitignore` defaults to `true` and hides files matched by `.gitignore`. Set `gitignore: false` to find `.env*`, `*.log`, freshly-created build outputs, or anything else your repo ignores
7
+ - `hidden` defaults to `true`; combine with `gitignore: false` to surface dotfiles that are also gitignored
8
+ - `timeout` is in seconds (default 5, clamped to 0.5–60). On timeout, find returns whatever partial matches it has collected with `truncated: true` and a notice — increase `timeout` or narrow the pattern instead of retrying blindly
5
9
  - You SHOULD perform multiple searches in parallel when potentially useful
6
10
  </instruction>
7
11
 
@@ -12,6 +16,12 @@ Matching file paths sorted by modification time (most recent first). Truncated a
12
16
  <examples>
13
17
  # Find files
14
18
  `{"paths": ["src/**/*.ts"], "limit": 1000}`
19
+ # Multiple targets — separate array elements
20
+ `{"paths": ["src/**/*.ts", "test/**/*.ts"]}`
21
+ # Find gitignored files like .env
22
+ `{"paths": [".env*"], "gitignore": false}`
23
+ # Long-running search on a slow volume
24
+ `{"paths": ["/Volumes/Storage/**/*.py"], "timeout": 30}`
15
25
  </examples>
16
26
 
17
27
  <avoid>
@@ -19,8 +19,9 @@ Each op line is ONE of:
19
19
  Op lines carry no content — payload goes on the next line.
20
20
 
21
21
  WRONG: + 5pg| some code
22
+ WRONG: {{hsep}} some code
22
23
  RIGHT: + 5pg
23
- {{hsep}} some code
24
+ {{hsep}}some code
24
25
 
25
26
  A single `+`/`<`/`=` op accepts MANY `{{hsep}}` payload lines. To insert N consecutive lines, write ONE op followed by N payload lines — NEVER N ops with one payload each.
26
27
 
@@ -37,8 +38,9 @@ RIGHT (one op, many payload lines):
37
38
  </format-reminder>
38
39
 
39
40
  <rules>
40
- - Every payload line MUST start with `{{hsep}}`.
41
- - Payload is verbatim NEVER escape unicode.
41
+ - Every payload line MUST start with `{{hsep}}` immediately followed by payload text. Do NOT add a readability space after `{{hsep}}`.
42
+ - Every character after `{{hsep}}` is file content. If the target line intentionally starts with one space, write exactly one space after `{{hsep}}`; otherwise write none.
43
+ - Payload text is verbatim — NEVER escape unicode.
42
44
  - **Payload is only what's NEW relative to your range:**
43
45
  - `=` replaces inside; NEVER include lines outside.
44
46
  - `+`/`<` adds at the anchor; NEVER repeat line A or neighbors.
@@ -3,7 +3,7 @@ Resolves a pending action by either applying or discarding it.
3
3
  - `"apply"` persists / submits the pending action.
4
4
  - `"discard"` rejects the pending action.
5
5
  - `reason` is required: one short complete sentence explaining why, starting with a capital letter and ending with a period.
6
- - `extra` (optional) is free-form metadata passed to the resolving tool. Schema depends on context:
6
+ - `extra` (optional) is free-form metadata passed to the resolving tool. When the pending action is a plan-approval gate, supply `extra.title` (kebab/PascalCase slug for the approved plan filename). For preview-style pending actions (e.g. `ast_edit`), `extra` is unused.
7
7
 
8
8
  Valid whenever a pending action exists — either a preview-style staging (e.g. `ast_edit`) or a long-lived approval gate.
9
9
  Call fails with an error when no pending action exists.
@@ -1,8 +1,9 @@
1
1
  Searches files using powerful regex matching.
2
2
 
3
3
  <instruction>
4
- - Supports full regex syntax (e.g., `log.*Error`, `function\\s+\\w+`); literal braces need escaping (`interface\\{\\}` for `interface{}` in Go)
4
+ - Supports Rust regex syntax (RE2-style no lookaround or backreferences). Use line anchors or post-filters instead of (?!…)/(?<!…)
5
5
  - `paths` is required and accepts an array of files, directories, globs, or internal URLs
6
+ - `paths` is an array; do not embed commas or spaces inside a single entry. Pass `["src", "tests"]` not `["src,tests"]`.
6
7
  - Cross-line patterns are detected from literal `\n` or escaped `\\n` in `pattern`
7
8
  </instruction>
8
9
 
@@ -70,8 +70,12 @@ Parallel when tasks touch disjoint files or are independent refactors/tests.
70
70
  </assignment-fmt>
71
71
 
72
72
  <agents>
73
+ {{#if spawningDisabled}}
74
+ Agent spawning is disabled for this context.
75
+ {{else}}
73
76
  {{#list agents join="\n"}}
74
77
  # {{name}}
75
78
  {{description}}
76
79
  {{/list}}
80
+ {{/if}}
77
81
  </agents>
@@ -1,3 +1,5 @@
1
+ **Tasks are referenced by their verbatim content string, not by any auto-generated ID. There is no "task-1"/"task-N" identifier — the tool never emits one. Pass the task's content text in the `task` field.**
2
+
1
3
  Manages a phased task list. Pass `ops`: a flat array of operations.
2
4
  The next pending task is auto-promoted to `in_progress` after each completion.
3
5
  Allowed `op` values are only `init`, `start`, `done`, `drop`, `rm`, `append`, and `note`. `pending` is a task status, not an `op`; leave not-yet-started tasks implicit in `init`/`append` lists.
@@ -65,11 +65,13 @@ import type {
65
65
  } from "@oh-my-pi/pi-ai";
66
66
  import {
67
67
  calculateRateLimitBackoffMs,
68
+ clearAnthropicFastModeFallback,
68
69
  getSupportedEfforts,
69
70
  isContextOverflow,
70
71
  isUsageLimitError,
71
72
  modelsAreEqual,
72
73
  parseRateLimitReason,
74
+ resolveServiceTier,
73
75
  streamSimple,
74
76
  } from "@oh-my-pi/pi-ai";
75
77
  import { MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
@@ -443,6 +445,44 @@ function todoClearKey(phaseName: string, taskContent: string): string {
443
445
  return `${phaseName}\u0000${taskContent}`;
444
446
  }
445
447
 
448
+ const IRC_REPLY_MAX_BYTES = 4096;
449
+
450
+ /**
451
+ * Collapse degenerate IRC ephemeral replies before they hit the relay.
452
+ * Models occasionally loop on a single line (~16 reports of N-times-repeated
453
+ * replies); compress runs longer than 3 down to one instance + `[…N×]`, then
454
+ * cap at 4 KiB so a runaway reply can't flood the channel.
455
+ */
456
+ function dedupeIrcReply(text: string): string {
457
+ if (!text) return text;
458
+ const lines = text.split("\n");
459
+ const out: string[] = [];
460
+ let i = 0;
461
+ while (i < lines.length) {
462
+ let j = i + 1;
463
+ while (j < lines.length && lines[j] === lines[i]) j++;
464
+ const runLen = j - i;
465
+ if (runLen > 3) {
466
+ out.push(lines[i], `[…${runLen}×]`);
467
+ } else {
468
+ for (let k = 0; k < runLen; k++) out.push(lines[i]);
469
+ }
470
+ i = j;
471
+ }
472
+ let result = out.join("\n");
473
+ if (Buffer.byteLength(result, "utf8") > IRC_REPLY_MAX_BYTES) {
474
+ // Trim by characters until we're under the byte budget — handles multi-byte
475
+ // glyphs at the boundary without splitting them.
476
+ const suffix = "\n[…truncated]";
477
+ const budget = IRC_REPLY_MAX_BYTES - Buffer.byteLength(suffix, "utf8");
478
+ while (Buffer.byteLength(result, "utf8") > budget) {
479
+ result = result.slice(0, -1);
480
+ }
481
+ result += suffix;
482
+ }
483
+ return result;
484
+ }
485
+
446
486
  /**
447
487
  * Build the per-request `metadata` payload for the Anthropic provider, shaped
448
488
  * like real Claude Code's `getAPIMetadata` output (`{ session_id, account_uuid,
@@ -1578,6 +1618,16 @@ export class AgentSession {
1578
1618
  if (event.message.role === "assistant") {
1579
1619
  this.#lastAssistantMessage = event.message;
1580
1620
  const assistantMsg = event.message as AssistantMessage;
1621
+ const currentGrantsAnthropicPriority =
1622
+ this.serviceTier === "priority" || this.serviceTier === "claude-only";
1623
+ if (assistantMsg.disabledFeatures?.includes("priority") && currentGrantsAnthropicPriority) {
1624
+ this.setServiceTier(undefined);
1625
+ this.emitNotice(
1626
+ "warning",
1627
+ "Priority/fast mode rejected for this model; retried without it. Fast mode is now off.",
1628
+ "priority",
1629
+ );
1630
+ }
1581
1631
  // Resolve TTSR resume gate before checking for new deferred injections.
1582
1632
  // Gate on #ttsrAbortPending, not stopReason: a non-TTSR abort (e.g. streaming
1583
1633
  // edit) also produces stopReason === "aborted" but has no continuation coming.
@@ -1713,10 +1763,6 @@ export class AgentSession {
1713
1763
  }
1714
1764
  this.#resolveRetry();
1715
1765
 
1716
- if (msg.stopReason === "aborted" && this.#checkpointState) {
1717
- this.#checkpointState = undefined;
1718
- this.#pendingRewindReport = undefined;
1719
- }
1720
1766
  const compactionTask = this.#checkCompaction(msg);
1721
1767
  this.#trackPostPromptTask(compactionTask);
1722
1768
  await compactionTask;
@@ -3101,6 +3147,13 @@ export class AgentSession {
3101
3147
  if (!permissionIntent) {
3102
3148
  return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
3103
3149
  }
3150
+ const command =
3151
+ target.name === "bash" && args && typeof args === "object" && !Array.isArray(args)
3152
+ ? getStringProperty(args as Record<string, unknown>, "command")
3153
+ : undefined;
3154
+ const commandContent = command
3155
+ ? [{ type: "content" as const, content: { type: "text" as const, text: `$ ${command}` } }]
3156
+ : undefined;
3104
3157
  // Short-circuit on persisted decisions.
3105
3158
  const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
3106
3159
  if (persisted === "allow_always") {
@@ -3125,8 +3178,10 @@ export class AgentSession {
3125
3178
  toolCallId,
3126
3179
  toolName: target.name,
3127
3180
  title: permissionIntent.title,
3181
+ ...(target.name === "bash" ? { kind: "execute" } : {}),
3128
3182
  status: "pending",
3129
3183
  rawInput: args,
3184
+ ...(commandContent ? { content: commandContent } : {}),
3130
3185
  locations: extractPermissionLocations(
3131
3186
  args,
3132
3187
  this.sessionManager.getCwd(),
@@ -4599,7 +4654,12 @@ export class AgentSession {
4599
4654
 
4600
4655
  /** Schedule auto-removal of completed/abandoned tasks after a delay. */
4601
4656
  #scheduleTodoAutoClear(phases: TodoPhase[]): void {
4602
- const delaySec = this.settings.get("tasks.todoClearDelay") ?? 60;
4657
+ // Default bumped from 60s to 30 min: the prior 60s splice mutated canonical
4658
+ // state mid-turn, so the model observed phase totals shrinking ("6 → 5")
4659
+ // between tool calls. Surviving the turn matches user expectations; a
4660
+ // render-time filter in the UI consumer would be cleaner but lives in a
4661
+ // different package and is out of scope for this fix.
4662
+ const delaySec = this.settings.get("tasks.todoClearDelay") ?? 1800;
4603
4663
  if (delaySec < 0) return; // "Never" — no auto-clear
4604
4664
  const delayMs = delaySec * 1000;
4605
4665
  const doneKeys = new Set<string>();
@@ -5110,17 +5170,50 @@ export class AgentSession {
5110
5170
  return nextLevel;
5111
5171
  }
5112
5172
 
5173
+ /**
5174
+ * True when *any* fast-mode-granting service tier is configured, regardless
5175
+ * of whether the active model's provider actually realizes it. Used by the
5176
+ * toggle (`/fast on|off`) so re-toggling a scoped tier (`openai-only`,
5177
+ * `claude-only`) doesn't silently broaden it to unscoped `priority`.
5178
+ *
5179
+ * For "is fast mode actually applied to the next request?" use
5180
+ * {@link isFastModeActive} instead — that one respects the model's provider.
5181
+ */
5113
5182
  isFastModeEnabled(): boolean {
5114
- return this.serviceTier === "priority";
5183
+ return (
5184
+ this.serviceTier === "priority" || this.serviceTier === "claude-only" || this.serviceTier === "openai-only"
5185
+ );
5186
+ }
5187
+
5188
+ /**
5189
+ * True when the configured `serviceTier` resolves to `"priority"` for the
5190
+ * *currently selected model's provider*. Returns false for scoped tiers
5191
+ * that don't match (e.g. `"openai-only"` on an anthropic model) and when
5192
+ * no model is selected.
5193
+ */
5194
+ isFastModeActive(): boolean {
5195
+ return resolveServiceTier(this.serviceTier, this.model?.provider) === "priority";
5115
5196
  }
5116
5197
 
5117
5198
  setServiceTier(serviceTier: ServiceTier | undefined): void {
5118
5199
  if (this.serviceTier === serviceTier) return;
5200
+ // Re-arming priority on Anthropic? Clear the per-session auto-fallback
5201
+ // sticky disable so the next request actually carries `speed: "fast"`
5202
+ // again. Without this, `/fast on` (or user switching to a tier that
5203
+ // grants anthropic priority) after an auto-disable is a silent no-op
5204
+ // and the warning notice fires every turn.
5205
+ if (serviceTier === "priority" || serviceTier === "claude-only") {
5206
+ clearAnthropicFastModeFallback(this.#providerSessionState);
5207
+ }
5119
5208
  this.agent.serviceTier = serviceTier;
5120
5209
  this.sessionManager.appendServiceTierChange(serviceTier ?? null);
5121
5210
  }
5122
5211
 
5123
5212
  setFastMode(enabled: boolean): void {
5213
+ if (enabled && this.isFastModeEnabled()) {
5214
+ // Already on under any scope — keep the user's scoped value.
5215
+ return;
5216
+ }
5124
5217
  this.setServiceTier(enabled ? "priority" : undefined);
5125
5218
  }
5126
5219
 
@@ -7574,6 +7667,11 @@ export class AgentSession {
7574
7667
  const context: Context = {
7575
7668
  systemPrompt: this.systemPrompt,
7576
7669
  messages: llmMessages,
7670
+ // Empty tools array: with toolChoice="none" some encoders still serialize the
7671
+ // recipient's tool catalog and the model leaks raw call markup
7672
+ // (<function_calls>, DSML envelopes) into IRC replies. Stripping tools here
7673
+ // removes the surface entirely.
7674
+ tools: [],
7577
7675
  };
7578
7676
  const options = this.prepareSimpleStreamOptions(
7579
7677
  {
@@ -7609,7 +7707,7 @@ export class AgentSession {
7609
7707
  if (!assistantMessage) {
7610
7708
  throw new Error("Ephemeral turn ended without a final message");
7611
7709
  }
7612
- return { replyText: replyText.trim(), assistantMessage };
7710
+ return { replyText: dedupeIrcReply(replyText.trim()), assistantMessage };
7613
7711
  }
7614
7712
 
7615
7713
  /**
@@ -7622,14 +7720,24 @@ export class AgentSession {
7622
7720
  const messages = [...this.messages];
7623
7721
  const streaming = this.agent.state.streamMessage;
7624
7722
  if (streaming && streaming.role === "assistant") {
7723
+ const preservedBlocks: AssistantMessage["content"] = [];
7724
+ // Preserve thinking blocks: DeepSeek-class encoders replay them as
7725
+ // `reasoning_content` and reject the request (HTTP 400) when the field
7726
+ // goes missing on a turn that previously emitted thinking.
7727
+ for (const c of streaming.content) {
7728
+ if (c.type === "thinking") preservedBlocks.push(c);
7729
+ }
7625
7730
  const streamingText = streaming.content
7626
7731
  .filter((c): c is TextContent => c.type === "text")
7627
7732
  .map(c => c.text)
7628
7733
  .join("");
7629
7734
  if (streamingText) {
7735
+ preservedBlocks.push({ type: "text", text: streamingText });
7736
+ }
7737
+ if (preservedBlocks.length > 0) {
7630
7738
  const normalized: AssistantMessage = {
7631
7739
  ...streaming,
7632
- content: [{ type: "text", text: streamingText }],
7740
+ content: preservedBlocks,
7633
7741
  };
7634
7742
  const lastMessage = messages.at(-1);
7635
7743
  if (lastMessage?.role === "assistant") {
@@ -27,6 +27,7 @@ export interface ClientBridgePermissionToolCall {
27
27
  kind?: string;
28
28
  status?: "pending" | "in_progress" | "completed" | "failed";
29
29
  rawInput?: unknown;
30
+ content?: unknown[];
30
31
  locations?: { path: string; line?: number }[];
31
32
  }
32
33
 
@@ -147,7 +147,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
147
147
  },
148
148
  {
149
149
  name: "fast",
150
- description: "Toggle fast mode (OpenAI service tier priority)",
150
+ description: "Toggle priority service tier (OpenAI service_tier=priority, Anthropic speed=fast)",
151
151
  acpDescription: "Toggle fast mode",
152
152
  acpInputHint: "[on|off|status]",
153
153
  subcommands: [
package/src/task/index.ts CHANGED
@@ -140,11 +140,25 @@ function renderDescription(
140
140
  disabledAgents: string[],
141
141
  simpleMode: TaskSimpleMode,
142
142
  ircEnabled: boolean,
143
+ parentSpawns: string,
143
144
  ): string {
144
- const filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
145
+ const spawningDisabled = parentSpawns === "";
146
+ let filteredAgents = disabledAgents.length > 0 ? agents.filter(a => !disabledAgents.includes(a.name)) : agents;
147
+ if (spawningDisabled) {
148
+ filteredAgents = [];
149
+ } else if (parentSpawns !== "*") {
150
+ const allowed = new Set(
151
+ parentSpawns
152
+ .split(",")
153
+ .map(s => s.trim())
154
+ .filter(Boolean),
155
+ );
156
+ filteredAgents = filteredAgents.filter(a => allowed.has(a.name));
157
+ }
145
158
  const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(simpleMode);
146
159
  return prompt.render(taskDescriptionTemplate, {
147
160
  agents: filteredAgents,
161
+ spawningDisabled,
148
162
  MAX_CONCURRENCY: maxConcurrency,
149
163
  isolationEnabled,
150
164
  asyncEnabled,
@@ -230,6 +244,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
230
244
  disabledAgents,
231
245
  this.#getTaskSimpleMode(),
232
246
  this.session.settings.get("irc.enabled") === true,
247
+ this.session.getSessionSpawns() ?? "*",
233
248
  );
234
249
  }
235
250
  private constructor(
@@ -493,10 +508,11 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
493
508
  taskIdByItemId.set(taskItems[i].id, uniqueIds[i]);
494
509
  }
495
510
  const startedListing = startedJobs
496
- .map(({ taskId }) => {
511
+ .map(({ taskId, jobId }) => {
497
512
  const id = taskIdByItemId.get(taskId) ?? taskId;
498
513
  const desc = progressByTaskId.get(taskId)?.description;
499
- return desc ? `- \`${id}\` ${desc}` : `- \`${id}\``;
514
+ const prefix = `- \`${id}\` (job \`${jobId}\`)`;
515
+ return desc ? `${prefix} — ${desc}` : prefix;
500
516
  })
501
517
  .join("\n");
502
518
  const coordinationHint = ircEnabled
@@ -1075,6 +1091,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1075
1091
 
1076
1092
  let mergeSummary = "";
1077
1093
  let changesApplied: boolean | null = null;
1094
+ let hadAnyChanges = false;
1078
1095
  let mergedBranchesForNestedPatches: Set<string> | null = null;
1079
1096
  if (isIsolated && repoRoot) {
1080
1097
  try {
@@ -1086,13 +1103,18 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1086
1103
 
1087
1104
  if (branchEntries.length === 0) {
1088
1105
  changesApplied = true;
1106
+ hadAnyChanges = false;
1107
+ mergeSummary = "\n\nNo changes to apply.";
1089
1108
  } else {
1090
1109
  const mergeResult = await mergeTaskBranches(repoRoot, branchEntries);
1091
1110
  mergedBranchesForNestedPatches = new Set(mergeResult.merged);
1092
1111
  changesApplied = mergeResult.failed.length === 0;
1112
+ hadAnyChanges = changesApplied && mergeResult.merged.length > 0;
1093
1113
 
1094
1114
  if (changesApplied) {
1095
- mergeSummary = `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`;
1115
+ mergeSummary = hadAnyChanges
1116
+ ? `\n\nMerged ${mergeResult.merged.length} branch${mergeResult.merged.length === 1 ? "" : "es"}: ${mergeResult.merged.join(", ")}`
1117
+ : "\n\nNo changes to apply.";
1096
1118
  } else {
1097
1119
  const mergedPart =
1098
1120
  mergeResult.merged.length > 0 ? `Merged: ${mergeResult.merged.join(", ")}.\n` : "";
@@ -1113,6 +1135,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1113
1135
  const missingPatch = results.some(result => !result.patchPath);
1114
1136
  if (missingPatch) {
1115
1137
  changesApplied = false;
1138
+ hadAnyChanges = false;
1116
1139
  } else {
1117
1140
  const patchStats = await Promise.all(
1118
1141
  patchesInOrder.map(async patchPath => ({
@@ -1123,6 +1146,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1123
1146
  const nonEmptyPatches = patchStats.filter(patch => patch.size > 0).map(patch => patch.patchPath);
1124
1147
  if (nonEmptyPatches.length === 0) {
1125
1148
  changesApplied = true;
1149
+ hadAnyChanges = false;
1126
1150
  } else {
1127
1151
  const patchTexts = await Promise.all(
1128
1152
  nonEmptyPatches.map(async patchPath => Bun.file(patchPath).text()),
@@ -1132,13 +1156,16 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1132
1156
  .join("");
1133
1157
  if (!combinedPatch.trim()) {
1134
1158
  changesApplied = true;
1159
+ hadAnyChanges = false;
1135
1160
  } else {
1136
1161
  changesApplied = await git.patch.canApplyText(repoRoot, combinedPatch);
1137
1162
  if (changesApplied) {
1138
1163
  try {
1139
1164
  await git.patch.applyText(repoRoot, combinedPatch);
1165
+ hadAnyChanges = true;
1140
1166
  } catch {
1141
1167
  changesApplied = false;
1168
+ hadAnyChanges = false;
1142
1169
  }
1143
1170
  }
1144
1171
  }
@@ -1146,7 +1173,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1146
1173
  }
1147
1174
 
1148
1175
  if (changesApplied) {
1149
- mergeSummary = "\n\nApplied patches: yes";
1176
+ mergeSummary = hadAnyChanges ? "\n\nApplied patches: yes" : "\n\nNo changes to apply.";
1150
1177
  } else {
1151
1178
  const notification =
1152
1179
  "<system-notification>Patches were not applied and must be handled manually.</system-notification>";
@@ -1160,6 +1187,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1160
1187
  } catch (mergeErr) {
1161
1188
  const msg = mergeErr instanceof Error ? mergeErr.message : String(mergeErr);
1162
1189
  changesApplied = false;
1190
+ hadAnyChanges = false;
1163
1191
  mergeSummary = `\n\n<system-notification>Merge phase failed: ${msg}\nTask outputs are preserved but changes were not applied.</system-notification>`;
1164
1192
  }
1165
1193
  }
@@ -996,7 +996,10 @@ export function renderResult(
996
996
  if (fallbackText.trim()) {
997
997
  const summaryLines = fallbackText.split("\n");
998
998
  const markerIndex = summaryLines.findIndex(
999
- line => line.includes("<system-notification>") || line.startsWith("Applied patches:"),
999
+ line =>
1000
+ line.includes("<system-notification>") ||
1001
+ line.startsWith("Applied patches:") ||
1002
+ line.startsWith("No changes to apply."),
1000
1003
  );
1001
1004
  if (markerIndex >= 0) {
1002
1005
  const extra = summaryLines.slice(markerIndex);