@yagni-app/code 1.0.1 → 1.0.2
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/dist/extension/askAdvisorTool.d.ts +7 -0
- package/dist/extension/askAdvisorTool.js +5 -1
- package/dist/extension/askYagniTool.d.ts +1 -1
- package/dist/extension/askYagniTool.js +19 -0
- package/dist/extension/childUsage.d.ts +40 -0
- package/dist/extension/childUsage.js +43 -0
- package/dist/extension/condensedTools.d.ts +4 -0
- package/dist/extension/condensedTools.js +8 -4
- package/dist/extension/footer.d.ts +13 -1
- package/dist/extension/footer.js +14 -7
- package/dist/extension/index.js +58 -6
- package/dist/extension/permission/gate.d.ts +6 -0
- package/dist/extension/permission/gate.js +11 -2
- package/dist/extension/permission/guardian.d.ts +20 -0
- package/dist/extension/permission/guardian.js +16 -1
- package/dist/extension/pipeline/goCommand.d.ts +8 -0
- package/dist/extension/pipeline/goCommand.js +8 -0
- package/dist/extension/slashCommandFilter.d.ts +30 -0
- package/dist/extension/slashCommandFilter.js +89 -0
- package/dist/extension/subagents.d.ts +11 -1
- package/dist/extension/subagents.js +16 -1
- package/package.json +2 -2
|
@@ -29,6 +29,7 @@ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-age
|
|
|
29
29
|
import { type Component } from "@earendil-works/pi-tui";
|
|
30
30
|
import { Type } from "typebox";
|
|
31
31
|
import { type AdvisorLimits, type AdvisorStateHandle } from "./advisor.js";
|
|
32
|
+
import type { ChildUsageHandle } from "./childUsage.js";
|
|
32
33
|
import type { WorkingLineHandle } from "./workingLine.js";
|
|
33
34
|
import { runStage as defaultRunStage } from "./pipeline/runner.js";
|
|
34
35
|
import { type PipelineStage } from "./pipeline/types.js";
|
|
@@ -56,6 +57,12 @@ export interface MakeAskAdvisorToolOptions {
|
|
|
56
57
|
* absent, the tool falls back to ui.setWorkingMessage directly.
|
|
57
58
|
*/
|
|
58
59
|
workingLine?: WorkingLineHandle;
|
|
60
|
+
/**
|
|
61
|
+
* Session child-usage accumulator (childUsage.ts), so the footer's session
|
|
62
|
+
* totals include advisor consults — the spend the consult-cost line already
|
|
63
|
+
* prints. Absent = not wired (tests/harnesses): no recording.
|
|
64
|
+
*/
|
|
65
|
+
childUsage?: ChildUsageHandle;
|
|
59
66
|
}
|
|
60
67
|
/**
|
|
61
68
|
* Assemble the consult brief. The advisor's persona already tells it not to take
|
|
@@ -129,7 +129,7 @@ export function makeAskAdvisorTool(opts) {
|
|
|
129
129
|
renderShell: "self",
|
|
130
130
|
renderCall: renderAdvisorCall,
|
|
131
131
|
renderResult: renderSubagentResult,
|
|
132
|
-
async execute(
|
|
132
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
133
133
|
// Read the LIVE session model: pi's picker can change it after this tool
|
|
134
134
|
// was registered, in both directions.
|
|
135
135
|
const decision = decideConsult({
|
|
@@ -194,6 +194,10 @@ export function makeAskAdvisorTool(opts) {
|
|
|
194
194
|
const cost = result.usage?.cost ?? 0;
|
|
195
195
|
const state = opts.state.record(cost);
|
|
196
196
|
finalizeTask(progress, result, Date.now());
|
|
197
|
+
// Footer session totals include the consult. Keyed by toolCallId — one
|
|
198
|
+
// consult per call, unique even for parallel consults minted in the same
|
|
199
|
+
// millisecond — so a re-fired finalize for the same call is a no-op.
|
|
200
|
+
opts.childUsage?.record("advisor", toolCallId, result.usage);
|
|
197
201
|
if (result.exitCode !== 0 && !result.finalOutput.trim()) {
|
|
198
202
|
// Fail honestly rather than returning an empty recommendation. The
|
|
199
203
|
// consult still counts: it spawned, and it may well have spent.
|
|
@@ -12,7 +12,7 @@ export interface Citation {
|
|
|
12
12
|
* AskStanding — mirrored locally by this extension's no-workspace-imports
|
|
13
13
|
* convention, see costHud.ts).
|
|
14
14
|
*/
|
|
15
|
-
export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position";
|
|
15
|
+
export type AskStanding = "confirmed" | "asserted" | "inferred" | "no_position" | "contested";
|
|
16
16
|
/** One line the TUI shows above the answer, per standing. */
|
|
17
17
|
export declare const STANDING_LINES: Record<AskStanding, string>;
|
|
18
18
|
/** Options for {@link makeAskYagniTool}. */
|
|
@@ -10,6 +10,7 @@ export const STANDING_LINES = {
|
|
|
10
10
|
asserted: "Grounded in a recorded assumption, not yet verified",
|
|
11
11
|
inferred: "Inferred from workspace context, not a recorded decision",
|
|
12
12
|
no_position: "No recorded position in this workspace",
|
|
13
|
+
contested: "Two recorded decisions conflict — escalate to a human",
|
|
13
14
|
};
|
|
14
15
|
function standingLine(value) {
|
|
15
16
|
return typeof value === "string" && value in STANDING_LINES
|
|
@@ -141,11 +142,29 @@ export function makeAskYagniTool(opts) {
|
|
|
141
142
|
text = `${text}\n\n${suggestion}`;
|
|
142
143
|
}
|
|
143
144
|
}
|
|
145
|
+
// P0 (Run 8 review): a contested answer must reach the MODEL, not just
|
|
146
|
+
// the TUI. The backend composes an ordinary answer and only overrides
|
|
147
|
+
// standing client-side, so without this the coding agent can act on one
|
|
148
|
+
// conflicting side without ever seeing the do-not-proceed instruction.
|
|
149
|
+
// The refusal + both positions are appended VERBATIM to tool content.
|
|
150
|
+
if (data.standing === "contested") {
|
|
151
|
+
const lines = [];
|
|
152
|
+
if (data.contestedInstruction)
|
|
153
|
+
lines.push(data.contestedInstruction);
|
|
154
|
+
for (const p of data.contestedPositions ?? []) {
|
|
155
|
+
lines.push(`- (${p.id}) ${p.question}: ${p.decision}`);
|
|
156
|
+
}
|
|
157
|
+
if (lines.length > 0) {
|
|
158
|
+
text = `${data.answer}\n\n${lines.join("\n")}`;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
144
161
|
return {
|
|
145
162
|
content: [{ type: "text", text }],
|
|
146
163
|
details: {
|
|
147
164
|
citations: data.citations ?? [],
|
|
148
165
|
...(data.standing ? { standing: data.standing } : {}),
|
|
166
|
+
...(data.contestedInstruction ? { contestedInstruction: data.contestedInstruction } : {}),
|
|
167
|
+
...(data.contestedPositions ? { contestedPositions: data.contestedPositions } : {}),
|
|
149
168
|
},
|
|
150
169
|
};
|
|
151
170
|
},
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped child-process usage accounting (YAG-383's honest-local
|
|
3
|
+
* counterpart for the FOOTER).
|
|
4
|
+
*
|
|
5
|
+
* `collectUsage` (footer.ts) reads pi's session entries, which only ever carry
|
|
6
|
+
* the DRIVER's own turns: subagents, advisor consults, Guardian reviews, and
|
|
7
|
+
* every /go stage run in a child process whose usage never lands in those
|
|
8
|
+
* entries. The per-child surfaces (subagent receipts, /go's run-cost note,
|
|
9
|
+
* advisor's consult-cost line) already fold each child's usage from its NDJSON
|
|
10
|
+
* stream — this module is the session-level SUM of those already-held figures,
|
|
11
|
+
* so the footer's totals can include what the receipts already printed.
|
|
12
|
+
*
|
|
13
|
+
* Same shape as `makeAdvisorState` (advisor.ts): a closure-held handle, `read()`
|
|
14
|
+
* snapshots, and an additive `record()`. Tokens ride along with cost so the
|
|
15
|
+
* footer's `↑in ↓out` figures stay honest too. `record()` is idempotent per
|
|
16
|
+
* (source, key): a re-fired completion for the same child must never
|
|
17
|
+
* double-count its spend, so each seam keys by the child's identity (one key
|
|
18
|
+
* per subagent task / consult / review / run attempt).
|
|
19
|
+
*/
|
|
20
|
+
/** Usage fields the footer's line-2 stats render. */
|
|
21
|
+
export interface ChildUsageDelta {
|
|
22
|
+
input: number;
|
|
23
|
+
output: number;
|
|
24
|
+
cacheRead: number;
|
|
25
|
+
cacheWrite: number;
|
|
26
|
+
cost: number;
|
|
27
|
+
}
|
|
28
|
+
export interface ChildUsageHandle {
|
|
29
|
+
read(): ChildUsageDelta;
|
|
30
|
+
/**
|
|
31
|
+
* Add one child's usage, deduped by `(source, key)`. A repeated record for
|
|
32
|
+
* the same key is a no-op (re-fire safety), not an overwrite: child spend is
|
|
33
|
+
* cumulative and never revised downward. `delta` may be absent (a degraded
|
|
34
|
+
* or stubbed runner that reported no usage): the key is still consumed and
|
|
35
|
+
* nothing is added — never a throw.
|
|
36
|
+
*/
|
|
37
|
+
record(source: string, key: string, delta: Partial<ChildUsageDelta> | undefined): ChildUsageDelta;
|
|
38
|
+
}
|
|
39
|
+
export declare function makeChildUsageState(): ChildUsageHandle;
|
|
40
|
+
//# sourceMappingURL=childUsage.d.ts.map
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session-scoped child-process usage accounting (YAG-383's honest-local
|
|
3
|
+
* counterpart for the FOOTER).
|
|
4
|
+
*
|
|
5
|
+
* `collectUsage` (footer.ts) reads pi's session entries, which only ever carry
|
|
6
|
+
* the DRIVER's own turns: subagents, advisor consults, Guardian reviews, and
|
|
7
|
+
* every /go stage run in a child process whose usage never lands in those
|
|
8
|
+
* entries. The per-child surfaces (subagent receipts, /go's run-cost note,
|
|
9
|
+
* advisor's consult-cost line) already fold each child's usage from its NDJSON
|
|
10
|
+
* stream — this module is the session-level SUM of those already-held figures,
|
|
11
|
+
* so the footer's totals can include what the receipts already printed.
|
|
12
|
+
*
|
|
13
|
+
* Same shape as `makeAdvisorState` (advisor.ts): a closure-held handle, `read()`
|
|
14
|
+
* snapshots, and an additive `record()`. Tokens ride along with cost so the
|
|
15
|
+
* footer's `↑in ↓out` figures stay honest too. `record()` is idempotent per
|
|
16
|
+
* (source, key): a re-fired completion for the same child must never
|
|
17
|
+
* double-count its spend, so each seam keys by the child's identity (one key
|
|
18
|
+
* per subagent task / consult / review / run attempt).
|
|
19
|
+
*/
|
|
20
|
+
const num = (n) => {
|
|
21
|
+
const v = n ?? 0;
|
|
22
|
+
return Number.isFinite(v) && v > 0 ? v : 0;
|
|
23
|
+
};
|
|
24
|
+
export function makeChildUsageState() {
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const totals = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
27
|
+
return {
|
|
28
|
+
read: () => ({ ...totals }),
|
|
29
|
+
record(source, key, delta) {
|
|
30
|
+
const dedupeKey = `${source}\u0000${key}`;
|
|
31
|
+
if (seen.has(dedupeKey))
|
|
32
|
+
return { ...totals };
|
|
33
|
+
seen.add(dedupeKey);
|
|
34
|
+
totals.input += num(delta?.input);
|
|
35
|
+
totals.output += num(delta?.output);
|
|
36
|
+
totals.cacheRead += num(delta?.cacheRead);
|
|
37
|
+
totals.cacheWrite += num(delta?.cacheWrite);
|
|
38
|
+
totals.cost += num(delta?.cost);
|
|
39
|
+
return { ...totals };
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=childUsage.js.map
|
|
@@ -35,6 +35,10 @@ export declare const DIFF_PREVIEW_LINES = 12;
|
|
|
35
35
|
export declare const ERROR_TAIL_LINES = 5;
|
|
36
36
|
/** Output tail lines shown under a still-running shell command. */
|
|
37
37
|
export declare const PARTIAL_TAIL_LINES = 3;
|
|
38
|
+
/** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
|
|
39
|
+
* tool rows align with prose. Self-render rows bypass pi's pad (renderShell
|
|
40
|
+
* "self" → unpadded container), so we reapply it here. */
|
|
41
|
+
export declare const OUTPUT_PAD = 1;
|
|
38
42
|
declare const BUILTIN_NAMES: readonly ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
39
43
|
type BuiltinName = (typeof BUILTIN_NAMES)[number];
|
|
40
44
|
export type RowStatus = "running" | "ok" | "error";
|
|
@@ -41,6 +41,10 @@ export const PARTIAL_TAIL_LINES = 3;
|
|
|
41
41
|
const EXPANDED_MAX_LINES = 1000;
|
|
42
42
|
/** Title arg preview width. */
|
|
43
43
|
const ARG_PREVIEW_MAX = 96;
|
|
44
|
+
/** Left/right margin for self-rendered rows, matching pi's `outputPad` (1) so
|
|
45
|
+
* tool rows align with prose. Self-render rows bypass pi's pad (renderShell
|
|
46
|
+
* "self" → unpadded container), so we reapply it here. */
|
|
47
|
+
export const OUTPUT_PAD = 1;
|
|
44
48
|
const BUILTIN_NAMES = ["read", "bash", "edit", "write", "grep", "find", "ls"];
|
|
45
49
|
const TITLE_BY_NAME = {
|
|
46
50
|
read: "Read",
|
|
@@ -257,7 +261,7 @@ function empty() {
|
|
|
257
261
|
return new Container();
|
|
258
262
|
}
|
|
259
263
|
function textComponent(lines) {
|
|
260
|
-
return lines.length === 0 ? empty() : new Text(lines.join("\n"),
|
|
264
|
+
return lines.length === 0 ? empty() : new Text(lines.join("\n"), OUTPUT_PAD, 0);
|
|
261
265
|
}
|
|
262
266
|
function defaultLoadSettings(cwd) {
|
|
263
267
|
return SettingsManager.create(cwd, getAgentDir());
|
|
@@ -332,13 +336,13 @@ export function registerCondensedTools(pi, deps = {}) {
|
|
|
332
336
|
const status = slice.isError ? "error" : slice.isPartial ? "running" : "ok";
|
|
333
337
|
const argsRecord = args;
|
|
334
338
|
if (slice.expanded || !isQuiet(row)) {
|
|
335
|
-
return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd),
|
|
339
|
+
return new Text(formatRowTitle(name, argsRecord, status, theme, slice.cwd), OUTPUT_PAD, 0);
|
|
336
340
|
}
|
|
337
341
|
if (!row.final) {
|
|
338
|
-
return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd),
|
|
342
|
+
return new Text(formatRowTitle(name, argsRecord, "running", theme, slice.cwd), OUTPUT_PAD, 0);
|
|
339
343
|
}
|
|
340
344
|
const summary = tracker.summaryFor(row.id);
|
|
341
|
-
return summary ? new Text(theme.fg("muted", summary),
|
|
345
|
+
return summary ? new Text(theme.fg("muted", summary), OUTPUT_PAD, 0) : empty();
|
|
342
346
|
},
|
|
343
347
|
renderResult(result, options, theme, context) {
|
|
344
348
|
const slice = context;
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
* — useful reference for what data to replicate and how to format it.
|
|
40
40
|
*/
|
|
41
41
|
import type { ExtensionContext, ReadonlyFooterDataProvider, Theme } from "@earendil-works/pi-coding-agent";
|
|
42
|
+
import type { ChildUsageHandle } from "./childUsage.js";
|
|
42
43
|
import type { ModeHolder, PermissionMode } from "./permission/gate.js";
|
|
43
44
|
export declare const BRANCH_MAX_WIDTH = 60;
|
|
44
45
|
export declare function cyclePermissionMode(current: PermissionMode): PermissionMode;
|
|
@@ -99,6 +100,17 @@ export declare function renderFooterLines(input: {
|
|
|
99
100
|
/** Formatted `+N -M` / `±`, or null when clean — appended to the branch as `[ … ]`. */
|
|
100
101
|
diff?: string | null;
|
|
101
102
|
usage: UsageTotals;
|
|
103
|
+
/**
|
|
104
|
+
* Session child-process spend (subagents, advisor consults, Guardian
|
|
105
|
+
* reviews, /go runs) — childUsage.ts's session accumulator. Added into
|
|
106
|
+
* the line-2 stats so the footer's totals include the spend the per-child
|
|
107
|
+
* receipts already print; absent/zero changes nothing.
|
|
108
|
+
*/
|
|
109
|
+
childUsage?: {
|
|
110
|
+
input: number;
|
|
111
|
+
output: number;
|
|
112
|
+
cost: number;
|
|
113
|
+
};
|
|
102
114
|
contextPercent: number | null;
|
|
103
115
|
statuses: string[];
|
|
104
116
|
}, theme: Pick<Theme, "fg">, width: number, padX?: number): string[];
|
|
@@ -111,7 +123,7 @@ export interface FooterInvalidateHandle {
|
|
|
111
123
|
invalidateGit(): void;
|
|
112
124
|
requestRender(): void;
|
|
113
125
|
}
|
|
114
|
-
export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
126
|
+
export declare function createYagniFooterFactory(ctx: ExtensionContext, modeHolder?: ModeHolder, invalidateHandle?: FooterInvalidateHandle, childUsage?: ChildUsageHandle): (_tui: unknown, theme: Theme, footerData: ReadonlyFooterDataProvider) => {
|
|
115
127
|
render(width: number): string[];
|
|
116
128
|
invalidate(): void;
|
|
117
129
|
dispose(): void;
|
package/dist/extension/footer.js
CHANGED
|
@@ -245,13 +245,19 @@ export function renderFooterLines(input, theme, width, padX = 0) {
|
|
|
245
245
|
}
|
|
246
246
|
const line1 = pad + truncateToWidth(line1Parts.join(sep), contentWidth, dim("…"));
|
|
247
247
|
// Line 2: [mode ·] model · ↑in ↓out $cost · ctx%
|
|
248
|
+
// Child-process spend (subagents, advisor, /go) joins the driver totals so
|
|
249
|
+
// the footer matches the per-child receipts and /cost's session scope.
|
|
250
|
+
const child = input.childUsage;
|
|
251
|
+
const totalIn = input.usage.input + (child?.input ?? 0);
|
|
252
|
+
const totalOut = input.usage.output + (child?.output ?? 0);
|
|
253
|
+
const totalCost = input.usage.cost + (child?.cost ?? 0);
|
|
248
254
|
const statParts = [];
|
|
249
|
-
if (
|
|
250
|
-
statParts.push(`↑${formatTokens(
|
|
251
|
-
if (
|
|
252
|
-
statParts.push(`↓${formatTokens(
|
|
253
|
-
if (
|
|
254
|
-
statParts.push(`$${
|
|
255
|
+
if (totalIn)
|
|
256
|
+
statParts.push(`↑${formatTokens(totalIn)}`);
|
|
257
|
+
if (totalOut)
|
|
258
|
+
statParts.push(`↓${formatTokens(totalOut)}`);
|
|
259
|
+
if (totalCost)
|
|
260
|
+
statParts.push(`$${totalCost.toFixed(3)}`);
|
|
255
261
|
const stats = statParts.join(" ");
|
|
256
262
|
const percentText = input.contextPercent === null ? "?" : `${Math.round(input.contextPercent)}%`;
|
|
257
263
|
const line2Parts = [];
|
|
@@ -272,7 +278,7 @@ export function renderFooterLines(input, theme, width, padX = 0) {
|
|
|
272
278
|
}
|
|
273
279
|
return lines;
|
|
274
280
|
}
|
|
275
|
-
export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
|
|
281
|
+
export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle, childUsage) {
|
|
276
282
|
return (_tui, theme, footerData) => {
|
|
277
283
|
let gitCache;
|
|
278
284
|
let diffStatCache;
|
|
@@ -305,6 +311,7 @@ export function createYagniFooterFactory(ctx, modeHolder, invalidateHandle) {
|
|
|
305
311
|
mode: modeHolder?.get() ?? null,
|
|
306
312
|
diff: formatDiffStat(diffStatCache?.get() ?? null),
|
|
307
313
|
usage: collectUsage(ctx.sessionManager),
|
|
314
|
+
childUsage: childUsage?.read(),
|
|
308
315
|
contextPercent: ctx.getContextUsage()?.percent ?? null,
|
|
309
316
|
statuses,
|
|
310
317
|
}, theme, width, resolveFooterPadX());
|
package/dist/extension/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { Text } from "@earendil-works/pi-tui";
|
|
3
3
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
4
|
+
import { makeChildUsageState } from "./childUsage.js";
|
|
4
5
|
import { appendGrant, loadGrants, resolveRepoKey, storagePrefix } from "./permission/approvedPrefixes.js";
|
|
5
6
|
import { redactCommand } from "./redact.js";
|
|
6
7
|
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand, deriveGuardianTimeoutMs } from "./permission/guardian.js";
|
|
@@ -40,12 +41,13 @@ import { registerDecisionCommands } from "./decisions.js";
|
|
|
40
41
|
import { makeDecisionCapture } from "./decisionCapture.js";
|
|
41
42
|
import { registerAmbientRecall } from "./recall.js";
|
|
42
43
|
import { resilientFetch } from "./resilientFetch.js";
|
|
43
|
-
import { installUncaughtExceptionMonitor, makeCrashReporter } from "./crashReport.js";
|
|
44
|
+
import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
|
|
44
45
|
import { flushSpool as defaultFlushSpool } from "./spool.js";
|
|
45
46
|
import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
|
|
46
47
|
import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
|
|
47
48
|
import { buildYagniProvider } from "./provider.js";
|
|
48
49
|
import { registerChipEditor } from "./chipEditor.js";
|
|
50
|
+
import { registerSlashCommandFilter } from "./slashCommandFilter.js";
|
|
49
51
|
import { defaultMineBeatGit, fileMineBeatMarkers, maybeOfferMiningBeat as defaultMaybeOfferMiningBeat, } from "./mineBeat.js";
|
|
50
52
|
import { makeFlywheelState } from "./flywheel.js";
|
|
51
53
|
import { registerSilentTurnReminder } from "./silentTurnReminder.js";
|
|
@@ -186,7 +188,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
186
188
|
// hide it forever from a session that switched TO Advanced. One state handle
|
|
187
189
|
// per session, shared with /advise so they draw on the same cap.
|
|
188
190
|
const advisorState = makeAdvisorState();
|
|
189
|
-
|
|
191
|
+
// Session child-usage accumulator: every child process's spend (subagent
|
|
192
|
+
// tasks, advisor consults, Guardian reviews, /go runs) lands here so the
|
|
193
|
+
// footer's session totals include what the per-child receipts print.
|
|
194
|
+
const childUsage = makeChildUsageState();
|
|
195
|
+
const askAdvisorTool = makeAskAdvisorTool({ state: advisorState, workingLine, childUsage });
|
|
190
196
|
pi.registerTool(askAdvisorTool);
|
|
191
197
|
// /advise runs the SAME tool, sharing the state handle, so a manual consult
|
|
192
198
|
// draws on the same cap rather than opening a side channel around it.
|
|
@@ -219,6 +225,11 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
219
225
|
// temp path, and registers the input transform that turns chips into image
|
|
220
226
|
// attachments for the model. No-op outside TUI mode.
|
|
221
227
|
registerChipEditor(pi);
|
|
228
|
+
// Hide pi's built-in slash commands that contradict YAGNI's single-provider,
|
|
229
|
+
// single-identity model (e.g. /share gist upload, /login <provider>) from
|
|
230
|
+
// slash-discovery. TUI autocomplete only; the desktop palette already
|
|
231
|
+
// excludes built-ins, and this is a no-op in RPC/print modes.
|
|
232
|
+
registerSlashCommandFilter(pi);
|
|
222
233
|
// The general subagent tool: delegate self-contained tasks (optionally in
|
|
223
234
|
// parallel) to fresh-context agents defined in .claude/agents / .pi/agents,
|
|
224
235
|
// riding the /go pipeline's child runner. /agents lists what's available.
|
|
@@ -226,7 +237,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
226
237
|
// driver's delegation identity for the diamond directive (see the
|
|
227
238
|
// before_agent_start handler below) and widens the tool's fan-out ceiling.
|
|
228
239
|
const ultraHolder = createUltraHolder();
|
|
229
|
-
registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine });
|
|
240
|
+
registerSubagents(pi, { isUltra: () => ultraHolder.get(), workingLine, childUsage });
|
|
230
241
|
registerUltraCommand(pi, ultraHolder);
|
|
231
242
|
// Shared fetch timeout for the small, interactive display-path reads below
|
|
232
243
|
// (/cost's spend + headroom, and Task 8's per-run spend for /go's summary):
|
|
@@ -236,6 +247,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
236
247
|
// The grounded multi-agent pipeline entry point: /go <ticket> runs
|
|
237
248
|
// map → plan → implement → review → fix, each child grounded by inheritance.
|
|
238
249
|
registerGoCommand(pi, {
|
|
250
|
+
childUsage,
|
|
239
251
|
// /ultra is one dial for the whole session: the same holder the subagent
|
|
240
252
|
// tool reads widens the implement diamond's parallel ceiling (4 -> 8) for
|
|
241
253
|
// the fan and its fix turns. Read per run, so a toggle lands on the next /go.
|
|
@@ -326,7 +338,10 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
326
338
|
const { event: _ignored, ...fields } = payload;
|
|
327
339
|
logEvent({
|
|
328
340
|
source: "guardian",
|
|
329
|
-
|
|
341
|
+
// `malformed` is a real failure (this is the "unclear verdict" bug the
|
|
342
|
+
// whole capture exists to diagnose) — an error, not routine info. The
|
|
343
|
+
// failed-telemetry event is already an error.
|
|
344
|
+
level: event === "guardian_event_post_failed" || payload.outcome === "malformed" ? "error" : "info",
|
|
330
345
|
event,
|
|
331
346
|
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
332
347
|
fields,
|
|
@@ -350,8 +365,45 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
350
365
|
guardianLimits,
|
|
351
366
|
guardianTier,
|
|
352
367
|
guardianDisabled,
|
|
368
|
+
childUsage,
|
|
353
369
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
354
|
-
onGuardianReview: (ev) =>
|
|
370
|
+
onGuardianReview: (ev) => {
|
|
371
|
+
guardianLogSink(ev);
|
|
372
|
+
// A malformed verdict is a real failure on our side that must reach
|
|
373
|
+
// Sentry for EVERY workspace (independent of the opt-in storage tier).
|
|
374
|
+
// Fire-and-forget to the dedicated telemetry endpoint; fail-soft, and
|
|
375
|
+
// suppressed under test/eval so a CI run never phones prod.
|
|
376
|
+
if (ev.outcome === "malformed" && !evalMode && !runningUnderTest(env)) {
|
|
377
|
+
void (async () => {
|
|
378
|
+
try {
|
|
379
|
+
const body = {
|
|
380
|
+
outcome: ev.outcome,
|
|
381
|
+
...(ev.rawOutput ? { rawOutput: ev.rawOutput } : {}),
|
|
382
|
+
...(ev.durationMs !== undefined ? { durationMs: ev.durationMs } : {}),
|
|
383
|
+
...(ev.tier ? { tier: ev.tier } : {}),
|
|
384
|
+
};
|
|
385
|
+
const res = await resilientFetch(`${baseUrl}/api/yagni-code/guardian-error`, {
|
|
386
|
+
method: "POST",
|
|
387
|
+
headers: {
|
|
388
|
+
"content-type": "application/json",
|
|
389
|
+
authorization: `Bearer ${getTokenFn() ?? ""}`,
|
|
390
|
+
...attributionHeaders(deps.env),
|
|
391
|
+
},
|
|
392
|
+
body: JSON.stringify(body),
|
|
393
|
+
}, {
|
|
394
|
+
fetchImpl: authedFetch,
|
|
395
|
+
policy: { maxAttempts: 1, backoffBaseMs: 0, backoffMaxMs: 0, timeoutMs: GUARDIAN_EVENT_TIMEOUT_MS, jitterRatio: 0 },
|
|
396
|
+
});
|
|
397
|
+
if (!res.ok) {
|
|
398
|
+
guardianLogSink({ event: "guardian_error_post_failed", status: res.status });
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
catch {
|
|
402
|
+
guardianLogSink({ event: "guardian_error_post_failed", kind: "network" });
|
|
403
|
+
}
|
|
404
|
+
})();
|
|
405
|
+
}
|
|
406
|
+
},
|
|
355
407
|
grants: sessionGrants,
|
|
356
408
|
resolveRepoKey,
|
|
357
409
|
persistGrant: (grant) => {
|
|
@@ -855,7 +907,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
855
907
|
// context % on line 2, and extension statuses (brand, todos, mode) on
|
|
856
908
|
// line 3. The factory captures ctx so the footer can read session data
|
|
857
909
|
// (token stats, context usage) that isn't on the footerData provider.
|
|
858
|
-
ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle)(tui, theme, footerData));
|
|
910
|
+
ctx.ui?.setFooter?.((tui, theme, footerData) => createYagniFooterFactory(ctx, modeHolder, footerInvalidateHandle, childUsage)(tui, theme, footerData));
|
|
859
911
|
ctx.ui?.onTerminalInput?.((data) => {
|
|
860
912
|
if (isShiftTab(data)) {
|
|
861
913
|
modeHolder.set(cyclePermissionMode(modeHolder.get()));
|
|
@@ -151,6 +151,12 @@ export interface RegisterPermissionDeps {
|
|
|
151
151
|
guardianState?: import("./guardian.js").GuardianStateHandle;
|
|
152
152
|
/** Guardian limits (timeouts, circuit breaker). Defaults to DEFAULT_GUARDIAN_LIMITS. */
|
|
153
153
|
guardianLimits?: import("./guardian.js").GuardianLimits;
|
|
154
|
+
/**
|
|
155
|
+
* Session child-usage accumulator (childUsage.ts): each completed Guardian
|
|
156
|
+
* review's spend is recorded so the footer's session totals include it.
|
|
157
|
+
* Absent = not wired: no recording, nothing else changes.
|
|
158
|
+
*/
|
|
159
|
+
childUsage?: import("../childUsage.js").ChildUsageHandle;
|
|
154
160
|
/** Override the Guardian model tier (default: efficient). */
|
|
155
161
|
guardianTier?: string;
|
|
156
162
|
/** Injectable Guardian review function (tests pass a stub). */
|
|
@@ -366,6 +366,10 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
366
366
|
const ERROR_ASK_CAP = 3;
|
|
367
367
|
let errorFallbackAsks = 0;
|
|
368
368
|
let breakerEscalationOffered = false;
|
|
369
|
+
// Uniquifies each Guardian review's childUsage key: parallel gated tool
|
|
370
|
+
// calls can start their reviews in the same millisecond, and a timestamp
|
|
371
|
+
// alone would silently drop the second review's spend as a "duplicate".
|
|
372
|
+
let reviewSpendSeq = 0;
|
|
369
373
|
const emitGateEvent = (event) => {
|
|
370
374
|
if (!deps.onGuardianEvent)
|
|
371
375
|
return;
|
|
@@ -561,13 +565,18 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
561
565
|
ctx.ui.setStatus?.("yagni-guardian", undefined);
|
|
562
566
|
}
|
|
563
567
|
const durationMs = Date.now() - startMs;
|
|
564
|
-
|
|
568
|
+
// Footer session totals include the review's spend. Each review
|
|
569
|
+
// records exactly once, right here, so the key only has to be unique
|
|
570
|
+
// per review — the seq keeps same-millisecond parallel reviews apart.
|
|
571
|
+
deps.childUsage?.record("guardian", `${startMs}#${reviewSpendSeq++}`, { cost: reviewResult.cost });
|
|
572
|
+
const emitDiag = (outcome, rationale, rawOutput) => {
|
|
565
573
|
if (!deps.onGuardianReview)
|
|
566
574
|
return;
|
|
567
575
|
void Promise.resolve(deps.onGuardianReview(buildDiagnosticEvent(outcome, {
|
|
568
576
|
durationMs,
|
|
569
577
|
tier: guardianTier,
|
|
570
578
|
...(rationale ? { rationale } : {}),
|
|
579
|
+
...(rawOutput ? { rawOutput } : {}),
|
|
571
580
|
debug: isDebug(),
|
|
572
581
|
}))).catch(() => { });
|
|
573
582
|
};
|
|
@@ -734,7 +743,7 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
734
743
|
}
|
|
735
744
|
// Guardian failed (timeout/malformed/network/empty/aborted).
|
|
736
745
|
const error = reviewResult.error ?? "network";
|
|
737
|
-
emitDiag(error);
|
|
746
|
+
emitDiag(error, undefined, reviewResult.rawOutput);
|
|
738
747
|
if (error === "aborted" || ctx?.signal?.aborted) {
|
|
739
748
|
// The user aborted mid-consult — silent block: no dialog, no
|
|
740
749
|
// "Guardian unavailable" warning on a turn they deliberately
|
|
@@ -106,7 +106,20 @@ export interface ReviewResult {
|
|
|
106
106
|
verdict: GuardianVerdict | null;
|
|
107
107
|
error?: GuardianError;
|
|
108
108
|
cost: number;
|
|
109
|
+
/**
|
|
110
|
+
* Scrubbed + capped copy of the model output when the verdict failed to
|
|
111
|
+
* parse (`error: "malformed"`). Present so the sink can capture the exact
|
|
112
|
+
* failure shape. Never contains the raw command unredacted: `scrubSecrets`
|
|
113
|
+
* removes secret-shaped values before this is stored.
|
|
114
|
+
*/
|
|
115
|
+
rawOutput?: string;
|
|
109
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Cap on the malformed-output capture. The model's unparseable verdict is
|
|
119
|
+
* usually a single short JSON-ish blob; this bounds a pathological output so
|
|
120
|
+
* it cannot balloon a log line or a Sentry payload.
|
|
121
|
+
*/
|
|
122
|
+
export declare const GUARDIAN_RAW_OUTPUT_CAP = 2048;
|
|
110
123
|
export interface ReviewCommandDeps {
|
|
111
124
|
runStage?: typeof defaultRunStage;
|
|
112
125
|
cwd: string;
|
|
@@ -143,6 +156,12 @@ export interface GuardianDiagnosticEvent {
|
|
|
143
156
|
commandHash?: string;
|
|
144
157
|
/** Debug-only: the Guardian's rationale. */
|
|
145
158
|
rationale?: string;
|
|
159
|
+
/**
|
|
160
|
+
* Scrubbed + capped copy of the unparseable model output, present only for
|
|
161
|
+
* `outcome: "malformed"`. Always-on (NOT debug-gated): it is already
|
|
162
|
+
* `scrubSecrets`-redacted and size-capped at the source.
|
|
163
|
+
*/
|
|
164
|
+
rawOutput?: string;
|
|
146
165
|
}
|
|
147
166
|
/**
|
|
148
167
|
* Create a sanitized diagnostic event. Never includes the raw command text
|
|
@@ -153,6 +172,7 @@ export declare function buildDiagnosticEvent(outcome: GuardianOutcome | Guardian
|
|
|
153
172
|
tier?: string;
|
|
154
173
|
rationale?: string;
|
|
155
174
|
commandHash?: string;
|
|
175
|
+
rawOutput?: string;
|
|
156
176
|
debug?: boolean;
|
|
157
177
|
}): GuardianDiagnosticEvent;
|
|
158
178
|
//# sourceMappingURL=guardian.d.ts.map
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
* alternating deny/ask.
|
|
29
29
|
*/
|
|
30
30
|
import { runStage as defaultRunStage } from "../pipeline/runner.js";
|
|
31
|
+
import { scrubSecrets } from "../pipeline/scrubSecrets.js";
|
|
31
32
|
export const DEFAULT_GUARDIAN_LIMITS = {
|
|
32
33
|
maxReviews: 120,
|
|
33
34
|
maxConsecutiveDenials: 3,
|
|
@@ -161,6 +162,12 @@ export function formatGuardianSubtotal(state, limits) {
|
|
|
161
162
|
const plural = state.reviews === 1 ? "review" : "reviews";
|
|
162
163
|
return `Guardian: ${state.reviews} ${plural}.`;
|
|
163
164
|
}
|
|
165
|
+
/**
|
|
166
|
+
* Cap on the malformed-output capture. The model's unparseable verdict is
|
|
167
|
+
* usually a single short JSON-ish blob; this bounds a pathological output so
|
|
168
|
+
* it cannot balloon a log line or a Sentry payload.
|
|
169
|
+
*/
|
|
170
|
+
export const GUARDIAN_RAW_OUTPUT_CAP = 2048;
|
|
164
171
|
/**
|
|
165
172
|
* The synthetic stage a Guardian consult runs as. Borrows the `plan` StageId
|
|
166
173
|
* (same pattern as the advisor) so it doesn't ripple into feed/reducers. The
|
|
@@ -231,7 +238,14 @@ export async function reviewCommand(command, deps) {
|
|
|
231
238
|
}
|
|
232
239
|
const verdict = parseVerdict(output);
|
|
233
240
|
if (!verdict) {
|
|
234
|
-
|
|
241
|
+
// Scrubbed + capped so the local sink and Sentry can see the exact
|
|
242
|
+
// failure shape without carrying a raw command or a secret it echoed.
|
|
243
|
+
return {
|
|
244
|
+
verdict: null,
|
|
245
|
+
error: "malformed",
|
|
246
|
+
cost,
|
|
247
|
+
rawOutput: scrubSecrets(output).slice(0, GUARDIAN_RAW_OUTPUT_CAP),
|
|
248
|
+
};
|
|
235
249
|
}
|
|
236
250
|
return { verdict, cost };
|
|
237
251
|
}
|
|
@@ -261,6 +275,7 @@ export function buildDiagnosticEvent(outcome, opts) {
|
|
|
261
275
|
outcome,
|
|
262
276
|
...(opts.durationMs !== undefined ? { durationMs: opts.durationMs } : {}),
|
|
263
277
|
...(opts.tier !== undefined ? { tier: opts.tier } : {}),
|
|
278
|
+
...(opts.rawOutput !== undefined ? { rawOutput: opts.rawOutput } : {}),
|
|
264
279
|
};
|
|
265
280
|
if (opts.debug) {
|
|
266
281
|
if (opts.rationale)
|
|
@@ -110,6 +110,14 @@ export interface RegisterGoDeps {
|
|
|
110
110
|
* cares injects its own fake.
|
|
111
111
|
*/
|
|
112
112
|
fetchRunSpend?: (runId: string, signal?: AbortSignal) => Promise<SpendResponse | null>;
|
|
113
|
+
/**
|
|
114
|
+
* Session child-usage accumulator (childUsage.ts). Each attempt's newly-run
|
|
115
|
+
* stage usage is recorded (keyed by runId + attempt start, because a resumed
|
|
116
|
+
* attempt's stages are new spend, never a replay) so the footer's session
|
|
117
|
+
* totals include /go spend — the same figure `runCostNote` already prints.
|
|
118
|
+
* Absent = not wired.
|
|
119
|
+
*/
|
|
120
|
+
childUsage?: import("../childUsage.js").ChildUsageHandle;
|
|
113
121
|
/** Injectable fs existence check (worktree adoption on resume). */
|
|
114
122
|
exists?: (path: string) => boolean;
|
|
115
123
|
/** Clock seam for registry rows + staleness. */
|
|
@@ -969,6 +969,14 @@ export function registerGoCommand(pi, deps = {}) {
|
|
|
969
969
|
if (result.stopReason === "clean" && runSignal.aborted) {
|
|
970
970
|
result = { ...result, stopReason: "aborted" };
|
|
971
971
|
}
|
|
972
|
+
// Footer session totals include this run's spend. Keyed per ATTEMPT
|
|
973
|
+
// (runId + this invocation's start), not per runId: `result.stages`
|
|
974
|
+
// only ever holds the stages THIS attempt actually ran (a resume's
|
|
975
|
+
// replayed spend lives in the checkpoint's priorUsage, never here),
|
|
976
|
+
// so an abort → same-session resume must record both attempts — a
|
|
977
|
+
// runId-only key would silently drop the resume's new spend. Partial
|
|
978
|
+
// spend on an aborted attempt is still real spend worth counting.
|
|
979
|
+
deps.childUsage?.record("go", `${runId}@${startedAt}`, aggregateRunUsage(result.stages, result.rounds));
|
|
972
980
|
// FINISH stage (spec §3c): ONLY on a clean stop. Commit the run's work
|
|
973
981
|
// (worktree always; --here only over a clean pre-run baseline) with the
|
|
974
982
|
// provenance trailer, and push + PR when --pr asked for it. Iron
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
/**
|
|
3
|
+
* Slash-command filtering for the CLI.
|
|
4
|
+
*
|
|
5
|
+
* pi ships its own built-in slash commands (see `BUILTIN_SLASH_COMMANDS` in
|
|
6
|
+
* pi's `core/slash-commands.js`) and bakes them directly into its interactive
|
|
7
|
+
* autocomplete provider (`createBaseAutocompleteProvider`). Most are
|
|
8
|
+
* pi-generic and harmless (`/new`, `/compact`, `/settings`), but a subset
|
|
9
|
+
* contradict YAGNI's single-provider, single-identity model — or leak pi
|
|
10
|
+
* features YAGNI deliberately does not surface — and only confuse a YAGNI Code
|
|
11
|
+
* user. We hide those from slash-discovery.
|
|
12
|
+
*
|
|
13
|
+
* Scope note: this filters DISCOVERY only. pi dispatches built-in commands in
|
|
14
|
+
* its own private `onSubmit` chain (not from the autocomplete list), so a
|
|
15
|
+
* hidden command can still be typed by hand — the same bar pi itself draws for
|
|
16
|
+
* its easter-egg commands, which are also absent from autocomplete. Removing
|
|
17
|
+
* them from the list is the whole intent: a YAGNI user should not be offered
|
|
18
|
+
* `/share` (ships a transcript to a GitHub gist) or `/login <provider>` (pi
|
|
19
|
+
* provider auth) when YAGNI's model is a single `yagni` provider.
|
|
20
|
+
*
|
|
21
|
+
* The desktop palette is unaffected: pi's RPC `get_commands` (which the desktop
|
|
22
|
+
* app reads) already returns extension commands + prompt templates + skills and
|
|
23
|
+
* never includes built-ins. This wrapper is inert outside interactive TUI mode,
|
|
24
|
+
* where `addAutocompleteProvider` is handed a no-op factory.
|
|
25
|
+
*/
|
|
26
|
+
/** pi built-in slash commands hidden from YAGNI Code autocomplete. */
|
|
27
|
+
export declare const HIDDEN_BUILTIN_SLASH_COMMANDS: readonly string[];
|
|
28
|
+
/** Register the slash-command filter on `session_start` (TUI autocomplete only). */
|
|
29
|
+
export declare function registerSlashCommandFilter(pi: ExtensionAPI): void;
|
|
30
|
+
//# sourceMappingURL=slashCommandFilter.d.ts.map
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slash-command filtering for the CLI.
|
|
3
|
+
*
|
|
4
|
+
* pi ships its own built-in slash commands (see `BUILTIN_SLASH_COMMANDS` in
|
|
5
|
+
* pi's `core/slash-commands.js`) and bakes them directly into its interactive
|
|
6
|
+
* autocomplete provider (`createBaseAutocompleteProvider`). Most are
|
|
7
|
+
* pi-generic and harmless (`/new`, `/compact`, `/settings`), but a subset
|
|
8
|
+
* contradict YAGNI's single-provider, single-identity model — or leak pi
|
|
9
|
+
* features YAGNI deliberately does not surface — and only confuse a YAGNI Code
|
|
10
|
+
* user. We hide those from slash-discovery.
|
|
11
|
+
*
|
|
12
|
+
* Scope note: this filters DISCOVERY only. pi dispatches built-in commands in
|
|
13
|
+
* its own private `onSubmit` chain (not from the autocomplete list), so a
|
|
14
|
+
* hidden command can still be typed by hand — the same bar pi itself draws for
|
|
15
|
+
* its easter-egg commands, which are also absent from autocomplete. Removing
|
|
16
|
+
* them from the list is the whole intent: a YAGNI user should not be offered
|
|
17
|
+
* `/share` (ships a transcript to a GitHub gist) or `/login <provider>` (pi
|
|
18
|
+
* provider auth) when YAGNI's model is a single `yagni` provider.
|
|
19
|
+
*
|
|
20
|
+
* The desktop palette is unaffected: pi's RPC `get_commands` (which the desktop
|
|
21
|
+
* app reads) already returns extension commands + prompt templates + skills and
|
|
22
|
+
* never includes built-ins. This wrapper is inert outside interactive TUI mode,
|
|
23
|
+
* where `addAutocompleteProvider` is handed a no-op factory.
|
|
24
|
+
*/
|
|
25
|
+
/** pi built-in slash commands hidden from YAGNI Code autocomplete. */
|
|
26
|
+
export const HIDDEN_BUILTIN_SLASH_COMMANDS = [
|
|
27
|
+
"share", // ships a session transcript to a secret GitHub gist (off-brand + privacy footgun)
|
|
28
|
+
"login", // pi provider auth; YAGNI auth is `yagni login`, one provider
|
|
29
|
+
"logout", // pi provider auth; YAGNI auth is `yagni logout`
|
|
30
|
+
"scoped-models", // pi multi-model Ctrl+P cycling; YAGNI pins a single tier
|
|
31
|
+
"fork", // pi session-fork feature YAGNI never surfaces
|
|
32
|
+
"clone", // pi session-clone feature YAGNI never surfaces
|
|
33
|
+
"tree", // pi session-tree feature YAGNI never surfaces
|
|
34
|
+
"trust", // pi project-trust prompt; collides with YAGNI guardrails
|
|
35
|
+
"changelog", // pi's own changelog, not YAGNI's
|
|
36
|
+
];
|
|
37
|
+
const HIDDEN = new Set(HIDDEN_BUILTIN_SLASH_COMMANDS);
|
|
38
|
+
/**
|
|
39
|
+
* True when the text before the cursor is a slash-COMMAND name being typed —
|
|
40
|
+
* a leading `/` plus a name with no interior `/` or space. This mirrors pi's
|
|
41
|
+
* own slash-command detection (its `applyCompletion` treats a prefix with an
|
|
42
|
+
* interior `/` as a file path, e.g. the absolute path `/usr/local/...`), so we
|
|
43
|
+
* never strip a legitimate file-named completion.
|
|
44
|
+
*/
|
|
45
|
+
function isSlashCommandName(textBeforeCursor) {
|
|
46
|
+
return textBeforeCursor.startsWith("/")
|
|
47
|
+
&& !textBeforeCursor.slice(1).includes("/")
|
|
48
|
+
&& !textBeforeCursor.slice(1).includes(" ");
|
|
49
|
+
}
|
|
50
|
+
function filterHiddenSlashSuggestions(suggestions) {
|
|
51
|
+
const items = suggestions.items.filter((item) => !HIDDEN.has(item.value));
|
|
52
|
+
if (items.length === 0)
|
|
53
|
+
return null;
|
|
54
|
+
return { ...suggestions, items };
|
|
55
|
+
}
|
|
56
|
+
/** Wrap pi's built-in autocomplete provider so hidden built-ins never complete. */
|
|
57
|
+
function makeYagniAutocompleteProvider(current) {
|
|
58
|
+
return {
|
|
59
|
+
triggerCharacters: current.triggerCharacters,
|
|
60
|
+
async getSuggestions(lines, cursorLine, cursorCol, options) {
|
|
61
|
+
const textBeforeCursor = (lines[cursorLine] ?? "").slice(0, cursorCol);
|
|
62
|
+
if (!isSlashCommandName(textBeforeCursor)) {
|
|
63
|
+
return current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
64
|
+
}
|
|
65
|
+
const suggestions = await current.getSuggestions(lines, cursorLine, cursorCol, options);
|
|
66
|
+
return suggestions ? filterHiddenSlashSuggestions(suggestions) : null;
|
|
67
|
+
},
|
|
68
|
+
applyCompletion(lines, cursorLine, cursorCol, item, prefix) {
|
|
69
|
+
return current.applyCompletion(lines, cursorLine, cursorCol, item, prefix);
|
|
70
|
+
},
|
|
71
|
+
shouldTriggerFileCompletion(lines, cursorLine, cursorCol) {
|
|
72
|
+
return current.shouldTriggerFileCompletion?.(lines, cursorLine, cursorCol) ?? true;
|
|
73
|
+
},
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
/** Register the slash-command filter on `session_start` (TUI autocomplete only). */
|
|
77
|
+
export function registerSlashCommandFilter(pi) {
|
|
78
|
+
pi.on("session_start", (_event, ctx) => {
|
|
79
|
+
if (ctx.mode !== "tui" || !ctx.hasUI)
|
|
80
|
+
return;
|
|
81
|
+
try {
|
|
82
|
+
ctx.ui.addAutocompleteProvider(makeYagniAutocompleteProvider);
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
// Filtering is cosmetic; a failure must never break session start.
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
//# sourceMappingURL=slashCommandFilter.js.map
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
21
21
|
import { Type } from "typebox";
|
|
22
|
+
import type { ChildUsageHandle } from "./childUsage.js";
|
|
22
23
|
import type { WorkingLineHandle } from "./workingLine.js";
|
|
23
24
|
import { runStage } from "./pipeline/runner.js";
|
|
24
25
|
import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
|
|
@@ -103,6 +104,13 @@ export interface MakeSubagentToolDeps {
|
|
|
103
104
|
* the tool falls back to setting ui.setWorkingMessage directly.
|
|
104
105
|
*/
|
|
105
106
|
workingLine?: WorkingLineHandle;
|
|
107
|
+
/**
|
|
108
|
+
* Session child-usage accumulator (childUsage.ts). Each completed task's
|
|
109
|
+
* folded usage is recorded so the footer's session totals include the
|
|
110
|
+
* child spend the per-task receipt already prints. Absent = not wired
|
|
111
|
+
* (headless/eval harnesses): no recording, nothing else changes.
|
|
112
|
+
*/
|
|
113
|
+
childUsage?: ChildUsageHandle;
|
|
106
114
|
}
|
|
107
115
|
export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
|
|
108
116
|
name: string;
|
|
@@ -120,7 +128,7 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
|
|
|
120
128
|
renderShell: "self";
|
|
121
129
|
renderCall: typeof renderSubagentCall;
|
|
122
130
|
renderResult: typeof renderSubagentResult;
|
|
123
|
-
execute(
|
|
131
|
+
execute(toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
|
|
124
132
|
content: Array<{
|
|
125
133
|
type: "text";
|
|
126
134
|
text: string;
|
|
@@ -151,6 +159,8 @@ export interface RegisterSubagentsDeps {
|
|
|
151
159
|
isUltra?: () => boolean;
|
|
152
160
|
/** Session working-line manager; see MakeSubagentToolDeps.workingLine. */
|
|
153
161
|
workingLine?: WorkingLineHandle;
|
|
162
|
+
/** Session child-usage accumulator; see MakeSubagentToolDeps.childUsage. */
|
|
163
|
+
childUsage?: ChildUsageHandle;
|
|
154
164
|
}
|
|
155
165
|
/** Wire the subagent tool and the /agents listing command. */
|
|
156
166
|
export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;
|
|
@@ -33,6 +33,17 @@ import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, p
|
|
|
33
33
|
* within the model proxy's 64-char caller-label limit.
|
|
34
34
|
*/
|
|
35
35
|
const SUBAGENT_CALLER_PREFIX = "subagent:";
|
|
36
|
+
/**
|
|
37
|
+
* Dedupe key for one task's childUsage record. Neither `startedAt` (parallel
|
|
38
|
+
* tasks on the same agent are minted in the same `resolved.map` pass and can
|
|
39
|
+
* share a millisecond) nor the per-invocation index (every invocation starts
|
|
40
|
+
* at 0) is unique alone; the pair is, because one tool call's task set is
|
|
41
|
+
* minted exactly once. A re-fired finalize for the same record lands on the
|
|
42
|
+
* same key, so the accumulator's (source, key) dedupe makes it a no-op.
|
|
43
|
+
*/
|
|
44
|
+
function progressKey(toolCallId, p, index) {
|
|
45
|
+
return `${toolCallId}/${index}/${p.agent}@${p.startedAt}`;
|
|
46
|
+
}
|
|
36
47
|
export const SUBAGENT_TOOL_NAME = "subagent";
|
|
37
48
|
export const GENERAL_AGENT_NAME = "general";
|
|
38
49
|
export const MAX_PARALLEL_SUBAGENTS = 4;
|
|
@@ -354,7 +365,7 @@ export function makeSubagentTool(deps = {}) {
|
|
|
354
365
|
renderShell: "self",
|
|
355
366
|
renderCall: renderSubagentCall,
|
|
356
367
|
renderResult: renderSubagentResult,
|
|
357
|
-
async execute(
|
|
368
|
+
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
358
369
|
const fail = (text) => ({
|
|
359
370
|
content: [{ type: "text", text }],
|
|
360
371
|
details: {},
|
|
@@ -428,6 +439,10 @@ export function makeSubagentTool(deps = {}) {
|
|
|
428
439
|
},
|
|
429
440
|
});
|
|
430
441
|
finalizeTask(progress, result, Date.now());
|
|
442
|
+
// Record the child's spend into the session accumulator so the
|
|
443
|
+
// footer totals include it (dedupe key: this progress record's
|
|
444
|
+
// identity — per invocation, so parallel tasks never collide).
|
|
445
|
+
deps.childUsage?.record("subagent", progressKey(toolCallId, progress, index), result.usage);
|
|
431
446
|
emit();
|
|
432
447
|
return { agent: def.name, task, result };
|
|
433
448
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -41,5 +41,5 @@
|
|
|
41
41
|
"turndown": "^7.2.4",
|
|
42
42
|
"typebox": "^1.3.15"
|
|
43
43
|
},
|
|
44
|
-
"yagniSourceSha": "
|
|
44
|
+
"yagniSourceSha": "431d2548899146f7522a21fed3488b204b4e77d0"
|
|
45
45
|
}
|