@oh-my-pi/pi-coding-agent 17.0.5 → 17.0.6
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 +166 -0
- package/dist/cli.js +2838 -2917
- package/dist/types/exec/bash-executor.d.ts +1 -0
- package/dist/types/extensibility/extensions/load-errors.d.ts +3 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
- package/dist/types/modes/components/status-line/component.d.ts +8 -0
- package/dist/types/modes/components/usage-row.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-client.d.ts +1 -1
- package/dist/types/modes/rpc/rpc-frame.d.ts +9 -0
- package/dist/types/modes/setup-wizard/index.d.ts +1 -1
- package/dist/types/modes/setup-wizard/scenes/model.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +23 -5
- package/dist/types/session/session-paths.d.ts +21 -4
- package/dist/types/tools/xdev.d.ts +5 -3
- package/dist/types/vibe/__tests__/token-rate.test.d.ts +1 -0
- package/dist/types/vibe/runtime.d.ts +23 -0
- package/package.json +12 -12
- package/src/exec/bash-executor.ts +68 -8
- package/src/extensibility/extensions/load-errors.ts +13 -0
- package/src/extensibility/plugins/legacy-pi-compat.ts +41 -6
- package/src/main.ts +8 -0
- package/src/modes/components/chat-transcript-builder.ts +10 -1
- package/src/modes/components/status-line/component.ts +55 -1
- package/src/modes/components/usage-row.ts +17 -1
- package/src/modes/controllers/command-controller.ts +41 -1
- package/src/modes/controllers/event-controller.ts +6 -1
- package/src/modes/interactive-mode.ts +62 -52
- package/src/modes/noninteractive-dispose.test.ts +2 -0
- package/src/modes/print-mode.ts +60 -0
- package/src/modes/rpc/rpc-client.ts +76 -35
- package/src/modes/rpc/rpc-frame.ts +156 -0
- package/src/modes/rpc/rpc-mode.ts +4 -2
- package/src/modes/setup-wizard/index.ts +2 -0
- package/src/modes/setup-wizard/scenes/model.ts +134 -0
- package/src/modes/utils/ui-helpers.ts +6 -1
- package/src/prompts/system/workflow-notice.md +89 -74
- package/src/session/agent-session.ts +89 -26
- package/src/session/session-manager.ts +34 -3
- package/src/session/session-paths.ts +38 -9
- package/src/task/executor.ts +45 -0
- package/src/tools/xdev.ts +11 -4
- package/src/tools/yield.ts +4 -1
- package/src/{modes/components/status-line → utils}/token-rate.ts +6 -0
- package/src/vibe/__tests__/token-rate.test.ts +96 -0
- package/src/vibe/runtime.ts +50 -0
- /package/dist/types/{modes/components/status-line → utils}/token-rate.d.ts +0 -0
|
@@ -264,7 +264,7 @@ import {
|
|
|
264
264
|
estimateToolSchemaTokens,
|
|
265
265
|
} from "../modes/utils/context-usage";
|
|
266
266
|
import { containsWorkflow, renderWorkflowNotice } from "../modes/workflow";
|
|
267
|
-
import { resolveApprovedPlan } from "../plan-mode/approved-plan";
|
|
267
|
+
import { type PlanApprovalDetails, resolveApprovedPlan } from "../plan-mode/approved-plan";
|
|
268
268
|
import { createPlanReadMatcher } from "../plan-mode/plan-protection";
|
|
269
269
|
import type { PlanModeState } from "../plan-mode/state";
|
|
270
270
|
import advisorSystemPrompt from "../prompts/advisor/system.md" with { type: "text" };
|
|
@@ -2567,6 +2567,24 @@ export class AgentSession {
|
|
|
2567
2567
|
this.setPlanProposalHandler(title => this.#approvePlanYoloProposal(title));
|
|
2568
2568
|
}
|
|
2569
2569
|
|
|
2570
|
+
/** Validate the active plan artifact and shape an `xd://propose` result for review-mode hosts. */
|
|
2571
|
+
async preparePlanForReview(title: string): Promise<AgentToolResult<PlanApprovalDetails>> {
|
|
2572
|
+
const state = this.getPlanModeState();
|
|
2573
|
+
if (!state?.enabled) {
|
|
2574
|
+
throw new ToolError("Plan mode is not active.");
|
|
2575
|
+
}
|
|
2576
|
+
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
|
|
2577
|
+
suppliedTitle: title,
|
|
2578
|
+
statePlanFilePath: state.planFilePath,
|
|
2579
|
+
readPlan: url => this.#readPlanFile(url),
|
|
2580
|
+
listPlanFiles: () => this.#listPlanFiles(),
|
|
2581
|
+
});
|
|
2582
|
+
return {
|
|
2583
|
+
content: [{ type: "text", text: "Plan ready for review." }],
|
|
2584
|
+
details: { planFilePath, title: resolvedTitle, planExists: true },
|
|
2585
|
+
};
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2570
2588
|
/**
|
|
2571
2589
|
* Plan-proposal handler while PlanYolo's plan phase is active. Auto-approves
|
|
2572
2590
|
* the instant the model writes the plan slug/title to `xd://propose` — no
|
|
@@ -2587,8 +2605,8 @@ export class AgentSession {
|
|
|
2587
2605
|
const { planFilePath, title: resolvedTitle } = await resolveApprovedPlan({
|
|
2588
2606
|
suppliedTitle: title,
|
|
2589
2607
|
statePlanFilePath: state.planFilePath,
|
|
2590
|
-
readPlan: url => this.#
|
|
2591
|
-
listPlanFiles: () => this.#
|
|
2608
|
+
readPlan: url => this.#readPlanFile(url),
|
|
2609
|
+
listPlanFiles: () => this.#listPlanFiles(),
|
|
2592
2610
|
});
|
|
2593
2611
|
this.setPlanModeState(undefined);
|
|
2594
2612
|
const previousTools = this.#planYoloPreviousTools;
|
|
@@ -2623,7 +2641,7 @@ export class AgentSession {
|
|
|
2623
2641
|
};
|
|
2624
2642
|
}
|
|
2625
2643
|
|
|
2626
|
-
async #
|
|
2644
|
+
async #readPlanFile(planFilePath: string): Promise<string | null> {
|
|
2627
2645
|
const resolvedPath = planFilePath.startsWith("local:")
|
|
2628
2646
|
? resolveLocalUrlToPath(normalizeLocalScheme(planFilePath), this.#localProtocolOptions())
|
|
2629
2647
|
: resolveToCwd(planFilePath, this.sessionManager.getCwd());
|
|
@@ -2637,7 +2655,7 @@ export class AgentSession {
|
|
|
2637
2655
|
|
|
2638
2656
|
/** `local://` URLs of plan files in the session-local root, newest first —
|
|
2639
2657
|
* a fallback for `resolveApprovedPlan` when the agent dropped `extra.title`. */
|
|
2640
|
-
async #
|
|
2658
|
+
async #listPlanFiles(): Promise<string[]> {
|
|
2641
2659
|
const localRoot = resolveLocalUrlToPath("local://", this.#localProtocolOptions());
|
|
2642
2660
|
try {
|
|
2643
2661
|
const entries = await fs.promises.readdir(localRoot, { withFileTypes: true });
|
|
@@ -7536,11 +7554,54 @@ export class AgentSession {
|
|
|
7536
7554
|
* Changes take effect before the next model call.
|
|
7537
7555
|
*/
|
|
7538
7556
|
async setActiveToolsByName(toolNames: string[]): Promise<void> {
|
|
7539
|
-
const mounted = this.#mountedXdevToolNames;
|
|
7540
7557
|
const normalized = normalizeToolNames(toolNames);
|
|
7558
|
+
// Transport-write eligibility keys off the *current* active set: an ordinary
|
|
7559
|
+
// selection change should not demote `write` unless it is already active.
|
|
7560
|
+
await this.#applyToolPresentation(
|
|
7561
|
+
normalized,
|
|
7562
|
+
this.#mountedXdevToolNames,
|
|
7563
|
+
this.getActiveToolNames().includes("write"),
|
|
7564
|
+
);
|
|
7565
|
+
}
|
|
7566
|
+
|
|
7567
|
+
/**
|
|
7568
|
+
* Restore an enabled tool set with its exact top-level versus `xd://` partition.
|
|
7569
|
+
*
|
|
7570
|
+
* Both inputs are required because {@link setActiveToolsByName} only receives the
|
|
7571
|
+
* enabled name list and classifies mounts from the *current* `#mountedXdevToolNames`.
|
|
7572
|
+
* Rollback/restore callers must pass the snapshotted mounted subset so names that
|
|
7573
|
+
* were top-level stay pinned (`#runtimeSelectedToolNames`) and names that were under
|
|
7574
|
+
* `xd://` remain mount-eligible, even when the live mount set has drifted.
|
|
7575
|
+
*
|
|
7576
|
+
* Names outside `mountedToolNames` are pinned top-level for this application;
|
|
7577
|
+
* names in the mounted subset remain eligible for xdev mounting. Delegates the
|
|
7578
|
+
* actual apply through `#applyActiveToolsByName` and restores the prior runtime
|
|
7579
|
+
* selection if that apply throws.
|
|
7580
|
+
*/
|
|
7581
|
+
async setActiveToolPresentation(toolNames: string[], mountedToolNames: string[]): Promise<void> {
|
|
7582
|
+
const normalized = normalizeToolNames(toolNames);
|
|
7583
|
+
// Restoration targets a snapshot, so write eligibility comes from the
|
|
7584
|
+
// *target* set rather than whatever happens to be active mid-rollback.
|
|
7585
|
+
await this.#applyToolPresentation(
|
|
7586
|
+
normalized,
|
|
7587
|
+
new Set(normalizeToolNames(mountedToolNames)),
|
|
7588
|
+
normalized.includes("write"),
|
|
7589
|
+
);
|
|
7590
|
+
}
|
|
7591
|
+
|
|
7592
|
+
/**
|
|
7593
|
+
* Shared body for {@link setActiveToolsByName} and {@link setActiveToolPresentation}:
|
|
7594
|
+
* pins non-mounted names as the runtime selection (holding `write` back when it is
|
|
7595
|
+
* transport-only) and applies the set, rolling the selection back if apply throws.
|
|
7596
|
+
*/
|
|
7597
|
+
async #applyToolPresentation(
|
|
7598
|
+
normalized: string[],
|
|
7599
|
+
mounted: ReadonlySet<string>,
|
|
7600
|
+
writeSelected: boolean,
|
|
7601
|
+
): Promise<void> {
|
|
7541
7602
|
const transportWriteActive =
|
|
7603
|
+
writeSelected &&
|
|
7542
7604
|
this.#builtInToolNames.has("write") &&
|
|
7543
|
-
this.getActiveToolNames().includes("write") &&
|
|
7544
7605
|
this.#presentationPinnedToolNames?.has("write") !== true &&
|
|
7545
7606
|
this.#runtimeSelectedToolNames?.has("write") !== true &&
|
|
7546
7607
|
(mounted.size > 0 || this.#planModeState?.enabled === true);
|
|
@@ -8569,19 +8630,18 @@ export class AgentSession {
|
|
|
8569
8630
|
timestamp,
|
|
8570
8631
|
});
|
|
8571
8632
|
}
|
|
8572
|
-
if (
|
|
8573
|
-
this
|
|
8574
|
-
|
|
8575
|
-
|
|
8576
|
-
|
|
8577
|
-
|
|
8578
|
-
|
|
8579
|
-
|
|
8580
|
-
|
|
8581
|
-
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
});
|
|
8633
|
+
if (this.#magicKeywordEnabled("workflow") && containsWorkflow(text)) {
|
|
8634
|
+
const activeToolNames = this.getActiveToolNames();
|
|
8635
|
+
if (activeToolNames.includes("task") && activeToolNames.includes("eval")) {
|
|
8636
|
+
keywordNotices.push({
|
|
8637
|
+
role: "custom",
|
|
8638
|
+
customType: "workflow-notice",
|
|
8639
|
+
content: renderWorkflowNotice({ taskBatch: this.settings.get("task.batch") }),
|
|
8640
|
+
display: false,
|
|
8641
|
+
attribution: "user",
|
|
8642
|
+
timestamp,
|
|
8643
|
+
});
|
|
8644
|
+
}
|
|
8585
8645
|
}
|
|
8586
8646
|
return keywordNotices;
|
|
8587
8647
|
}
|
|
@@ -10345,15 +10405,16 @@ export class AgentSession {
|
|
|
10345
10405
|
}
|
|
10346
10406
|
|
|
10347
10407
|
/**
|
|
10348
|
-
* Set the thinking level. `auto` enables per-turn classification
|
|
10349
|
-
*
|
|
10350
|
-
*
|
|
10351
|
-
*
|
|
10408
|
+
* Set the thinking level. `auto` enables per-turn classification. Entering
|
|
10409
|
+
* auto writes its provisional level plus `configured: "auto"` immediately,
|
|
10410
|
+
* giving external readers an authoritative selection receipt before the next
|
|
10411
|
+
* user turn. Later classifications persist only changed concrete resolutions.
|
|
10352
10412
|
*/
|
|
10353
10413
|
setThinkingLevel(level: ConfiguredThinkingLevel | undefined, persist: boolean = false): void {
|
|
10354
10414
|
if (level === AUTO_THINKING) {
|
|
10355
10415
|
const provisional = resolveProvisionalAutoLevel(this.model);
|
|
10356
10416
|
const wasAuto = this.#autoThinking;
|
|
10417
|
+
const previousLevel = this.#thinkingLevel;
|
|
10357
10418
|
this.#autoThinking = true;
|
|
10358
10419
|
this.#autoResolvedLevel = undefined;
|
|
10359
10420
|
this.#thinkingLevel = provisional;
|
|
@@ -10364,7 +10425,9 @@ export class AgentSession {
|
|
|
10364
10425
|
if (persist) {
|
|
10365
10426
|
this.settings.set("defaultThinkingLevel", AUTO_THINKING);
|
|
10366
10427
|
}
|
|
10367
|
-
|
|
10428
|
+
const isChanging = !wasAuto || previousLevel !== provisional;
|
|
10429
|
+
if (isChanging) {
|
|
10430
|
+
this.sessionManager.appendThinkingLevelChange(provisional, AUTO_THINKING);
|
|
10368
10431
|
this.#emit({ type: "thinking_level_changed", thinkingLevel: provisional, configured: AUTO_THINKING });
|
|
10369
10432
|
}
|
|
10370
10433
|
return;
|
|
@@ -10473,7 +10536,7 @@ export class AgentSession {
|
|
|
10473
10536
|
|
|
10474
10537
|
const effort = resolved ?? resolveProvisionalAutoLevel(model);
|
|
10475
10538
|
if (effort === undefined) return;
|
|
10476
|
-
const shouldPersistResolution = this.#
|
|
10539
|
+
const shouldPersistResolution = this.#thinkingLevel !== effort;
|
|
10477
10540
|
this.#autoResolvedLevel = effort;
|
|
10478
10541
|
this.#thinkingLevel = effort;
|
|
10479
10542
|
this.#applyThinkingLevelToAgent(effort);
|
|
@@ -446,6 +446,13 @@ export class SessionManager {
|
|
|
446
446
|
#inMemoryArtifactCounter = 0;
|
|
447
447
|
|
|
448
448
|
#suppressBreadcrumb = false;
|
|
449
|
+
/**
|
|
450
|
+
* The last breadcrumb this manager wrote marked a lazy `/new` boundary whose
|
|
451
|
+
* JSONL is not yet on disk. Cleared (and the crumb re-stamped non-fresh) once
|
|
452
|
+
* the session materializes, so a materialized-then-deleted session still falls
|
|
453
|
+
* back to the most-recent session instead of being treated as a fresh crumb.
|
|
454
|
+
*/
|
|
455
|
+
#breadcrumbFresh = false;
|
|
449
456
|
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
450
457
|
|
|
451
458
|
private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
|
|
@@ -458,8 +465,18 @@ export class SessionManager {
|
|
|
458
465
|
if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir);
|
|
459
466
|
}
|
|
460
467
|
|
|
461
|
-
#rememberBreadcrumb(cwd: string, sessionFile: string): void {
|
|
462
|
-
|
|
468
|
+
#rememberBreadcrumb(cwd: string, sessionFile: string, fresh = false): void {
|
|
469
|
+
this.#breadcrumbFresh = fresh;
|
|
470
|
+
if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile, fresh);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Re-stamp a fresh `/new` breadcrumb as non-fresh once the session has
|
|
475
|
+
* materialized on disk. A no-op unless the current breadcrumb is still fresh.
|
|
476
|
+
*/
|
|
477
|
+
#materializeBreadcrumb(): void {
|
|
478
|
+
if (!this.#breadcrumbFresh || !this.#sessionFile) return;
|
|
479
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, false);
|
|
463
480
|
}
|
|
464
481
|
|
|
465
482
|
#clearDiskError(): void {
|
|
@@ -601,6 +618,7 @@ export class SessionManager {
|
|
|
601
618
|
this.#closeWriterEventually();
|
|
602
619
|
this.#storage.writeTextSync(this.#sessionFile, body);
|
|
603
620
|
this.#fileIsCurrent = true;
|
|
621
|
+
this.#materializeBreadcrumb();
|
|
604
622
|
this.#rewriteRequired = false;
|
|
605
623
|
this.#hasTitleSlot = true;
|
|
606
624
|
} catch (err) {
|
|
@@ -626,6 +644,7 @@ export class SessionManager {
|
|
|
626
644
|
async () => {
|
|
627
645
|
if (await this.#runFencedAtomicRewrite(startEpoch)) {
|
|
628
646
|
this.#fileIsCurrent = true;
|
|
647
|
+
this.#materializeBreadcrumb();
|
|
629
648
|
this.#rewriteRequired = false;
|
|
630
649
|
this.#hasTitleSlot = true;
|
|
631
650
|
}
|
|
@@ -808,7 +827,7 @@ export class SessionManager {
|
|
|
808
827
|
this.#sessionFile =
|
|
809
828
|
forcedSessionFile ??
|
|
810
829
|
path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
811
|
-
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
830
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile, true);
|
|
812
831
|
} else {
|
|
813
832
|
this.#sessionFile = undefined;
|
|
814
833
|
}
|
|
@@ -2055,6 +2074,18 @@ export class SessionManager {
|
|
|
2055
2074
|
let chosenSession: string | null | undefined;
|
|
2056
2075
|
|
|
2057
2076
|
if (breadcrumb) {
|
|
2077
|
+
// A fresh `/new` boundary whose JSONL was never materialized (lazy
|
|
2078
|
+
// new-session persistence, then a process exit before any assistant
|
|
2079
|
+
// output). Honor the boundary: start fresh rather than falling back to
|
|
2080
|
+
// findMostRecentSession(), which would resurrect the pre-`/new`
|
|
2081
|
+
// transcript. A materialized (or genuinely stale/deleted) crumb reports
|
|
2082
|
+
// exists=false only when fresh, so this never masks a real stale crumb.
|
|
2083
|
+
if (breadcrumb.fresh && !breadcrumb.exists) {
|
|
2084
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
2085
|
+
manager.#resetToNewSession();
|
|
2086
|
+
return manager;
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2058
2089
|
// Recover stale crumbs: a subagent open (pre-fix) may have pointed this
|
|
2059
2090
|
// terminal's breadcrumb at an artifact child; resume the parent instead.
|
|
2060
2091
|
breadcrumb.sessionFile = resolveBreadcrumbToInteractiveRoot(breadcrumb.sessionFile);
|
|
@@ -146,28 +146,54 @@ export function computeDefaultSessionDir(
|
|
|
146
146
|
* Write a breadcrumb linking the current terminal to a session file.
|
|
147
147
|
* The breadcrumb contains the cwd and session path so --continue can
|
|
148
148
|
* find "this terminal's last session" even when running concurrent instances.
|
|
149
|
+
*
|
|
150
|
+
* `fresh` marks a `/new` (or freshly-minted) session boundary whose JSONL is
|
|
151
|
+
* not yet materialized (new-session persistence is lazy until assistant output
|
|
152
|
+
* exists). A fresh breadcrumb is honored by {@link readTerminalBreadcrumbEntry}
|
|
153
|
+
* even when its target file is still absent, so relaunch/auto-resume reopens the
|
|
154
|
+
* post-`/new` session instead of falling back to the pre-`/new` transcript. Once
|
|
155
|
+
* the session materializes the caller rewrites the breadcrumb with `fresh:false`
|
|
156
|
+
* so a later external delete is still treated as a genuinely stale crumb.
|
|
149
157
|
*/
|
|
150
|
-
export function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
|
|
158
|
+
export function writeTerminalBreadcrumb(cwd: string, sessionFile: string, fresh = false): void {
|
|
151
159
|
const terminalId = getTerminalId();
|
|
152
160
|
if (!terminalId) return;
|
|
153
161
|
|
|
154
162
|
const breadcrumbDir = getTerminalSessionsDir();
|
|
155
163
|
const breadcrumbFile = path.join(breadcrumbDir, terminalId);
|
|
156
|
-
const content = `${cwd}\n${sessionFile}\n`;
|
|
157
|
-
//
|
|
158
|
-
|
|
164
|
+
const content = fresh ? `${cwd}\n${sessionFile}\nfresh\n` : `${cwd}\n${sessionFile}\n`;
|
|
165
|
+
// Synchronous + best-effort. Infrequent (session create/switch/reset, never
|
|
166
|
+
// per-append), and writing in order matters: a lazy `/new` fresh crumb is
|
|
167
|
+
// re-stamped non-fresh the instant the session materializes, so an async
|
|
168
|
+
// fire-and-forget could land the two writes out of order and leave a
|
|
169
|
+
// materialized session marked fresh.
|
|
170
|
+
try {
|
|
171
|
+
fs.mkdirSync(breadcrumbDir, { recursive: true });
|
|
172
|
+
fs.writeFileSync(breadcrumbFile, content);
|
|
173
|
+
} catch (err) {
|
|
174
|
+
if (!isEnoent(err)) logger.debug("Terminal breadcrumb write failed", { err });
|
|
175
|
+
}
|
|
159
176
|
}
|
|
160
177
|
|
|
161
178
|
export interface TerminalBreadcrumb {
|
|
162
179
|
cwd: string;
|
|
163
180
|
sessionFile: string;
|
|
181
|
+
/** The recorded session file exists on disk right now. */
|
|
182
|
+
exists: boolean;
|
|
183
|
+
/** Recorded as a `/new` fresh-session boundary whose JSONL may not exist yet. */
|
|
184
|
+
fresh: boolean;
|
|
164
185
|
}
|
|
165
186
|
|
|
166
187
|
/**
|
|
167
188
|
* Read the raw terminal breadcrumb for the current terminal.
|
|
168
|
-
* Returns the recorded cwd + session file
|
|
169
|
-
*
|
|
170
|
-
*
|
|
189
|
+
* Returns the recorded cwd + session file regardless of whether the recorded
|
|
190
|
+
* cwd still matches the current one. Callers decide how to interpret a cwd
|
|
191
|
+
* mismatch (e.g. a moved/renamed worktree).
|
|
192
|
+
*
|
|
193
|
+
* A missing target file yields `null` UNLESS the breadcrumb is a `fresh`
|
|
194
|
+
* boundary — a lazy `/new` session whose JSONL was never written — in which case
|
|
195
|
+
* the entry is returned with `exists:false` so the caller can distinguish it
|
|
196
|
+
* from a genuinely stale/deleted breadcrumb.
|
|
171
197
|
*/
|
|
172
198
|
export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb | null> {
|
|
173
199
|
const terminalId = getTerminalId();
|
|
@@ -181,10 +207,13 @@ export async function readTerminalBreadcrumbEntry(): Promise<TerminalBreadcrumb
|
|
|
181
207
|
|
|
182
208
|
const breadcrumbCwd = lines[0];
|
|
183
209
|
const sessionFile = lines[1];
|
|
210
|
+
const fresh = lines[2] === "fresh";
|
|
184
211
|
|
|
185
|
-
// Verify the session file still exists
|
|
186
212
|
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
|
187
|
-
|
|
213
|
+
const exists = stat?.isFile() === true;
|
|
214
|
+
// A materialized target resumes normally; a missing target is honored only
|
|
215
|
+
// for a fresh `/new` boundary (never-written lazy session).
|
|
216
|
+
if (exists || fresh) return { cwd: breadcrumbCwd, sessionFile, exists, fresh };
|
|
188
217
|
} catch (err) {
|
|
189
218
|
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
190
219
|
// Breadcrumb doesn't exist or is corrupt — fall through
|
package/src/task/executor.ts
CHANGED
|
@@ -828,6 +828,8 @@ export function createSubagentSettings(
|
|
|
828
828
|
|
|
829
829
|
export type AbortReason = "signal" | "terminate" | "timeout" | "budget";
|
|
830
830
|
|
|
831
|
+
const MAX_YIELD_TOOL_ERRORS = 6;
|
|
832
|
+
|
|
831
833
|
/** Inputs for the run monitor driving one subagent assignment. */
|
|
832
834
|
interface RunMonitorArgs {
|
|
833
835
|
index: number;
|
|
@@ -874,11 +876,13 @@ interface SubagentRunMonitor {
|
|
|
874
876
|
waitForBudgetStop(): Promise<void>;
|
|
875
877
|
/** The abort kind for this run, when an abort was requested. */
|
|
876
878
|
abortKind(): AbortReason | undefined;
|
|
879
|
+
terminalError(): string | undefined;
|
|
877
880
|
/** True when the abort carries a precise external reason (signal / wall-clock / budget). */
|
|
878
881
|
hasExplicitAbortReason(): boolean;
|
|
879
882
|
/** Whether the (attempted) abort counts as a cancelled run rather than an internal failure. */
|
|
880
883
|
isAbortedRun(): boolean;
|
|
881
884
|
requestAbort(reason: AbortReason): void;
|
|
885
|
+
failWithError(message: string): void;
|
|
882
886
|
abortActiveSession(): Promise<void>;
|
|
883
887
|
waitForActiveSessionAbort(): Promise<void>;
|
|
884
888
|
resolveSignalAbortReason(): string;
|
|
@@ -965,6 +969,8 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
965
969
|
let budgetLimitExceeded = false;
|
|
966
970
|
let budgetStopRequested = false;
|
|
967
971
|
let budgetStopAbortPromise: Promise<void> | undefined;
|
|
972
|
+
let terminalError: string | undefined;
|
|
973
|
+
let consecutiveYieldToolErrors = 0;
|
|
968
974
|
let lastAssistantSalvageText: string | undefined;
|
|
969
975
|
let activeSessionAbortPromise: Promise<void> | undefined;
|
|
970
976
|
|
|
@@ -1021,6 +1027,11 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
1021
1027
|
: Promise.resolve();
|
|
1022
1028
|
};
|
|
1023
1029
|
|
|
1030
|
+
const failWithError = (message: string) => {
|
|
1031
|
+
terminalError ??= message;
|
|
1032
|
+
requestAbort("terminate");
|
|
1033
|
+
};
|
|
1034
|
+
|
|
1024
1035
|
// Handle abort signal
|
|
1025
1036
|
if (signal) {
|
|
1026
1037
|
signal.addEventListener(
|
|
@@ -1323,6 +1334,37 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
1323
1334
|
requestAbort("terminate");
|
|
1324
1335
|
}
|
|
1325
1336
|
}
|
|
1337
|
+
if (event.toolName === "yield") {
|
|
1338
|
+
if (event.isError && !abortSent) {
|
|
1339
|
+
consecutiveYieldToolErrors++;
|
|
1340
|
+
let yieldErrorText = "";
|
|
1341
|
+
const resultContent = event.result?.content;
|
|
1342
|
+
if (Array.isArray(resultContent)) {
|
|
1343
|
+
const textParts: string[] = [];
|
|
1344
|
+
for (const block of resultContent) {
|
|
1345
|
+
if (
|
|
1346
|
+
block &&
|
|
1347
|
+
typeof block === "object" &&
|
|
1348
|
+
"type" in block &&
|
|
1349
|
+
block.type === "text" &&
|
|
1350
|
+
"text" in block &&
|
|
1351
|
+
typeof block.text === "string"
|
|
1352
|
+
) {
|
|
1353
|
+
textParts.push(block.text);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
yieldErrorText = textParts.join("\n").trim();
|
|
1357
|
+
}
|
|
1358
|
+
if (consecutiveYieldToolErrors >= MAX_YIELD_TOOL_ERRORS) {
|
|
1359
|
+
const suffix = yieldErrorText ? ` Last yield error: ${yieldErrorText}` : "";
|
|
1360
|
+
failWithError(
|
|
1361
|
+
`Subagent submitted invalid yield results ${consecutiveYieldToolErrors} times; stopping to avoid an infinite submit loop.${suffix}`,
|
|
1362
|
+
);
|
|
1363
|
+
}
|
|
1364
|
+
} else if (!event.isError) {
|
|
1365
|
+
consecutiveYieldToolErrors = 0;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1326
1368
|
flushProgress = true;
|
|
1327
1369
|
break;
|
|
1328
1370
|
}
|
|
@@ -1558,6 +1600,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
1558
1600
|
hasUsage: () => hasUsage,
|
|
1559
1601
|
yieldCalled: () => yieldCalled,
|
|
1560
1602
|
runtimeLimitExceeded: () => runtimeLimitExceeded,
|
|
1603
|
+
terminalError: () => terminalError,
|
|
1561
1604
|
hasExplicitAbortReason: () =>
|
|
1562
1605
|
abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || budgetStopRequested,
|
|
1563
1606
|
budgetStopRequested: () => budgetStopRequested,
|
|
@@ -1568,6 +1611,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
|
|
|
1568
1611
|
isAbortedRun: () =>
|
|
1569
1612
|
abortReason === "signal" || runtimeLimitExceeded || budgetLimitExceeded || abortReason === undefined,
|
|
1570
1613
|
requestAbort,
|
|
1614
|
+
failWithError,
|
|
1571
1615
|
abortActiveSession,
|
|
1572
1616
|
waitForActiveSessionAbort,
|
|
1573
1617
|
resolveSignalAbortReason,
|
|
@@ -1763,6 +1807,7 @@ async function driveSessionToYield(
|
|
|
1763
1807
|
}
|
|
1764
1808
|
}
|
|
1765
1809
|
} finally {
|
|
1810
|
+
error ??= monitor.terminalError();
|
|
1766
1811
|
if (abortSignal.aborted && (!monitor.yieldCalled() || monitor.runtimeLimitExceeded())) {
|
|
1767
1812
|
aborted = monitor.isAbortedRun();
|
|
1768
1813
|
if (aborted) {
|
package/src/tools/xdev.ts
CHANGED
|
@@ -38,11 +38,18 @@ import { ToolError } from "./tool-errors";
|
|
|
38
38
|
/**
|
|
39
39
|
* Discoverable built-ins that must stay top-level even when xdev mounting is
|
|
40
40
|
* active: `todo` feeds the todo prelude/prewalk machinery, `ask` is the
|
|
41
|
-
* model's user-interaction affordance,
|
|
42
|
-
*
|
|
43
|
-
* behind dispatch
|
|
41
|
+
* model's user-interaction affordance, `grep` is the redirect target of the
|
|
42
|
+
* bash interceptor rules, and `web_search` is invoked directly by most models
|
|
43
|
+
* (which have no notion of the `xd://` protocol) so hiding it behind dispatch
|
|
44
|
+
* makes it unreachable in practice (issue #5973) — each loses its harness
|
|
45
|
+
* integration or usability if hidden behind dispatch.
|
|
44
46
|
*/
|
|
45
|
-
export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = {
|
|
47
|
+
export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = {
|
|
48
|
+
todo: true,
|
|
49
|
+
ask: true,
|
|
50
|
+
grep: true,
|
|
51
|
+
web_search: true,
|
|
52
|
+
};
|
|
46
53
|
|
|
47
54
|
/**
|
|
48
55
|
* Tools that carry the `xd://` transport itself and therefore can never be
|
package/src/tools/yield.ts
CHANGED
|
@@ -16,6 +16,9 @@ import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
|
|
|
16
16
|
import type { ToolSession } from ".";
|
|
17
17
|
import { buildOutputValidator, formatAllValidationIssues } from "./output-schema-validator";
|
|
18
18
|
|
|
19
|
+
const YIELD_RESULT_FORMAT_HINT =
|
|
20
|
+
'Submit success as {"result":{"data":<your output>}} or failure as {"result":{"error":"message"}}.';
|
|
21
|
+
|
|
19
22
|
export interface YieldDetails {
|
|
20
23
|
/** Successful result payload, or omitted when `useLastTurn` requests last-turn extraction. */
|
|
21
24
|
data?: unknown;
|
|
@@ -314,7 +317,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
|
|
|
314
317
|
const raw = params as Record<string, unknown>;
|
|
315
318
|
const rawResult = raw.result;
|
|
316
319
|
if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
|
|
317
|
-
throw new Error(
|
|
320
|
+
throw new Error(`result must be an object containing either data or error. ${YIELD_RESULT_FORMAT_HINT}`);
|
|
318
321
|
}
|
|
319
322
|
const resultRecord = rawResult as Record<string, unknown>;
|
|
320
323
|
const errorMessage = typeof resultRecord.error === "string" ? resultRecord.error : undefined;
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token-throughput calculator shared by the status line (main session tok/s
|
|
3
|
+
* badge) and the vibe worker aggregation ({@link aggregateVibeWorkerTokensPerSecond}).
|
|
4
|
+
* Lives in `utils/` so neither the render layer nor the vibe runtime has to
|
|
5
|
+
* depend on the other for a pure arithmetic helper.
|
|
6
|
+
*/
|
|
1
7
|
const MIN_DURATION_MS = 100;
|
|
2
8
|
|
|
3
9
|
type AssistantUsage = {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Contracts: vibe worker tok/s aggregation.
|
|
3
|
+
*
|
|
4
|
+
* 1. Returns null when the owner has no vibe worker sessions.
|
|
5
|
+
* 2. Sums the tok/s of every live worker the owner has registered, reading
|
|
6
|
+
* each worker's last assistant message through the same calculator the
|
|
7
|
+
* main status line uses.
|
|
8
|
+
* 3. Returns null when workers exist but none are streaming (no live rate to
|
|
9
|
+
* aggregate) — so the caller's own rate shines through unchanged.
|
|
10
|
+
* 4. Skips workers whose AgentRegistry session is detached (parked/reviving),
|
|
11
|
+
* so a stale roster entry can't contribute a phantom zero.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, it } from "bun:test";
|
|
14
|
+
import { AgentRegistry, MAIN_AGENT_ID } from "../../registry/agent-registry";
|
|
15
|
+
import type { AgentSession } from "../../session/agent-session";
|
|
16
|
+
import { aggregateVibeWorkerTokensPerSecond, VibeSessionRegistry } from "../runtime";
|
|
17
|
+
|
|
18
|
+
const OWNER = "test-owner";
|
|
19
|
+
|
|
20
|
+
/** Minimal fake AgentSession: just the messages + isStreaming the aggregator reads. */
|
|
21
|
+
function fakeSession(messages: unknown[], isStreaming: boolean): AgentSession {
|
|
22
|
+
return { state: { messages }, isStreaming } as unknown as AgentSession;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A finalized assistant message with a known duration → deterministic tok/s. */
|
|
26
|
+
function assistantMessage(output: number, durationMs: number, timestamp = 1000) {
|
|
27
|
+
return { role: "assistant", timestamp, duration: durationMs, usage: { output } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function registerWorker(id: string, session: AgentSession | null, ownerId = OWNER) {
|
|
31
|
+
VibeSessionRegistry.global().registerRecordForTests({ id, ownerId });
|
|
32
|
+
if (session) {
|
|
33
|
+
AgentRegistry.global().register({
|
|
34
|
+
id,
|
|
35
|
+
displayName: id,
|
|
36
|
+
kind: "sub",
|
|
37
|
+
session,
|
|
38
|
+
status: "running",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("aggregateVibeWorkerTokensPerSecond", () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
AgentRegistry.resetGlobalForTests();
|
|
46
|
+
VibeSessionRegistry.resetGlobalForTests();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns null when the owner has no worker sessions", () => {
|
|
50
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("sums tok/s across every live streaming worker", () => {
|
|
54
|
+
// 100 tokens in 1000ms → 100 tok/s.
|
|
55
|
+
registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true));
|
|
56
|
+
// 50 tokens in 500ms → 100 tok/s.
|
|
57
|
+
registerWorker("w2", fakeSession([assistantMessage(50, 500)], true));
|
|
58
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(200);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("returns null when workers exist but none have a live rate", () => {
|
|
62
|
+
// Not streaming, no duration → calculateTokensPerSecond returns null.
|
|
63
|
+
registerWorker("w1", fakeSession([assistantMessage(100, 0)], false));
|
|
64
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("ignores idle workers whose last turn finished — a finalized duration must not contribute a stale rate", () => {
|
|
68
|
+
// Finalized message (duration set) but the worker is no longer
|
|
69
|
+
// streaming: its completed tok/s must not stick to the badge forever.
|
|
70
|
+
registerWorker("w1", fakeSession([assistantMessage(100, 1000)], false));
|
|
71
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("streaming workers still count while an idle sibling is skipped", () => {
|
|
75
|
+
registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true));
|
|
76
|
+
registerWorker("w2", fakeSession([assistantMessage(50, 500)], false));
|
|
77
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(100);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("ignores workers whose AgentRegistry session is detached", () => {
|
|
81
|
+
registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true));
|
|
82
|
+
// w2 is in the vibe roster but has no live AgentRegistry session.
|
|
83
|
+
registerWorker("w2", null);
|
|
84
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(100);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("scopes to the requesting owner — other owners' workers don't count", () => {
|
|
88
|
+
registerWorker("w1", fakeSession([assistantMessage(100, 1000)], true), OWNER);
|
|
89
|
+
registerWorker("w2", fakeSession([assistantMessage(50, 500)], true), "other-owner");
|
|
90
|
+
expect(aggregateVibeWorkerTokensPerSecond(OWNER)).toBe(100);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("returns null for the main-agent owner id when no workers are registered", () => {
|
|
94
|
+
expect(aggregateVibeWorkerTokensPerSecond(MAIN_AGENT_ID)).toBeNull();
|
|
95
|
+
});
|
|
96
|
+
});
|
package/src/vibe/runtime.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { type AgentDefinition, type AgentProgress, oneLineLabel, type SingleResu
|
|
|
33
33
|
import type { ToolSession } from "../tools";
|
|
34
34
|
import { formatDuration } from "../tools/render-utils";
|
|
35
35
|
import { ToolError } from "../tools/tool-errors";
|
|
36
|
+
import { calculateTokensPerSecond } from "../utils/token-rate";
|
|
36
37
|
|
|
37
38
|
/** The two worker CLI flavors the director drives. */
|
|
38
39
|
export type VibeCli = "fast" | "good";
|
|
@@ -207,6 +208,26 @@ export class VibeSessionRegistry {
|
|
|
207
208
|
VibeSessionRegistry.#global = undefined;
|
|
208
209
|
}
|
|
209
210
|
|
|
211
|
+
/**
|
|
212
|
+
* Insert a bare worker record without the spawn/job machinery. Test-only —
|
|
213
|
+
* lets {@link aggregateVibeWorkerTokensPerSecond} be exercised against a
|
|
214
|
+
* fake roster + AgentRegistry session without driving a real turn.
|
|
215
|
+
*/
|
|
216
|
+
registerRecordForTests(record: { id: string; cli?: VibeCli; ownerId: string; state?: VibeSessionState }): void {
|
|
217
|
+
this.#records.set(record.id, {
|
|
218
|
+
id: record.id,
|
|
219
|
+
cli: record.cli ?? "fast",
|
|
220
|
+
ownerId: record.ownerId,
|
|
221
|
+
agent: getBundledAgent("sonic")!,
|
|
222
|
+
state: record.state ?? "running",
|
|
223
|
+
createdAt: Date.now(),
|
|
224
|
+
lastActivityAt: Date.now(),
|
|
225
|
+
queue: [],
|
|
226
|
+
turnCount: 0,
|
|
227
|
+
killed: false,
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
210
231
|
readonly #records = new Map<string, VibeRecord>();
|
|
211
232
|
|
|
212
233
|
#manager(session: ToolSession): AsyncJobManager {
|
|
@@ -708,3 +729,32 @@ export class VibeSessionRegistry {
|
|
|
708
729
|
return text;
|
|
709
730
|
}
|
|
710
731
|
}
|
|
732
|
+
|
|
733
|
+
/**
|
|
734
|
+
* Aggregate tok/s across every live vibe worker session owned by `ownerId`.
|
|
735
|
+
* Returns null when no workers are streaming (so callers can fall back to
|
|
736
|
+
* their own rate unchanged). The director is often idle while workers stream,
|
|
737
|
+
* so without this aggregation the status-line tok/s badge would show a stale
|
|
738
|
+
* value while parallel work is actively generating tokens.
|
|
739
|
+
*
|
|
740
|
+
* Reads each worker's last assistant message via {@link calculateTokensPerSecond}
|
|
741
|
+
* — the same leaf calculator the main status line uses — so worker rates are
|
|
742
|
+
* computed identically to the main session's rate.
|
|
743
|
+
*/
|
|
744
|
+
export function aggregateVibeWorkerTokensPerSecond(ownerId: string): number | null {
|
|
745
|
+
const ids = VibeSessionRegistry.global().listIds(ownerId);
|
|
746
|
+
if (ids.length === 0) return null;
|
|
747
|
+
let total = 0;
|
|
748
|
+
let any = false;
|
|
749
|
+
const registry = AgentRegistry.global();
|
|
750
|
+
for (const id of ids) {
|
|
751
|
+
const workerSession = registry.get(id)?.session;
|
|
752
|
+
if (!workerSession?.isStreaming) continue;
|
|
753
|
+
const rate = calculateTokensPerSecond(workerSession.state.messages, true);
|
|
754
|
+
if (rate !== null) {
|
|
755
|
+
total += rate;
|
|
756
|
+
any = true;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return any ? total : null;
|
|
760
|
+
}
|
|
File without changes
|