@oh-my-pi/pi-coding-agent 15.1.5 → 15.1.7
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.
- package/CHANGELOG.md +24 -0
- package/dist/types/config/settings-schema.d.ts +15 -7
- package/dist/types/hashline/hash.d.ts +4 -4
- package/dist/types/hashline/recovery.d.ts +5 -0
- package/dist/types/lsp/edits.d.ts +8 -1
- package/dist/types/plan-mode/approved-plan.d.ts +18 -0
- package/dist/types/session/agent-session.d.ts +16 -0
- package/dist/types/session/client-bridge.d.ts +1 -0
- package/dist/types/tools/find.d.ts +4 -0
- package/dist/types/tools/path-utils.d.ts +22 -0
- package/dist/types/tools/resolve.d.ts +5 -0
- package/dist/types/tools/tool-timeouts.d.ts +1 -1
- package/package.json +7 -7
- package/src/config/settings-schema.ts +22 -7
- package/src/dap/session.ts +58 -5
- package/src/edit/modes/patch.ts +46 -0
- package/src/eval/js/context-manager.ts +11 -7
- package/src/eval/js/shared/rewrite-imports.ts +21 -9
- package/src/eval/js/shared/runtime.ts +2 -1
- package/src/hashline/apply.ts +25 -4
- package/src/hashline/hash.ts +11 -8
- package/src/hashline/parser.ts +23 -6
- package/src/hashline/recovery.ts +44 -3
- package/src/lsp/edits.ts +92 -38
- package/src/lsp/index.ts +110 -7
- package/src/lsp/utils.ts +13 -0
- package/src/modes/acp/acp-client-bridge.ts +1 -0
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/interactive-mode.ts +6 -8
- package/src/plan-mode/approved-plan.ts +52 -0
- package/src/prompts/tools/bash.md +14 -0
- package/src/prompts/tools/debug.md +4 -1
- package/src/prompts/tools/find.md +10 -0
- package/src/prompts/tools/hashline.md +5 -3
- package/src/prompts/tools/resolve.md +1 -1
- package/src/prompts/tools/search.md +2 -1
- package/src/prompts/tools/task.md +4 -0
- package/src/prompts/tools/todo-write.md +2 -0
- package/src/session/agent-session.ts +116 -8
- package/src/session/client-bridge.ts +1 -0
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/task/index.ts +33 -5
- package/src/task/render.ts +4 -1
- package/src/tools/browser/tab-supervisor.ts +23 -3
- package/src/tools/browser/tab-worker.ts +4 -2
- package/src/tools/browser.ts +1 -1
- package/src/tools/debug.ts +19 -2
- package/src/tools/find.ts +80 -24
- package/src/tools/path-utils.ts +59 -0
- package/src/tools/read.ts +15 -10
- package/src/tools/resolve.ts +54 -22
- package/src/tools/search.ts +31 -0
- package/src/tools/todo-write.ts +11 -4
- package/src/tools/tool-timeouts.ts +1 -1
- package/src/utils/tools-manager.ts +29 -22
- package/src/web/search/providers/codex.ts +3 -0
|
@@ -49,6 +49,58 @@ export function normalizePlanTitle(title: string): { title: string; fileName: st
|
|
|
49
49
|
return { title: sanitized, fileName };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** Best-effort derivation of a plan title from inputs the agent already produced.
|
|
53
|
+
* Returns the first non-empty candidate that survives `normalizePlanTitle`:
|
|
54
|
+
* 1. an explicit `suppliedTitle` (e.g. `extra.title` from the resolve call),
|
|
55
|
+
* 2. the first level-1 markdown heading inside `planContent`,
|
|
56
|
+
* 3. the filename stem of `planFilePath` (e.g. `PLAN` from `local://PLAN.md`),
|
|
57
|
+
* 4. the literal `"plan"` so callers never have to handle `null`.
|
|
58
|
+
* The fallback exists because some grammar-constrained models cannot emit a
|
|
59
|
+
* string into the open `extra` schema and instead drop in `{}` (issue #1179);
|
|
60
|
+
* plan-mode would otherwise loop forever on an unreachable validation. */
|
|
61
|
+
export function resolvePlanTitle(input: { suppliedTitle?: unknown; planContent: string; planFilePath: string }): {
|
|
62
|
+
title: string;
|
|
63
|
+
fileName: string;
|
|
64
|
+
source: "supplied" | "heading" | "filename" | "default";
|
|
65
|
+
} {
|
|
66
|
+
const candidates: Array<{ value: string; source: "supplied" | "heading" | "filename" | "default" }> = [];
|
|
67
|
+
if (typeof input.suppliedTitle === "string") {
|
|
68
|
+
const trimmed = input.suppliedTitle.trim();
|
|
69
|
+
if (trimmed) candidates.push({ value: trimmed, source: "supplied" });
|
|
70
|
+
}
|
|
71
|
+
const heading = firstLevelOneHeading(input.planContent);
|
|
72
|
+
if (heading) candidates.push({ value: heading, source: "heading" });
|
|
73
|
+
const stem = planFilenameStem(input.planFilePath);
|
|
74
|
+
if (stem) candidates.push({ value: stem, source: "filename" });
|
|
75
|
+
candidates.push({ value: "plan", source: "default" });
|
|
76
|
+
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
try {
|
|
79
|
+
const normalized = normalizePlanTitle(candidate.value);
|
|
80
|
+
return { ...normalized, source: candidate.source };
|
|
81
|
+
} catch {
|
|
82
|
+
// Fall through to the next candidate.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Last-ditch literal so the type-system contract holds even if `normalizePlanTitle("plan")` ever throws.
|
|
86
|
+
return { title: "plan", fileName: "plan.md", source: "default" };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** First `# Heading` text on its own line, trimmed. Returns the empty string if
|
|
90
|
+
* none is found so callers can chain it through truthiness checks. */
|
|
91
|
+
function firstLevelOneHeading(planContent: string): string {
|
|
92
|
+
const match = planContent.match(/^[ \t]*#[ \t]+(.+?)[ \t]*$/m);
|
|
93
|
+
return match?.[1]?.trim() ?? "";
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Stem of a `local://name.md` (or bare `name.md`) URL — the filename without
|
|
97
|
+
* scheme or extension. Returns the empty string for inputs that have no stem. */
|
|
98
|
+
function planFilenameStem(planFilePath: string): string {
|
|
99
|
+
const withoutScheme = planFilePath.replace(/^local:\/+/, "");
|
|
100
|
+
const lastSegment = withoutScheme.split(/[\\/]/).pop() ?? "";
|
|
101
|
+
return lastSegment.replace(/\.md$/i, "");
|
|
102
|
+
}
|
|
103
|
+
|
|
52
104
|
/** Humanize a normalized plan title for use as a session display name.
|
|
53
105
|
* Replaces `-`/`_` separators with spaces and capitalizes the first letter.
|
|
54
106
|
* Returns an empty string when the input collapses to whitespace. */
|
|
@@ -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
|
-
|
|
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
|
-
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
7740
|
+
content: preservedBlocks,
|
|
7633
7741
|
};
|
|
7634
7742
|
const lastMessage = messages.at(-1);
|
|
7635
7743
|
if (lastMessage?.role === "assistant") {
|
|
@@ -147,7 +147,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
|
|
|
147
147
|
},
|
|
148
148
|
{
|
|
149
149
|
name: "fast",
|
|
150
|
-
description: "Toggle
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
}
|
package/src/task/render.ts
CHANGED
|
@@ -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 =>
|
|
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);
|
|
@@ -120,14 +120,34 @@ export async function acquireTab(
|
|
|
120
120
|
}
|
|
121
121
|
|
|
122
122
|
const initPayload = await buildInitPayload(browser, opts);
|
|
123
|
-
|
|
123
|
+
let worker = await spawnTabWorker();
|
|
124
124
|
let info: ReadyInfo;
|
|
125
125
|
try {
|
|
126
126
|
info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
|
|
127
127
|
} catch (error) {
|
|
128
|
+
// `BuildMessage`-class failures arrive asynchronously via the worker's `error` event,
|
|
129
|
+
// after `spawnTabWorker`'s synchronous try/catch has already returned. Fall back to
|
|
130
|
+
// the inline worker here so module-resolution failures don't poison every tab open.
|
|
128
131
|
await worker.terminate().catch(() => undefined);
|
|
129
|
-
if (
|
|
130
|
-
|
|
132
|
+
if (worker.mode === "inline") {
|
|
133
|
+
if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
logger.warn("Tab worker init failed; retrying with inline tab worker (no sync-loop guard)", {
|
|
137
|
+
error: error instanceof Error ? error.message : String(error),
|
|
138
|
+
});
|
|
139
|
+
worker = await spawnInlineWorker();
|
|
140
|
+
try {
|
|
141
|
+
info = await initializeTabWorker(worker, initPayload, opts.timeoutMs + GRACE_MS);
|
|
142
|
+
} catch (inlineError) {
|
|
143
|
+
await worker.terminate().catch(() => undefined);
|
|
144
|
+
if (browser.refCount === 0) await releaseBrowser(browser, { kill: false });
|
|
145
|
+
const finalError = new ToolError(
|
|
146
|
+
`Failed to start browser tab worker (inline fallback also failed): ${inlineError instanceof Error ? inlineError.message : String(inlineError)}`,
|
|
147
|
+
);
|
|
148
|
+
(finalError as { cause?: unknown }).cause = error;
|
|
149
|
+
throw finalError;
|
|
150
|
+
}
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
holdBrowser(browser);
|
|
@@ -459,7 +459,8 @@ export class WorkerCore {
|
|
|
459
459
|
if (payload.dialogs) this.#applyDialogPolicy(payload.dialogs);
|
|
460
460
|
if (payload.url) {
|
|
461
461
|
await this.#page.goto(payload.url, {
|
|
462
|
-
|
|
462
|
+
// Default to "load" because dev servers with HMR/WS never reach networkidle.
|
|
463
|
+
waitUntil: payload.waitUntil ?? "load",
|
|
463
464
|
timeout: payload.timeoutMs,
|
|
464
465
|
});
|
|
465
466
|
}
|
|
@@ -667,7 +668,8 @@ export class WorkerCore {
|
|
|
667
668
|
goto: async (url, opts) => {
|
|
668
669
|
this.#clearElementCache();
|
|
669
670
|
await untilAborted(signal, () =>
|
|
670
|
-
|
|
671
|
+
// Default to "load" because dev servers with HMR/WS never reach networkidle.
|
|
672
|
+
page.goto(url, { waitUntil: opts?.waitUntil ?? "load", timeout: timeoutMs }),
|
|
671
673
|
);
|
|
672
674
|
},
|
|
673
675
|
observe: opts => this.#collectObservation({ ...opts, signal }),
|
package/src/tools/browser.ts
CHANGED
|
@@ -45,7 +45,7 @@ const browserSchema = z.object({
|
|
|
45
45
|
.describe("auto-handle dialogs")
|
|
46
46
|
.optional(),
|
|
47
47
|
code: z.string().describe("js body to run in tab").optional(),
|
|
48
|
-
timeout: z.number().default(30).describe("timeout in seconds").optional(),
|
|
48
|
+
timeout: z.number().default(30).describe("timeout in seconds (default 30, max 300)").optional(),
|
|
49
49
|
all: z.boolean().describe("close every tab").optional(),
|
|
50
50
|
kill: z.boolean().describe("also kill spawned-app browsers").optional(),
|
|
51
51
|
});
|