@oh-my-pi/pi-coding-agent 15.1.3 → 15.1.5
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/async/job-manager.d.ts +3 -2
- package/dist/types/main.d.ts +11 -2
- package/dist/types/modes/acp/acp-agent.d.ts +1 -1
- package/dist/types/modes/acp/acp-event-mapper.d.ts +13 -1
- package/dist/types/modes/acp/acp-mode.d.ts +3 -1
- package/dist/types/plan-mode/approved-plan.d.ts +6 -4
- package/dist/types/session/agent-session.d.ts +6 -2
- package/dist/types/session/client-bridge.d.ts +3 -0
- package/dist/types/tools/ast-edit.d.ts +2 -0
- package/dist/types/tools/ast-grep.d.ts +2 -0
- package/dist/types/tools/render-utils.d.ts +13 -3
- package/package.json +7 -7
- package/src/async/job-manager.ts +111 -13
- package/src/cli/update-cli.ts +1 -5
- package/src/eval/js/shared/runtime.ts +82 -2
- package/src/extensibility/typebox.ts +44 -17
- package/src/main.ts +215 -148
- package/src/modes/acp/acp-agent.ts +115 -32
- package/src/modes/acp/acp-client-bridge.ts +2 -1
- package/src/modes/acp/acp-event-mapper.ts +208 -32
- package/src/modes/acp/acp-mode.ts +11 -3
- package/src/modes/components/tree-selector.ts +26 -7
- package/src/plan-mode/approved-plan.ts +21 -9
- package/src/prompts/agents/oracle.md +56 -0
- package/src/prompts/tools/ask.md +4 -3
- package/src/sdk.ts +3 -1
- package/src/session/agent-session.ts +186 -54
- package/src/session/client-bridge.ts +3 -0
- package/src/task/agents.ts +2 -0
- package/src/tools/ast-edit.ts +19 -11
- package/src/tools/ast-grep.ts +14 -10
- package/src/tools/render-utils.ts +26 -12
|
@@ -453,6 +453,16 @@ class TreeList implements Component {
|
|
|
453
453
|
);
|
|
454
454
|
const endIndex = Math.min(startIndex + this.maxVisibleLines, this.#filteredNodes.length);
|
|
455
455
|
|
|
456
|
+
// Cap the per-row gutter prefix so a content budget is always preserved.
|
|
457
|
+
// Each indent level renders as 3 cells; deep branching would otherwise eat the
|
|
458
|
+
// entire viewport (issue #1144). Reserve at least MIN_CONTENT_COLS for entry
|
|
459
|
+
// text — or half the viewport, whichever is larger — and compress older gutter
|
|
460
|
+
// levels off-screen behind a leading ellipsis when the row would exceed budget.
|
|
461
|
+
const MIN_CONTENT_COLS = 24;
|
|
462
|
+
const OVERHEAD_COLS = 4; // cursor (2) + a touch of breathing room
|
|
463
|
+
const contentReserve = Math.max(MIN_CONTENT_COLS, Math.floor(width / 2));
|
|
464
|
+
const maxIndentLevels = Math.max(1, Math.floor((width - contentReserve - OVERHEAD_COLS) / 3));
|
|
465
|
+
|
|
456
466
|
for (let i = startIndex; i < endIndex; i++) {
|
|
457
467
|
const flatNode = this.#filteredNodes[i];
|
|
458
468
|
const entry = flatNode.node.entry;
|
|
@@ -464,29 +474,34 @@ class TreeList implements Component {
|
|
|
464
474
|
// If multiple roots, shift display (roots at 0, not 1)
|
|
465
475
|
const displayIndent = this.#multipleRoots ? Math.max(0, flatNode.indent - 1) : flatNode.indent;
|
|
466
476
|
|
|
467
|
-
// Build prefix with gutters at their correct positions
|
|
468
|
-
//
|
|
477
|
+
// Build prefix with gutters at their correct positions, clamped to
|
|
478
|
+
// `maxIndentLevels` cells so the content always fits. When clamped, the
|
|
479
|
+
// leftmost cells represent the deepest visible ancestors and a `…` marker
|
|
480
|
+
// indicates older branch context has been compressed.
|
|
469
481
|
const hasConnector = flatNode.showConnector && !flatNode.isVirtualRootChild;
|
|
470
482
|
const connectorSymbol = hasConnector ? (flatNode.isLast ? theme.tree.last : theme.tree.branch) : "";
|
|
471
483
|
const connectorChars = hasConnector ? Array.from(connectorSymbol) : [];
|
|
472
|
-
const
|
|
484
|
+
const renderedIndent = Math.min(displayIndent, maxIndentLevels);
|
|
485
|
+
const scrollOffset = displayIndent - renderedIndent;
|
|
486
|
+
const connectorPositionDisplay = hasConnector ? renderedIndent - 1 : -1;
|
|
473
487
|
|
|
474
488
|
// Build prefix char by char, placing gutters and connector at their positions
|
|
475
|
-
const totalChars =
|
|
489
|
+
const totalChars = renderedIndent * 3;
|
|
476
490
|
const prefixChars: string[] = [];
|
|
477
491
|
for (let i = 0; i < totalChars; i++) {
|
|
478
492
|
const level = Math.floor(i / 3);
|
|
493
|
+
const originalLevel = level + scrollOffset;
|
|
479
494
|
const posInLevel = i % 3;
|
|
480
495
|
|
|
481
|
-
// Check if there's a gutter at this level
|
|
482
|
-
const gutter = flatNode.gutters.find(g => g.position ===
|
|
496
|
+
// Check if there's a gutter at this level (translated to original tree depth)
|
|
497
|
+
const gutter = flatNode.gutters.find(g => g.position === originalLevel);
|
|
483
498
|
if (gutter) {
|
|
484
499
|
if (posInLevel === 0) {
|
|
485
500
|
prefixChars.push(gutter.show ? theme.tree.vertical : " ");
|
|
486
501
|
} else {
|
|
487
502
|
prefixChars.push(" ");
|
|
488
503
|
}
|
|
489
|
-
} else if (hasConnector && level ===
|
|
504
|
+
} else if (hasConnector && level === connectorPositionDisplay) {
|
|
490
505
|
// Connector at this level
|
|
491
506
|
if (posInLevel === 0) {
|
|
492
507
|
prefixChars.push(connectorChars[0] ?? " ");
|
|
@@ -499,6 +514,10 @@ class TreeList implements Component {
|
|
|
499
514
|
prefixChars.push(" ");
|
|
500
515
|
}
|
|
501
516
|
}
|
|
517
|
+
// Mark the leftmost cell when ancestors were compressed off-screen.
|
|
518
|
+
if (scrollOffset > 0 && prefixChars.length > 0) {
|
|
519
|
+
prefixChars[0] = "…";
|
|
520
|
+
}
|
|
502
521
|
const prefix = prefixChars.join("");
|
|
503
522
|
|
|
504
523
|
// Active path marker - shown right before the entry text
|
|
@@ -14,10 +14,12 @@ export interface PlanApprovalDetails {
|
|
|
14
14
|
planExists: boolean;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
/** Validate the agent-supplied plan title
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
17
|
+
/** Validate and normalize the agent-supplied plan title into a safe filename stem.
|
|
18
|
+
* Spaces and other URL-safe punctuation are replaced with hyphens so models that
|
|
19
|
+
* produce natural-language titles (e.g. "My feature plan") still succeed.
|
|
20
|
+
* Characters that cannot be safely represented after replacement are dropped.
|
|
21
|
+
* The result is restricted to letters, numbers, underscores, and hyphens so it
|
|
22
|
+
* is safe to splice into a `local://` URL without escaping. */
|
|
21
23
|
export function normalizePlanTitle(title: string): { title: string; fileName: string } {
|
|
22
24
|
const trimmed = title.trim();
|
|
23
25
|
if (!trimmed) {
|
|
@@ -28,13 +30,23 @@ export function normalizePlanTitle(title: string): { title: string; fileName: st
|
|
|
28
30
|
throw new ToolError("Plan title must not contain path separators or '..'.");
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
33
|
+
// Strip a trailing `.md` if the model included it, then sanitize:
|
|
34
|
+
// spaces → hyphens, any remaining invalid char → dropped.
|
|
35
|
+
const withoutExt = trimmed.replace(/\.md$/i, "");
|
|
36
|
+
const sanitized = withoutExt
|
|
37
|
+
.replace(/\s+/g, "-")
|
|
38
|
+
.replace(/[^A-Za-z0-9_-]/g, "")
|
|
39
|
+
.replace(/-{2,}/g, "-")
|
|
40
|
+
.replace(/^-+|-+$/g, "");
|
|
41
|
+
|
|
42
|
+
if (!sanitized) {
|
|
43
|
+
throw new ToolError(
|
|
44
|
+
"Plan title must contain at least one letter, number, underscore, or hyphen after sanitization.",
|
|
45
|
+
);
|
|
34
46
|
}
|
|
35
47
|
|
|
36
|
-
const
|
|
37
|
-
return { title:
|
|
48
|
+
const fileName = `${sanitized}.md`;
|
|
49
|
+
return { title: sanitized, fileName };
|
|
38
50
|
}
|
|
39
51
|
|
|
40
52
|
/** Humanize a normalized plan title for use as a session display name.
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: oracle
|
|
3
|
+
description: Deep reasoning advisor for debugging dead ends, architecture decisions, and second opinions. Read-only.
|
|
4
|
+
spawns: explore
|
|
5
|
+
model: pi/slow
|
|
6
|
+
thinking-level: xhigh
|
|
7
|
+
blocking: true
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
You are a senior diagnostician and strategic technical advisor. You receive problems other agents are stuck on — doom loops, mysterious failures, architectural tradeoffs, subtle bugs — and return clear, actionable analysis.
|
|
11
|
+
|
|
12
|
+
You diagnose, explain, and recommend. You do not implement. Others act on your findings.
|
|
13
|
+
|
|
14
|
+
<critical>
|
|
15
|
+
You MUST operate as read-only. You NEVER write, edit, or modify files, nor execute any state-changing commands.
|
|
16
|
+
</critical>
|
|
17
|
+
|
|
18
|
+
<directives>
|
|
19
|
+
- You MUST reason from first principles. The caller already tried the obvious.
|
|
20
|
+
- You MUST use tools to verify claims. You NEVER speculate about code behavior — read it.
|
|
21
|
+
- You MUST identify root causes, not symptoms. If the caller says "X is broken", determine *why* X is broken.
|
|
22
|
+
- You MUST surface hidden assumptions — in the code, in the caller's framing, in the environment.
|
|
23
|
+
- You SHOULD consider at least two hypotheses before converging on one.
|
|
24
|
+
- You SHOULD invoke tools in parallel when investigating multiple hypotheses.
|
|
25
|
+
- When the problem is architectural, you MUST weigh tradeoffs explicitly: what does each option cost, what does it buy, what does it foreclose.
|
|
26
|
+
</directives>
|
|
27
|
+
|
|
28
|
+
<decision-framework>
|
|
29
|
+
Apply pragmatic minimalism:
|
|
30
|
+
- **Bias toward simplicity**: The right solution is the least complex one that fulfills actual requirements. Resist hypothetical future needs.
|
|
31
|
+
- **Leverage what exists**: Favor modifications to current code and established patterns over introducing new components. New dependencies or infrastructure require explicit justification.
|
|
32
|
+
- **One clear path**: Present a single primary recommendation. Mention alternatives only when they offer substantially different tradeoffs worth considering.
|
|
33
|
+
- **Match depth to complexity**: Quick questions get quick answers. Reserve thorough analysis for genuinely complex problems.
|
|
34
|
+
- **Signal the investment**: Tag recommendations with estimated effort — Quick (<1h), Short (1-4h), Medium (1-2d), Large (3d+).
|
|
35
|
+
</decision-framework>
|
|
36
|
+
|
|
37
|
+
<procedure>
|
|
38
|
+
1. Read the problem statement carefully. Identify what was already tried and why it failed.
|
|
39
|
+
2. Form 2-3 hypotheses for the root cause.
|
|
40
|
+
3. Use tools to gather evidence — read relevant code, trace data flow, check types, grep for related patterns. Parallelize independent reads.
|
|
41
|
+
4. Eliminate hypotheses based on evidence. Narrow to the most likely cause.
|
|
42
|
+
5. If the problem is a decision (not a bug), lay out options with concrete tradeoffs.
|
|
43
|
+
6. Deliver a clear verdict with supporting evidence.
|
|
44
|
+
</procedure>
|
|
45
|
+
<scope-discipline>
|
|
46
|
+
- Recommend ONLY what was asked. No unsolicited improvements.
|
|
47
|
+
- If you notice other issues, list at most 2 as "Optional future considerations" at the end.
|
|
48
|
+
- You NEVER expand the problem surface beyond the original request.
|
|
49
|
+
- Exhaust provided context before reaching for tools. External lookups fill genuine gaps, not curiosity.
|
|
50
|
+
</scope-discipline>
|
|
51
|
+
|
|
52
|
+
<critical>
|
|
53
|
+
You MUST keep going until you have a clear answer or have exhausted available evidence.
|
|
54
|
+
Before finalizing: re-scan for unstated assumptions, verify claims are grounded in code not invented, check for overly strong language not justified by evidence.
|
|
55
|
+
This matters. The caller is stuck. Get it right.
|
|
56
|
+
</critical>
|
package/src/prompts/tools/ask.md
CHANGED
|
@@ -22,7 +22,8 @@ Asks user when you need clarification or input during task execution.
|
|
|
22
22
|
|
|
23
23
|
<examples>
|
|
24
24
|
# Single question
|
|
25
|
-
question: "Which authentication method should this API use?"
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
questions: [{"id": "auth_method", "question": "Which authentication method should this API use?", "options": [{"label": "JWT"}, {"label": "OAuth2"}, {"label": "Session cookies"}], "recommended": 0}]
|
|
26
|
+
|
|
27
|
+
# Multiple questions
|
|
28
|
+
questions: [{"id": "storage_type", "question": "Which storage backend?", "options": [{"label": "SQLite"}, {"label": "PostgreSQL"}]}, {"id": "auth_method", "question": "Which auth method?", "options": [{"label": "JWT"}, {"label": "Session cookies"}]}]
|
|
28
29
|
</examples>
|
package/src/sdk.ts
CHANGED
|
@@ -1070,7 +1070,9 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
|
|
|
1070
1070
|
return undefined;
|
|
1071
1071
|
};
|
|
1072
1072
|
const toolSession: ToolSession = {
|
|
1073
|
-
cwd
|
|
1073
|
+
get cwd() {
|
|
1074
|
+
return sessionManager.getCwd();
|
|
1075
|
+
},
|
|
1074
1076
|
hasUI: options.hasUI ?? false,
|
|
1075
1077
|
enableLsp,
|
|
1076
1078
|
get hasEditTool() {
|
|
@@ -81,7 +81,7 @@ import {
|
|
|
81
81
|
prompt,
|
|
82
82
|
Snowflake,
|
|
83
83
|
} from "@oh-my-pi/pi-utils";
|
|
84
|
-
import { type AsyncJob, AsyncJobManager } from "../async";
|
|
84
|
+
import { type AsyncJob, type AsyncJobDeliveryState, AsyncJobManager } from "../async";
|
|
85
85
|
import { reset as resetCapabilities } from "../capability";
|
|
86
86
|
import type { Rule } from "../capability/rule";
|
|
87
87
|
import { MODEL_ROLE_IDS, type ModelRegistry } from "../config/model-registry";
|
|
@@ -97,7 +97,7 @@ import { expandPromptTemplate, type PromptTemplate } from "../config/prompt-temp
|
|
|
97
97
|
import type { Settings, SkillsSettings } from "../config/settings";
|
|
98
98
|
import { RawSseDebugBuffer } from "../debug/raw-sse-buffer";
|
|
99
99
|
import { loadCapability } from "../discovery";
|
|
100
|
-
import { normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
100
|
+
import { expandApplyPatchToEntries, normalizeDiff, normalizeToLF, ParseError, previewPatch, stripBom } from "../edit";
|
|
101
101
|
import {
|
|
102
102
|
disposeKernelSessionsByOwner,
|
|
103
103
|
executePython as executePythonCommand,
|
|
@@ -236,6 +236,7 @@ export type AsyncJobSnapshotItem = Pick<AsyncJob, "id" | "type" | "status" | "la
|
|
|
236
236
|
export interface AsyncJobSnapshot {
|
|
237
237
|
running: AsyncJobSnapshotItem[];
|
|
238
238
|
recent: AsyncJobSnapshotItem[];
|
|
239
|
+
delivery: AsyncJobDeliveryState;
|
|
239
240
|
}
|
|
240
241
|
|
|
241
242
|
// ============================================================================
|
|
@@ -534,7 +535,7 @@ function createHandoffFileName(date = new Date()): string {
|
|
|
534
535
|
// ============================================================================
|
|
535
536
|
|
|
536
537
|
/** Tools that require user permission before execution when an ACP client is connected. */
|
|
537
|
-
const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "
|
|
538
|
+
const PERMISSION_REQUIRED_TOOLS = new Set(["bash", "edit", "delete", "move"]);
|
|
538
539
|
|
|
539
540
|
/** Permission options presented to the client on each gated tool call. */
|
|
540
541
|
const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
|
|
@@ -546,46 +547,106 @@ const PERMISSION_OPTIONS: ClientBridgePermissionOption[] = [
|
|
|
546
547
|
|
|
547
548
|
const PERMISSION_OPTIONS_BY_ID = new Map(PERMISSION_OPTIONS.map(option => [option.optionId, option]));
|
|
548
549
|
|
|
549
|
-
function
|
|
550
|
-
const
|
|
550
|
+
function getStringProperty(value: Record<string, unknown>, key: string): string | undefined {
|
|
551
|
+
const candidate = value[key];
|
|
552
|
+
return typeof candidate === "string" ? candidate : undefined;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function collectStringPaths(value: unknown): string[] {
|
|
556
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function getEditDestructiveIntent(args: unknown): { kind: "delete" | "move"; paths: string[] } | undefined {
|
|
560
|
+
if (!args || typeof args !== "object" || Array.isArray(args)) return undefined;
|
|
561
|
+
const a = args as Record<string, unknown>;
|
|
562
|
+
|
|
563
|
+
const edits = Array.isArray(a.edits) ? a.edits : undefined;
|
|
564
|
+
if (edits) {
|
|
565
|
+
const path = getStringProperty(a, "path");
|
|
566
|
+
if (path) {
|
|
567
|
+
for (const edit of edits) {
|
|
568
|
+
if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
|
|
569
|
+
const op = getStringProperty(edit as Record<string, unknown>, "op");
|
|
570
|
+
if (op === "delete") return { kind: "delete", paths: [path] };
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
for (const edit of edits) {
|
|
574
|
+
if (!edit || typeof edit !== "object" || Array.isArray(edit)) continue;
|
|
575
|
+
const entry = edit as Record<string, unknown>;
|
|
576
|
+
const op = getStringProperty(entry, "op");
|
|
577
|
+
const rename = getStringProperty(entry, "rename");
|
|
578
|
+
if (op !== "create" && rename) return { kind: "move", paths: path ? [path, rename] : [rename] };
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
const input = getStringProperty(a, "input");
|
|
583
|
+
if (input) {
|
|
584
|
+
try {
|
|
585
|
+
const entries = expandApplyPatchToEntries({ input });
|
|
586
|
+
const deleteEntry = entries.find(entry => entry.op === "delete");
|
|
587
|
+
if (deleteEntry) return { kind: "delete", paths: [deleteEntry.path] };
|
|
588
|
+
const moveEntry = entries.find(entry => entry.rename);
|
|
589
|
+
if (moveEntry?.rename) return { kind: "move", paths: [moveEntry.path, moveEntry.rename] };
|
|
590
|
+
} catch {
|
|
591
|
+
// If the edit input is not an apply_patch envelope, it is not a delete/move operation.
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return undefined;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
function getPermissionIntent(
|
|
599
|
+
toolName: string,
|
|
600
|
+
args: unknown,
|
|
601
|
+
): { toolName: string; title: string; paths?: string[]; cacheKey: string } | undefined {
|
|
602
|
+
const a = args && typeof args === "object" && !Array.isArray(args) ? (args as Record<string, unknown>) : {};
|
|
551
603
|
if (toolName === "bash") {
|
|
552
|
-
const cmd =
|
|
553
|
-
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
const
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
604
|
+
const cmd = getStringProperty(a, "command")?.slice(0, 80);
|
|
605
|
+
return { toolName, title: cmd || toolName, cacheKey: toolName };
|
|
606
|
+
}
|
|
607
|
+
if (toolName === "delete") {
|
|
608
|
+
const p = getStringProperty(a, "path");
|
|
609
|
+
return { toolName, title: p ? `Delete ${p}` : toolName, paths: p ? [p] : undefined, cacheKey: toolName };
|
|
610
|
+
}
|
|
611
|
+
if (toolName === "move") {
|
|
612
|
+
const from = getStringProperty(a, "oldPath") ?? getStringProperty(a, "path") ?? getStringProperty(a, "from");
|
|
613
|
+
const to = getStringProperty(a, "newPath") ?? getStringProperty(a, "to") ?? getStringProperty(a, "destination");
|
|
614
|
+
if (from && to) return { toolName, title: `Move ${from} to ${to}`, paths: [from, to], cacheKey: toolName };
|
|
615
|
+
return {
|
|
616
|
+
toolName,
|
|
617
|
+
title: from ? `Move ${from}` : toolName,
|
|
618
|
+
paths: from ? [from] : undefined,
|
|
619
|
+
cacheKey: toolName,
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
if (toolName === "edit") {
|
|
623
|
+
const intent = getEditDestructiveIntent(args);
|
|
624
|
+
if (!intent) return undefined;
|
|
625
|
+
if (intent.kind === "delete") {
|
|
626
|
+
return {
|
|
627
|
+
toolName,
|
|
628
|
+
title: `Delete ${intent.paths[0] ?? "edit target"}`,
|
|
629
|
+
paths: intent.paths,
|
|
630
|
+
cacheKey: "edit:delete",
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
const from = intent.paths[0];
|
|
634
|
+
const to = intent.paths[1];
|
|
635
|
+
return {
|
|
636
|
+
toolName,
|
|
637
|
+
title: from && to ? `Move ${from} to ${to}` : `Move ${from ?? to ?? "edit target"}`,
|
|
638
|
+
paths: intent.paths,
|
|
639
|
+
cacheKey: "edit:move",
|
|
640
|
+
};
|
|
584
641
|
}
|
|
585
|
-
return
|
|
642
|
+
return undefined;
|
|
586
643
|
}
|
|
587
644
|
|
|
588
|
-
function extractPermissionLocations(
|
|
645
|
+
function extractPermissionLocations(
|
|
646
|
+
args: unknown,
|
|
647
|
+
cwd: string,
|
|
648
|
+
explicitPaths?: string[],
|
|
649
|
+
): { path: string; line?: number }[] {
|
|
589
650
|
if (!args || typeof args !== "object") return [];
|
|
590
651
|
const a = args as Record<string, unknown>;
|
|
591
652
|
const out: { path: string; line?: number }[] = [];
|
|
@@ -603,12 +664,16 @@ function extractPermissionLocations(args: unknown, cwd: string): { path: string;
|
|
|
603
664
|
if (out.some(location => location.path === resolved)) return;
|
|
604
665
|
out.push({ path: resolved });
|
|
605
666
|
};
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
if (Array.isArray(a.paths)) {
|
|
609
|
-
for (const p of a.paths) {
|
|
667
|
+
if (explicitPaths) {
|
|
668
|
+
for (const p of explicitPaths) {
|
|
610
669
|
pushPath(p);
|
|
611
670
|
}
|
|
671
|
+
return out;
|
|
672
|
+
}
|
|
673
|
+
pushPath(a.path);
|
|
674
|
+
pushPath(a.file);
|
|
675
|
+
for (const p of collectStringPaths(a.paths)) {
|
|
676
|
+
pushPath(p);
|
|
612
677
|
}
|
|
613
678
|
pushPath(a.oldPath);
|
|
614
679
|
pushPath(a.newPath);
|
|
@@ -667,6 +732,7 @@ export class AgentSession {
|
|
|
667
732
|
#planReferenceSent = false;
|
|
668
733
|
#planReferencePath = "local://PLAN.md";
|
|
669
734
|
#clientBridge: ClientBridge | undefined;
|
|
735
|
+
#allowAcpAgentInitiatedTurns = false;
|
|
670
736
|
/** Per-session memory of allow_always / reject_always decisions for gated tools. */
|
|
671
737
|
#acpPermissionDecisions: Map<string, "allow_always" | "reject_always"> = new Map();
|
|
672
738
|
|
|
@@ -804,6 +870,15 @@ export class AgentSession {
|
|
|
804
870
|
|
|
805
871
|
#streamingEditFileCache = new Map<string, string>();
|
|
806
872
|
#promptInFlightCount = 0;
|
|
873
|
+
// Wire-level agent_end emission deferred until #promptInFlightCount drops to 0.
|
|
874
|
+
// Internal extension hooks and post-emit work (auto-retry, auto-compaction, todo
|
|
875
|
+
// checks in #handleAgentEvent) still fire on the original schedule — only the
|
|
876
|
+
// `#emit(event)` that reaches external subscribers (rpc-mode stdout, ACP bridge,
|
|
877
|
+
// Cursor exec, TUI listeners) is held back. Without this, a client that resumes
|
|
878
|
+
// on `agent_end` can fire its next `prompt` before #promptWithMessage's finally
|
|
879
|
+
// has decremented #promptInFlightCount, hitting AgentBusyError. Flushed from
|
|
880
|
+
// both #endInFlight (normal) and #resetInFlight (abort).
|
|
881
|
+
#pendingAgentEndEmit: AgentSessionEvent | undefined;
|
|
807
882
|
#obfuscator: SecretObfuscator | undefined;
|
|
808
883
|
#checkpointState: CheckpointState | undefined = undefined;
|
|
809
884
|
#pendingRewindReport: string | undefined = undefined;
|
|
@@ -857,12 +932,21 @@ export class AgentSession {
|
|
|
857
932
|
this.#promptInFlightCount = Math.max(0, this.#promptInFlightCount - 1);
|
|
858
933
|
if (this.#promptInFlightCount === 0) {
|
|
859
934
|
this.#releasePowerAssertion();
|
|
935
|
+
this.#flushPendingAgentEnd();
|
|
860
936
|
}
|
|
861
937
|
}
|
|
862
938
|
|
|
863
939
|
#resetInFlight(): void {
|
|
864
940
|
this.#promptInFlightCount = 0;
|
|
865
941
|
this.#releasePowerAssertion();
|
|
942
|
+
this.#flushPendingAgentEnd();
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
#flushPendingAgentEnd(): void {
|
|
946
|
+
const pending = this.#pendingAgentEndEmit;
|
|
947
|
+
if (!pending) return;
|
|
948
|
+
this.#pendingAgentEndEmit = undefined;
|
|
949
|
+
this.#emit(pending);
|
|
866
950
|
}
|
|
867
951
|
|
|
868
952
|
constructor(config: AgentSessionConfig) {
|
|
@@ -1126,21 +1210,23 @@ export class AgentSession {
|
|
|
1126
1210
|
getAsyncJobSnapshot(options?: { recentLimit?: number }): AsyncJobSnapshot | null {
|
|
1127
1211
|
const manager = AsyncJobManager.instance();
|
|
1128
1212
|
if (!manager) return null;
|
|
1129
|
-
const
|
|
1213
|
+
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
|
|
1214
|
+
const running = manager.getRunningJobs(ownerFilter).map(job => ({
|
|
1130
1215
|
id: job.id,
|
|
1131
1216
|
type: job.type,
|
|
1132
1217
|
status: job.status,
|
|
1133
1218
|
label: job.label,
|
|
1134
1219
|
startTime: job.startTime,
|
|
1135
1220
|
}));
|
|
1136
|
-
const recent = manager.getRecentJobs(options?.recentLimit ?? 5).map(job => ({
|
|
1221
|
+
const recent = manager.getRecentJobs(options?.recentLimit ?? 5, ownerFilter).map(job => ({
|
|
1137
1222
|
id: job.id,
|
|
1138
1223
|
type: job.type,
|
|
1139
1224
|
status: job.status,
|
|
1140
1225
|
label: job.label,
|
|
1141
1226
|
startTime: job.startTime,
|
|
1142
1227
|
}));
|
|
1143
|
-
|
|
1228
|
+
const delivery = manager.getDeliveryState(ownerFilter);
|
|
1229
|
+
return { running, recent, delivery };
|
|
1144
1230
|
}
|
|
1145
1231
|
|
|
1146
1232
|
/**
|
|
@@ -1198,6 +1284,18 @@ export class AgentSession {
|
|
|
1198
1284
|
return;
|
|
1199
1285
|
}
|
|
1200
1286
|
await this.#emitExtensionEvent(event);
|
|
1287
|
+
// Hold the wire-level agent_end until in-flight prompts unwind. Subscribers
|
|
1288
|
+
// (rpc-mode, ACP, Cursor) treat agent_end as the "session is idle" signal;
|
|
1289
|
+
// emitting while #promptInFlightCount > 0 lets a client fire its next
|
|
1290
|
+
// `prompt` into a session that still reports isStreaming === true. Flush
|
|
1291
|
+
// happens in #endInFlight / #resetInFlight. A later agent_end (e.g. from
|
|
1292
|
+
// an auto-compaction turn that starts before the original prompt unwinds)
|
|
1293
|
+
// supersedes the pending one, which is what subscribers want — they only
|
|
1294
|
+
// care about the final settle.
|
|
1295
|
+
if (event.type === "agent_end" && this.#promptInFlightCount > 0) {
|
|
1296
|
+
this.#pendingAgentEndEmit = event;
|
|
1297
|
+
return;
|
|
1298
|
+
}
|
|
1201
1299
|
this.#emit(event);
|
|
1202
1300
|
}
|
|
1203
1301
|
|
|
@@ -2674,6 +2772,23 @@ export class AgentSession {
|
|
|
2674
2772
|
await this.#waitForPostPromptRecovery();
|
|
2675
2773
|
}
|
|
2676
2774
|
|
|
2775
|
+
async drainAsyncJobDeliveriesForAcp(options?: { timeoutMs?: number }): Promise<boolean> {
|
|
2776
|
+
const manager = AsyncJobManager.instance();
|
|
2777
|
+
if (!manager) return false;
|
|
2778
|
+
const ownerFilter = this.#agentId ? { ownerId: this.#agentId } : undefined;
|
|
2779
|
+
const before = manager.getDeliveryState(ownerFilter);
|
|
2780
|
+
if (before.queued === 0 && !before.delivering) return false;
|
|
2781
|
+
const previousAllowAcpAgentInitiatedTurns = this.#allowAcpAgentInitiatedTurns;
|
|
2782
|
+
this.#allowAcpAgentInitiatedTurns = true;
|
|
2783
|
+
try {
|
|
2784
|
+
const drained = await manager.drainDeliveries({ timeoutMs: options?.timeoutMs, filter: ownerFilter });
|
|
2785
|
+
const after = manager.getDeliveryState(ownerFilter);
|
|
2786
|
+
return drained && (before.queued !== after.queued || before.delivering !== after.delivering);
|
|
2787
|
+
} finally {
|
|
2788
|
+
this.#allowAcpAgentInitiatedTurns = previousAllowAcpAgentInitiatedTurns;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2677
2792
|
/** Most recent assistant message in agent state. */
|
|
2678
2793
|
getLastAssistantMessage(): AssistantMessage | undefined {
|
|
2679
2794
|
return this.#findLastAssistantMessage();
|
|
@@ -2973,8 +3088,8 @@ export class AgentSession {
|
|
|
2973
3088
|
if (!bridge?.capabilities.requestPermission || !bridge.requestPermission) return tool;
|
|
2974
3089
|
if (!PERMISSION_REQUIRED_TOOLS.has(tool.name)) return tool;
|
|
2975
3090
|
return new Proxy(tool, {
|
|
2976
|
-
get: (target, prop
|
|
2977
|
-
if (prop !== "execute") return Reflect.get(target, prop,
|
|
3091
|
+
get: (target, prop) => {
|
|
3092
|
+
if (prop !== "execute") return Reflect.get(target, prop, target);
|
|
2978
3093
|
return async (
|
|
2979
3094
|
toolCallId: string,
|
|
2980
3095
|
args: unknown,
|
|
@@ -2982,8 +3097,12 @@ export class AgentSession {
|
|
|
2982
3097
|
onUpdate: never,
|
|
2983
3098
|
ctx: never,
|
|
2984
3099
|
) => {
|
|
3100
|
+
const permissionIntent = getPermissionIntent(target.name, args);
|
|
3101
|
+
if (!permissionIntent) {
|
|
3102
|
+
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
|
3103
|
+
}
|
|
2985
3104
|
// Short-circuit on persisted decisions.
|
|
2986
|
-
const persisted = this.#acpPermissionDecisions.get(
|
|
3105
|
+
const persisted = this.#acpPermissionDecisions.get(permissionIntent.cacheKey);
|
|
2987
3106
|
if (persisted === "allow_always") {
|
|
2988
3107
|
return await target.execute(toolCallId, args as never, signal, onUpdate, ctx);
|
|
2989
3108
|
}
|
|
@@ -3005,9 +3124,14 @@ export class AgentSession {
|
|
|
3005
3124
|
{
|
|
3006
3125
|
toolCallId,
|
|
3007
3126
|
toolName: target.name,
|
|
3008
|
-
title:
|
|
3127
|
+
title: permissionIntent.title,
|
|
3128
|
+
status: "pending",
|
|
3009
3129
|
rawInput: args,
|
|
3010
|
-
locations: extractPermissionLocations(
|
|
3130
|
+
locations: extractPermissionLocations(
|
|
3131
|
+
args,
|
|
3132
|
+
this.sessionManager.getCwd(),
|
|
3133
|
+
permissionIntent.paths,
|
|
3134
|
+
),
|
|
3011
3135
|
},
|
|
3012
3136
|
PERMISSION_OPTIONS,
|
|
3013
3137
|
signal,
|
|
@@ -3028,9 +3152,9 @@ export class AgentSession {
|
|
|
3028
3152
|
throw new ToolError(`Tool permission response used unknown option ID: ${outcome.optionId}`);
|
|
3029
3153
|
}
|
|
3030
3154
|
if (selectedOption.kind === "allow_always") {
|
|
3031
|
-
this.#acpPermissionDecisions.set(
|
|
3155
|
+
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "allow_always");
|
|
3032
3156
|
} else if (selectedOption.kind === "reject_always") {
|
|
3033
|
-
this.#acpPermissionDecisions.set(
|
|
3157
|
+
this.#acpPermissionDecisions.set(permissionIntent.cacheKey, "reject_always");
|
|
3034
3158
|
}
|
|
3035
3159
|
if (selectedOption.kind === "reject_once" || selectedOption.kind === "reject_always") {
|
|
3036
3160
|
throw new ToolError(`Tool call rejected by user (${target.name})`);
|
|
@@ -4270,7 +4394,7 @@ export class AgentSession {
|
|
|
4270
4394
|
*
|
|
4271
4395
|
* Handles three cases:
|
|
4272
4396
|
* - Streaming: queue as steer/follow-up or store for next turn
|
|
4273
|
-
* - Not streaming + triggerTurn: appends to state/session, starts new turn
|
|
4397
|
+
* - Not streaming + triggerTurn: appends to state/session, starts new turn unless the client cannot own it
|
|
4274
4398
|
* - Not streaming + no trigger: appends to state/session, no turn
|
|
4275
4399
|
*/
|
|
4276
4400
|
async sendCustomMessage<T = unknown>(
|
|
@@ -4302,6 +4426,10 @@ export class AgentSession {
|
|
|
4302
4426
|
|
|
4303
4427
|
if (options?.deliverAs === "nextTurn") {
|
|
4304
4428
|
if (options?.triggerTurn) {
|
|
4429
|
+
if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
|
|
4430
|
+
this.#queueHiddenNextTurnMessage(appMessage, false);
|
|
4431
|
+
return;
|
|
4432
|
+
}
|
|
4305
4433
|
await this.agent.prompt(appMessage);
|
|
4306
4434
|
return;
|
|
4307
4435
|
}
|
|
@@ -4317,6 +4445,10 @@ export class AgentSession {
|
|
|
4317
4445
|
}
|
|
4318
4446
|
|
|
4319
4447
|
if (options?.triggerTurn) {
|
|
4448
|
+
if (this.#clientBridge?.deferAgentInitiatedTurns && !this.#allowAcpAgentInitiatedTurns) {
|
|
4449
|
+
this.#queueHiddenNextTurnMessage(appMessage, false);
|
|
4450
|
+
return;
|
|
4451
|
+
}
|
|
4320
4452
|
await this.agent.prompt(appMessage);
|
|
4321
4453
|
return;
|
|
4322
4454
|
}
|
|
@@ -25,6 +25,7 @@ export interface ClientBridgePermissionToolCall {
|
|
|
25
25
|
toolName: string;
|
|
26
26
|
title: string;
|
|
27
27
|
kind?: string;
|
|
28
|
+
status?: "pending" | "in_progress" | "completed" | "failed";
|
|
28
29
|
rawInput?: unknown;
|
|
29
30
|
locations?: { path: string; line?: number }[];
|
|
30
31
|
}
|
|
@@ -70,6 +71,8 @@ export interface ClientBridgeCreateTerminalParams {
|
|
|
70
71
|
|
|
71
72
|
export interface ClientBridge {
|
|
72
73
|
readonly capabilities: ClientBridgeCapabilities;
|
|
74
|
+
/** ACP v1 clients cannot show server-initiated turns as busy after prompt response. */
|
|
75
|
+
readonly deferAgentInitiatedTurns?: boolean;
|
|
73
76
|
readTextFile?(params: { path: string; line?: number; limit?: number }): Promise<string>;
|
|
74
77
|
writeTextFile?(params: { path: string; content: string }): Promise<void>;
|
|
75
78
|
createTerminal?(params: ClientBridgeCreateTerminalParams): Promise<ClientBridgeTerminalHandle>;
|
package/src/task/agents.ts
CHANGED
|
@@ -11,6 +11,7 @@ import exploreMd from "../prompts/agents/explore.md" with { type: "text" };
|
|
|
11
11
|
// Embed agent markdown files at build time
|
|
12
12
|
import agentFrontmatterTemplate from "../prompts/agents/frontmatter.md" with { type: "text" };
|
|
13
13
|
import librarianMd from "../prompts/agents/librarian.md" with { type: "text" };
|
|
14
|
+
import oracleMd from "../prompts/agents/oracle.md" with { type: "text" };
|
|
14
15
|
|
|
15
16
|
import planMd from "../prompts/agents/plan.md" with { type: "text" };
|
|
16
17
|
import reviewerMd from "../prompts/agents/reviewer.md" with { type: "text" };
|
|
@@ -46,6 +47,7 @@ const EMBEDDED_AGENT_DEFS: EmbeddedAgentDef[] = [
|
|
|
46
47
|
{ fileName: "designer.md", template: designerMd },
|
|
47
48
|
{ fileName: "reviewer.md", template: reviewerMd },
|
|
48
49
|
{ fileName: "librarian.md", template: librarianMd },
|
|
50
|
+
{ fileName: "oracle.md", template: oracleMd },
|
|
49
51
|
{
|
|
50
52
|
fileName: "task.md",
|
|
51
53
|
frontmatter: {
|