@yagni-app/code-staging 0.3.0-staging.1071.1 → 0.3.0-staging.1077.1
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/askYagniTool.js +49 -0
- package/dist/extension/config.d.ts +11 -1
- package/dist/extension/config.js +1 -1
- package/dist/extension/execPolicy.d.ts +8 -0
- package/dist/extension/execPolicy.js +56 -2
- package/dist/extension/guardian.d.ts +6 -0
- package/dist/extension/guardian.js +11 -0
- package/dist/extension/index.d.ts +4 -4
- package/dist/extension/index.js +13 -6
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +4 -7
- package/dist/extension/subagents.js +55 -32
- package/package.json +2 -2
|
@@ -1,10 +1,24 @@
|
|
|
1
|
+
import { Container, Spacer, Text } from "@earendil-works/pi-tui";
|
|
1
2
|
import { Type } from "typebox";
|
|
2
3
|
import { collectRepoDocs } from "./repoDocs.js";
|
|
3
4
|
import { friendlyFetchError, METERED_POST_FETCH_POLICY, resilientFetch } from "./resilientFetch.js";
|
|
5
|
+
import { markdownOrPlain } from "./subagentRender.js";
|
|
4
6
|
const parameters = Type.Object({
|
|
5
7
|
question: Type.String(),
|
|
6
8
|
context: Type.Optional(Type.String()),
|
|
7
9
|
});
|
|
10
|
+
/** Collapsed answer preview length, in lines. */
|
|
11
|
+
const ANSWER_PREVIEW_LINES = 4;
|
|
12
|
+
/** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
|
|
13
|
+
function clipLine(text, max) {
|
|
14
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
15
|
+
if (collapsed.length <= max)
|
|
16
|
+
return collapsed;
|
|
17
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
18
|
+
}
|
|
19
|
+
function citationCount(n) {
|
|
20
|
+
return n === 1 ? "1 citation" : `${n} citations`;
|
|
21
|
+
}
|
|
8
22
|
/**
|
|
9
23
|
* Build the `ask_yagni` tool definition.
|
|
10
24
|
*
|
|
@@ -28,6 +42,41 @@ export function makeAskYagniTool(opts) {
|
|
|
28
42
|
"When you use an answer, quote or reference its citations so the user can verify the source.",
|
|
29
43
|
],
|
|
30
44
|
parameters,
|
|
45
|
+
renderCall(args, theme) {
|
|
46
|
+
const t = theme;
|
|
47
|
+
let text = `${t.fg("toolTitle", t.bold("ask_yagni"))} ${t.fg("dim", clipLine(args?.question ?? "…", 100))}`;
|
|
48
|
+
if (args?.context)
|
|
49
|
+
text += t.fg("muted", " (+context)");
|
|
50
|
+
return new Text(text, 0, 0);
|
|
51
|
+
},
|
|
52
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
53
|
+
const t = theme;
|
|
54
|
+
const answer = result.content.find((c) => c.type === "text")?.text ?? "";
|
|
55
|
+
const citations = result.details?.citations ?? [];
|
|
56
|
+
if (isPartial)
|
|
57
|
+
return new Text(t.fg("muted", answer || "Asking YAGNI…"), 0, 0);
|
|
58
|
+
if (expanded) {
|
|
59
|
+
const container = new Container();
|
|
60
|
+
container.addChild(markdownOrPlain(answer || "(no answer)", t));
|
|
61
|
+
if (citations.length > 0) {
|
|
62
|
+
container.addChild(new Spacer(1));
|
|
63
|
+
for (const c of citations) {
|
|
64
|
+
container.addChild(new Text(` ${t.fg("muted", "•")} ${t.fg("accent", c.title)} ${t.fg("dim", c.url)}`, 0, 0));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return container;
|
|
68
|
+
}
|
|
69
|
+
const lines = answer.trim().split("\n");
|
|
70
|
+
const out = lines.slice(0, ANSWER_PREVIEW_LINES).map((l) => t.fg("toolOutput", l));
|
|
71
|
+
const meta = [];
|
|
72
|
+
if (citations.length > 0)
|
|
73
|
+
meta.push(citationCount(citations.length));
|
|
74
|
+
if (lines.length > ANSWER_PREVIEW_LINES || citations.length > 0)
|
|
75
|
+
meta.push("(ctrl+o to expand)");
|
|
76
|
+
if (meta.length > 0)
|
|
77
|
+
out.push(t.fg("muted", ` ${meta.join(" · ")}`));
|
|
78
|
+
return new Text(out.join("\n"), 0, 0);
|
|
79
|
+
},
|
|
31
80
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
32
81
|
onUpdate?.({ content: [{ type: "text", text: "Asking YAGNI…" }], details: { citations: [] } });
|
|
33
82
|
// Era-correct repo grounding: gather the working tree's most relevant docs
|
|
@@ -72,13 +72,23 @@ export interface FetchCatalogOptions {
|
|
|
72
72
|
getToken: () => string | undefined;
|
|
73
73
|
fetchImpl?: typeof fetch;
|
|
74
74
|
}
|
|
75
|
+
/** The startup catalog response from GET /api/yagni-code/models. */
|
|
76
|
+
export interface CatalogResult {
|
|
77
|
+
models: ModelEntry[];
|
|
78
|
+
/**
|
|
79
|
+
* Per-workspace Guardian kill switch (yagni_code.guardian). Fail-safe:
|
|
80
|
+
* only an explicit `false` from the backend disables the Guardian — a
|
|
81
|
+
* missing field (older backend) reads as enabled.
|
|
82
|
+
*/
|
|
83
|
+
guardianEnabled: boolean;
|
|
84
|
+
}
|
|
75
85
|
/**
|
|
76
86
|
* Fetch the YAGNI model catalog at startup.
|
|
77
87
|
*
|
|
78
88
|
* @throws an actionable Error (mentioning `yagni login`) on any non-2xx
|
|
79
89
|
* response so the launcher can surface a clear re-authentication prompt.
|
|
80
90
|
*/
|
|
81
|
-
export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<
|
|
91
|
+
export declare function fetchCatalog(opts: FetchCatalogOptions): Promise<CatalogResult>;
|
|
82
92
|
/** The startup company brief returned by GET /api/yagni-code/context. */
|
|
83
93
|
export interface ContextBrief {
|
|
84
94
|
brief: string;
|
package/dist/extension/config.js
CHANGED
|
@@ -85,7 +85,7 @@ export async function fetchCatalog(opts) {
|
|
|
85
85
|
throw new Error(`Failed to fetch YAGNI model catalog (HTTP ${res.status}). Run \`yagni login\` to re-authenticate.`);
|
|
86
86
|
}
|
|
87
87
|
const data = (await res.json());
|
|
88
|
-
return data.models;
|
|
88
|
+
return { models: data.models, guardianEnabled: data.guardianEnabled !== false };
|
|
89
89
|
}
|
|
90
90
|
/** Shape-check for a caller label: mirrors the model proxy's own validation regex. */
|
|
91
91
|
const CALLER_LABEL_RE = /^[a-z0-9][a-z0-9:_.-]{0,63}$/i;
|
|
@@ -39,6 +39,14 @@ export interface PrefixRule {
|
|
|
39
39
|
pattern: (string | string[])[];
|
|
40
40
|
decision: ExecDecision;
|
|
41
41
|
justification: string;
|
|
42
|
+
/**
|
|
43
|
+
* Escape hatch for allow rules whose command has a mutating flag: if any
|
|
44
|
+
* token AFTER the matched prefix equals one of these (or, for entries ending
|
|
45
|
+
* in "*", starts with the part before the star), the rule does NOT match and
|
|
46
|
+
* evaluation falls through to later rules (usually landing in the prompt
|
|
47
|
+
* band). Example: sed is read-only except with -i/--in-place.
|
|
48
|
+
*/
|
|
49
|
+
unlessTokens?: string[];
|
|
42
50
|
/** Positive test invocations (validated at load if present). */
|
|
43
51
|
match?: string[][];
|
|
44
52
|
/** Negative test invocations (validated at load if present). */
|
|
@@ -243,6 +243,17 @@ function matchRule(tokens, rule) {
|
|
|
243
243
|
return false;
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
|
+
if (rule.unlessTokens) {
|
|
247
|
+
for (const tok of tokens.slice(rule.pattern.length)) {
|
|
248
|
+
for (const unless of rule.unlessTokens) {
|
|
249
|
+
const matches = unless.endsWith("*")
|
|
250
|
+
? tok.startsWith(unless.slice(0, -1))
|
|
251
|
+
: tok === unless;
|
|
252
|
+
if (matches)
|
|
253
|
+
return false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
246
257
|
return true;
|
|
247
258
|
}
|
|
248
259
|
/** Classify a single command segment (no shell constructs). */
|
|
@@ -340,11 +351,43 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
340
351
|
{ pattern: ["true"], decision: "allow", justification: "no-op success" },
|
|
341
352
|
{ pattern: ["false"], decision: "allow", justification: "no-op failure" },
|
|
342
353
|
{ pattern: ["test"], decision: "allow", justification: "test condition" },
|
|
343
|
-
{
|
|
344
|
-
|
|
354
|
+
{
|
|
355
|
+
pattern: ["find"],
|
|
356
|
+
decision: "allow",
|
|
357
|
+
justification: "search for files (read-only without -delete/-exec)",
|
|
358
|
+
unlessTokens: ["-delete", "-exec", "-execdir", "-ok", "-okdir", "-fprint*", "-fls"],
|
|
359
|
+
},
|
|
345
360
|
{ pattern: ["grep"], decision: "allow", justification: "search text" },
|
|
346
361
|
{ pattern: ["rg"], decision: "allow", justification: "search text (ripgrep)" },
|
|
347
362
|
{ pattern: ["ag"], decision: "allow", justification: "search text (silver searcher)" },
|
|
363
|
+
{ pattern: ["cd"], decision: "allow", justification: "change directory (scoped to this bash invocation)" },
|
|
364
|
+
{
|
|
365
|
+
pattern: ["sed"],
|
|
366
|
+
decision: "allow",
|
|
367
|
+
justification: "stream-edit text to stdout (read-only without -i)",
|
|
368
|
+
unlessTokens: ["-i*", "--in-place*"],
|
|
369
|
+
},
|
|
370
|
+
{ pattern: ["awk"], decision: "allow", justification: "text processing to stdout" },
|
|
371
|
+
{ pattern: ["sort"], decision: "allow", justification: "sort lines" },
|
|
372
|
+
{ pattern: ["uniq"], decision: "allow", justification: "filter duplicate lines" },
|
|
373
|
+
{ pattern: ["cut"], decision: "allow", justification: "extract columns" },
|
|
374
|
+
{ pattern: ["tr"], decision: "allow", justification: "translate characters" },
|
|
375
|
+
{ pattern: ["diff"], decision: "allow", justification: "compare files" },
|
|
376
|
+
{ pattern: ["nl"], decision: "allow", justification: "number lines" },
|
|
377
|
+
{ pattern: ["jq"], decision: "allow", justification: "filter JSON to stdout" },
|
|
378
|
+
{ pattern: ["stat"], decision: "allow", justification: "show file metadata" },
|
|
379
|
+
{ pattern: ["file"], decision: "allow", justification: "identify file type" },
|
|
380
|
+
{ pattern: ["basename"], decision: "allow", justification: "strip directory from path" },
|
|
381
|
+
{ pattern: ["dirname"], decision: "allow", justification: "extract directory from path" },
|
|
382
|
+
{ pattern: ["realpath"], decision: "allow", justification: "resolve a path" },
|
|
383
|
+
{ pattern: ["readlink"], decision: "allow", justification: "resolve a symlink" },
|
|
384
|
+
{ pattern: ["tree"], decision: "allow", justification: "list directory tree" },
|
|
385
|
+
{ pattern: ["du"], decision: "allow", justification: "show disk usage" },
|
|
386
|
+
{ pattern: ["df"], decision: "allow", justification: "show filesystem usage" },
|
|
387
|
+
{ pattern: ["date"], decision: "allow", justification: "show date/time" },
|
|
388
|
+
{ pattern: ["printf"], decision: "allow", justification: "print formatted text" },
|
|
389
|
+
{ pattern: ["whoami"], decision: "allow", justification: "show current user" },
|
|
390
|
+
{ pattern: ["uname"], decision: "allow", justification: "show system info" },
|
|
348
391
|
{ pattern: ["git", "status"], decision: "allow", justification: "show working tree status" },
|
|
349
392
|
{ pattern: ["git", "log"], decision: "allow", justification: "show commit log" },
|
|
350
393
|
{ pattern: ["git", "diff"], decision: "allow", justification: "show changes" },
|
|
@@ -353,6 +396,17 @@ export const DEFAULT_EXEC_POLICY = {
|
|
|
353
396
|
{ pattern: ["git", "remote"], decision: "allow", justification: "list remotes" },
|
|
354
397
|
{ pattern: ["git", "rev-parse"], decision: "allow", justification: "resolve git refs" },
|
|
355
398
|
{ pattern: ["git", "worktree", "list"], decision: "allow", justification: "list worktrees" },
|
|
399
|
+
{ pattern: ["git", "blame"], decision: "allow", justification: "show line authorship" },
|
|
400
|
+
{ pattern: ["git", "grep"], decision: "allow", justification: "search tracked files" },
|
|
401
|
+
{ pattern: ["git", "ls-files"], decision: "allow", justification: "list tracked files" },
|
|
402
|
+
{ pattern: ["git", "describe"], decision: "allow", justification: "describe a commit" },
|
|
403
|
+
{ pattern: ["git", "shortlog"], decision: "allow", justification: "summarize commit log" },
|
|
404
|
+
{ pattern: ["git", "stash", "list"], decision: "allow", justification: "list stashes" },
|
|
405
|
+
{ pattern: ["gh", "pr", ["view", "list", "diff", "checks", "status"]], decision: "allow", justification: "read pull request data" },
|
|
406
|
+
{ pattern: ["gh", "issue", ["view", "list", "status"]], decision: "allow", justification: "read issue data" },
|
|
407
|
+
{ pattern: ["gh", "run", ["view", "list"]], decision: "allow", justification: "read workflow run data" },
|
|
408
|
+
{ pattern: ["gh", "repo", "view"], decision: "allow", justification: "read repository data" },
|
|
409
|
+
{ pattern: ["gh", "search"], decision: "allow", justification: "search GitHub" },
|
|
356
410
|
{ pattern: ["node", "--version"], decision: "allow", justification: "check node version" },
|
|
357
411
|
{ pattern: ["node", "-v"], decision: "allow", justification: "check node version" },
|
|
358
412
|
{ pattern: ["npm", "ls"], decision: "allow", justification: "list installed packages" },
|
|
@@ -37,6 +37,12 @@ export interface GuardianLimits {
|
|
|
37
37
|
timeoutMs: number;
|
|
38
38
|
}
|
|
39
39
|
export declare const DEFAULT_GUARDIAN_LIMITS: GuardianLimits;
|
|
40
|
+
/**
|
|
41
|
+
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
42
|
+
* overrides the session review cap; anything non-numeric or < 1 falls back to
|
|
43
|
+
* the default (a bad value must never zero out the cap and lock the session).
|
|
44
|
+
*/
|
|
45
|
+
export declare function resolveGuardianLimits(env?: Record<string, string | undefined>): GuardianLimits;
|
|
40
46
|
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
41
47
|
export declare const GUARDIAN_MODEL_TIER = "efficient";
|
|
42
48
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
@@ -25,6 +25,17 @@ export const DEFAULT_GUARDIAN_LIMITS = {
|
|
|
25
25
|
maxConsecutiveDenials: 3,
|
|
26
26
|
timeoutMs: 15_000,
|
|
27
27
|
};
|
|
28
|
+
/**
|
|
29
|
+
* Resolve Guardian limits from the environment. `YAGNI_GUARDIAN_MAX_REVIEWS`
|
|
30
|
+
* overrides the session review cap; anything non-numeric or < 1 falls back to
|
|
31
|
+
* the default (a bad value must never zero out the cap and lock the session).
|
|
32
|
+
*/
|
|
33
|
+
export function resolveGuardianLimits(env = process.env) {
|
|
34
|
+
const raw = env.YAGNI_GUARDIAN_MAX_REVIEWS?.trim();
|
|
35
|
+
const parsed = raw ? Number.parseInt(raw, 10) : NaN;
|
|
36
|
+
const maxReviews = Number.isFinite(parsed) && parsed >= 1 ? parsed : DEFAULT_GUARDIAN_LIMITS.maxReviews;
|
|
37
|
+
return { ...DEFAULT_GUARDIAN_LIMITS, maxReviews };
|
|
38
|
+
}
|
|
28
39
|
/** The model tier the Guardian runs on. Configurable via YAGNI_GUARDIAN_TIER. */
|
|
29
40
|
export const GUARDIAN_MODEL_TIER = "efficient";
|
|
30
41
|
/** Read-only tools — the Guardian can read files for context but cannot write or execute. */
|
|
@@ -4,7 +4,7 @@ import { runInitPass as defaultRunInitPass } from "./initPass.js";
|
|
|
4
4
|
import { fetchMcpServers as defaultFetchMcpServers } from "./mcpTools.js";
|
|
5
5
|
import { type FlushOutcome, type SpoolClientOpts } from "./spool.js";
|
|
6
6
|
import { type TokenProvider } from "./tokenProvider.js";
|
|
7
|
-
import { type
|
|
7
|
+
import { type CatalogResult, type ContextBrief } from "./config.js";
|
|
8
8
|
/**
|
|
9
9
|
* YAGNI Code extension entry point.
|
|
10
10
|
*
|
|
@@ -31,7 +31,7 @@ export interface RegisterYagniDeps {
|
|
|
31
31
|
baseUrl: string;
|
|
32
32
|
getToken: () => string | undefined;
|
|
33
33
|
fetchImpl?: typeof fetch;
|
|
34
|
-
}) => Promise<
|
|
34
|
+
}) => Promise<CatalogResult>;
|
|
35
35
|
fetchContextBrief?: (opts: {
|
|
36
36
|
baseUrl: string;
|
|
37
37
|
getToken: () => string | undefined;
|
|
@@ -106,7 +106,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
|
|
|
106
106
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
107
107
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
108
108
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
109
|
-
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, reviewCommand, } from "./guardian.js";
|
|
109
|
+
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
|
|
110
110
|
export type { GuardianOutcome, GuardianVerdict, GuardianState, GuardianStateHandle, GuardianLimits, ReviewResult, ReviewCommandDeps, } from "./guardian.js";
|
|
111
111
|
export type { Citation, MakeAskYagniToolOptions } from "./askYagniTool.js";
|
|
112
112
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
@@ -124,7 +124,7 @@ export type { RunInitPassDeps, InitPassOutcome, RunTeamSetupDeps, TeamSetupOutco
|
|
|
124
124
|
export { isInitDone, markInitDone, initDoneMarkerFile, _setInitDoneHomeForTest } from "./initDone.js";
|
|
125
125
|
export { brandSystemPrompt, YAGNI_IDENTITY, YAGNI_IDENTITY_DRIVER, BRAND_NAME } from "./branding.js";
|
|
126
126
|
export { attributionHeaders, isDriverCaller, fetchCatalog, getToken, getWorkspaceId, resolveBaseUrl, sanitizeCallerSegment, } from "./config.js";
|
|
127
|
-
export type { FetchCatalogOptions, ModelEntry } from "./config.js";
|
|
127
|
+
export type { CatalogResult, FetchCatalogOptions, ModelEntry } from "./config.js";
|
|
128
128
|
export { buildYagniProvider } from "./provider.js";
|
|
129
129
|
export { registerGoCommand } from "./pipeline/goCommand.js";
|
|
130
130
|
export type { RegisterGoDeps } from "./pipeline/goCommand.js";
|
package/dist/extension/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { appendFileSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { DEFAULT_ADVISOR_LIMITS, formatAdvisorSubtotal, makeAdvisorState } from "./advisor.js";
|
|
5
|
-
import {
|
|
5
|
+
import { formatGuardianSubtotal, GUARDIAN_MODEL_TIER, makeGuardianState, resolveGuardianLimits, reviewCommand } from "./guardian.js";
|
|
6
6
|
import { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
7
7
|
import { makeAskYagniTool } from "./askYagniTool.js";
|
|
8
8
|
import { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
@@ -127,7 +127,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
127
127
|
// after_provider_response event does NOT fire on a 401 (the OpenAI SDK throws
|
|
128
128
|
// before onResponse is reached), so message_end is the only seam.
|
|
129
129
|
let lastAuthRecovery = null;
|
|
130
|
-
const fullCatalog = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
|
|
130
|
+
const { models: fullCatalog, guardianEnabled: workspaceGuardianEnabled } = await fetchCatalog({ baseUrl, getToken: getTokenFn, fetchImpl: authedFetch });
|
|
131
131
|
// Lock the interactive session to the `advanced` tier only. The backend
|
|
132
132
|
// catalog returns all tiers, but only `advanced` is registered with the
|
|
133
133
|
// `yagni` provider, so /model and Ctrl+P show a single entry. Child
|
|
@@ -236,12 +236,19 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
236
236
|
// holds them, review mode confirms them.
|
|
237
237
|
const modeHolder = createModeHolder();
|
|
238
238
|
const guardianState = makeGuardianState();
|
|
239
|
-
|
|
239
|
+
// Disabled by the local env override OR the workspace kill switch
|
|
240
|
+
// (yagni_code.guardian, read from the catalog response at launch). The env
|
|
241
|
+
// var wins for a single developer's debugging; the flag turns it off for
|
|
242
|
+
// every session in the workspace.
|
|
243
|
+
const guardianDisabled = env.YAGNI_DISABLE_GUARDIAN === "1" ||
|
|
244
|
+
env.YAGNI_DISABLE_GUARDIAN === "true" ||
|
|
245
|
+
!workspaceGuardianEnabled;
|
|
240
246
|
const guardianTier = env.YAGNI_GUARDIAN_TIER ?? GUARDIAN_MODEL_TIER;
|
|
247
|
+
const guardianLimits = resolveGuardianLimits(env);
|
|
241
248
|
registerPermissionGate(pi, {
|
|
242
249
|
modeHolder,
|
|
243
250
|
guardianState,
|
|
244
|
-
guardianLimits
|
|
251
|
+
guardianLimits,
|
|
245
252
|
guardianTier,
|
|
246
253
|
guardianDisabled,
|
|
247
254
|
guardianReview: (command, deps) => reviewCommand(command, { ...deps, modelTier: guardianTier }),
|
|
@@ -292,7 +299,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
292
299
|
// spend as an ordinary caller row.
|
|
293
300
|
advisorSubtotal: () => {
|
|
294
301
|
const advisor = formatAdvisorSubtotal(advisorState.read(), DEFAULT_ADVISOR_LIMITS);
|
|
295
|
-
const guardian = formatGuardianSubtotal(guardianState.read(),
|
|
302
|
+
const guardian = formatGuardianSubtotal(guardianState.read(), guardianLimits);
|
|
296
303
|
return [advisor, guardian].filter(Boolean).join(" ");
|
|
297
304
|
},
|
|
298
305
|
fetchHeadroom: async (signal) => {
|
|
@@ -674,7 +681,7 @@ export { makeAskYagniTool } from "./askYagniTool.js";
|
|
|
674
681
|
export { makeFileTicketTool, makeUpdateTicketStatusTool } from "./ticketTools.js";
|
|
675
682
|
export { makeAskAdvisorTool, registerAdviseCommand } from "./askAdvisorTool.js";
|
|
676
683
|
export { ADVISOR_TIER, DEFAULT_ADVISOR_LIMITS, decideConsult, formatAdvisorSubtotal, makeAdvisorState, } from "./advisor.js";
|
|
677
|
-
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, reviewCommand, } from "./guardian.js";
|
|
684
|
+
export { DEFAULT_GUARDIAN_LIMITS, GUARDIAN_MODEL_TIER, formatGuardianSubtotal, makeGuardianState, resolveGuardianLimits, reviewCommand, } from "./guardian.js";
|
|
678
685
|
export { makeReviewBusinessMatchTool } from "./reviewTool.js";
|
|
679
686
|
export { makeRecordEngineeringContextTool } from "./recordContextTool.js";
|
|
680
687
|
export { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live progress model + TUI renderers for the `subagent` tool.
|
|
3
|
+
*
|
|
4
|
+
* The model half is PURE (mirrors `pipeline/activity.ts`): `applyChildEvent`
|
|
5
|
+
* folds one NDJSON event from a child into a bounded per-task progress record,
|
|
6
|
+
* and `finalizeTask` stamps the outcome from the runner's `StageResult`. The
|
|
7
|
+
* tool carries the folded records in its `details`, so every partial update the
|
|
8
|
+
* TUI sees is a complete picture of all tasks.
|
|
9
|
+
*
|
|
10
|
+
* The renderer half implements pi's per-tool rendering seam (`renderCall` /
|
|
11
|
+
* `renderResult`). Collapsed-while-running is the two-line pattern: a stable
|
|
12
|
+
* `agent — task` title over a churning `↳ current tool` line. Completion is a
|
|
13
|
+
* one-line receipt (`✓ agent · N tool uses · Xk tokens · Ys`); expanded shows
|
|
14
|
+
* the curated action log and the full report as markdown. String assembly is
|
|
15
|
+
* kept in pure helpers over a minimal {@link RenderTheme} so tests run against
|
|
16
|
+
* plain text — renderer exceptions are swallowed by pi (silently degrading to
|
|
17
|
+
* the bare title bar), so everything here must stay boringly total.
|
|
18
|
+
*/
|
|
19
|
+
import { type Component } from "@earendil-works/pi-tui";
|
|
20
|
+
import type { JsonEvent, StageResult, StageUsage } from "./pipeline/types.js";
|
|
21
|
+
/** The minimal slice of pi's `Theme` the renderers style with (same shape as FeedTheme). */
|
|
22
|
+
export interface RenderTheme {
|
|
23
|
+
bold(text: string): string;
|
|
24
|
+
fg(color: string, text: string): string;
|
|
25
|
+
}
|
|
26
|
+
/** One curated line of a child's activity (tool action or narration headline). */
|
|
27
|
+
export interface SubagentActionEntry {
|
|
28
|
+
kind: "action" | "narration";
|
|
29
|
+
text: string;
|
|
30
|
+
state: "running" | "done" | "error";
|
|
31
|
+
/** Resolves a running tool start against its end event. */
|
|
32
|
+
toolCallId?: string;
|
|
33
|
+
}
|
|
34
|
+
/** Live/final progress of one subagent task; rides the tool's `details`. */
|
|
35
|
+
export interface SubagentTaskProgress {
|
|
36
|
+
agent: string;
|
|
37
|
+
task: string;
|
|
38
|
+
status: "running" | "done" | "error";
|
|
39
|
+
/** -1 while the child is still running (mirrors the pi subagent example). */
|
|
40
|
+
exitCode: number;
|
|
41
|
+
startedAt: number;
|
|
42
|
+
endedAt?: number;
|
|
43
|
+
toolCalls: number;
|
|
44
|
+
toolErrors: number;
|
|
45
|
+
usage: StageUsage;
|
|
46
|
+
/** Bounded curated log; oldest entries are dropped past ACTION_LOG_MAX. */
|
|
47
|
+
actions: SubagentActionEntry[];
|
|
48
|
+
droppedActions: number;
|
|
49
|
+
/** The child's final report, present once the task resolved. */
|
|
50
|
+
report?: string;
|
|
51
|
+
stopReason?: string;
|
|
52
|
+
errorMessage?: string;
|
|
53
|
+
}
|
|
54
|
+
export interface SubagentDetails {
|
|
55
|
+
tasks: SubagentTaskProgress[];
|
|
56
|
+
}
|
|
57
|
+
/** Bound on the retained action log so a chatty child cannot grow details unbounded. */
|
|
58
|
+
export declare const ACTION_LOG_MAX = 120;
|
|
59
|
+
export declare function newTaskProgress(agent: string, task: string, startedAt: number): SubagentTaskProgress;
|
|
60
|
+
/**
|
|
61
|
+
* Fold one child NDJSON event into the task's progress. Returns true when the
|
|
62
|
+
* record changed (the tool emits an update), false for events we drop.
|
|
63
|
+
*/
|
|
64
|
+
export declare function applyChildEvent(p: SubagentTaskProgress, ev: JsonEvent): boolean;
|
|
65
|
+
/** Stamp the runner's outcome onto the progress record. */
|
|
66
|
+
export declare function finalizeTask(p: SubagentTaskProgress, result: StageResult, endedAt: number): void;
|
|
67
|
+
/** 532 → "532", 41_234 → "41.2k", 1_240_000 → "1.2M". */
|
|
68
|
+
export declare function formatTokens(n: number): string;
|
|
69
|
+
/** 42_000 → "42s", 81_000 → "1m 21s", 3_720_000 → "1h 2m". */
|
|
70
|
+
export declare function formatDuration(ms: number): string;
|
|
71
|
+
/**
|
|
72
|
+
* The two-line live status for one running task: a stable `agent — task` title
|
|
73
|
+
* over the churning current-action line with elapsed time and live tokens.
|
|
74
|
+
*/
|
|
75
|
+
export declare function runningLines(p: SubagentTaskProgress, theme: RenderTheme, now: number, frame: string): string[];
|
|
76
|
+
/** One-line completion receipt: `✓ agent · N tool uses · Xk tokens · Ys · $c`. */
|
|
77
|
+
export declare function receiptLine(p: SubagentTaskProgress, theme: RenderTheme): string;
|
|
78
|
+
/**
|
|
79
|
+
* Plain-text (no theme) summary for the partial result's `content`, so headless
|
|
80
|
+
* consumers and pi's fallback renderer still see live progress.
|
|
81
|
+
*/
|
|
82
|
+
export declare function progressSummaryText(tasks: SubagentTaskProgress[], now: number): string;
|
|
83
|
+
/** The harness "Working…" replacement while subagents run. */
|
|
84
|
+
export declare function formatWorkingMessage(tasks: SubagentTaskProgress[], now: number): string;
|
|
85
|
+
/** The subagent tool's argument shape, partial while the model streams it. */
|
|
86
|
+
interface SubagentCallArgs {
|
|
87
|
+
task?: string;
|
|
88
|
+
agent?: string;
|
|
89
|
+
tasks?: Array<{
|
|
90
|
+
task?: string;
|
|
91
|
+
agent?: string;
|
|
92
|
+
}>;
|
|
93
|
+
}
|
|
94
|
+
/** Renderer-row state shared across renders of one tool call (context.state). */
|
|
95
|
+
interface LiveRenderState {
|
|
96
|
+
timer?: ReturnType<typeof setInterval>;
|
|
97
|
+
}
|
|
98
|
+
interface RenderContextSlice {
|
|
99
|
+
state?: LiveRenderState;
|
|
100
|
+
invalidate: () => void;
|
|
101
|
+
}
|
|
102
|
+
/** Title painted the moment the call streams in (before any execution output). */
|
|
103
|
+
export declare function renderSubagentCall(args: SubagentCallArgs | undefined, theme: RenderTheme, _context: unknown): Component;
|
|
104
|
+
/**
|
|
105
|
+
* A prose body as markdown when the TUI's markdown theme is available.
|
|
106
|
+
* `getMarkdownTheme()` hands back a lazy proxy that only throws when a style is
|
|
107
|
+
* first USED, so the fallback must wrap `render`, not construction — otherwise
|
|
108
|
+
* an uninitialized theme would blow up mid-paint and pi would silently degrade
|
|
109
|
+
* the whole row to the bare title bar.
|
|
110
|
+
*/
|
|
111
|
+
export declare function markdownOrPlain(body: string, theme: RenderTheme): Component;
|
|
112
|
+
/**
|
|
113
|
+
* Result renderer: live two-line status per task while partial; receipts plus
|
|
114
|
+
* report preview when collapsed; action log plus full markdown report when
|
|
115
|
+
* expanded. Drives its own refresh while running via an unref'd interval on
|
|
116
|
+
* `context.state` (pi has no unmount hook — the final render clears it).
|
|
117
|
+
*/
|
|
118
|
+
export declare function renderSubagentResult(result: {
|
|
119
|
+
content: Array<{
|
|
120
|
+
type: string;
|
|
121
|
+
text?: string;
|
|
122
|
+
}>;
|
|
123
|
+
details?: unknown;
|
|
124
|
+
}, options: {
|
|
125
|
+
expanded: boolean;
|
|
126
|
+
isPartial: boolean;
|
|
127
|
+
}, theme: RenderTheme, context: RenderContextSlice): Component;
|
|
128
|
+
export {};
|
|
129
|
+
//# sourceMappingURL=subagentRender.d.ts.map
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live progress model + TUI renderers for the `subagent` tool.
|
|
3
|
+
*
|
|
4
|
+
* The model half is PURE (mirrors `pipeline/activity.ts`): `applyChildEvent`
|
|
5
|
+
* folds one NDJSON event from a child into a bounded per-task progress record,
|
|
6
|
+
* and `finalizeTask` stamps the outcome from the runner's `StageResult`. The
|
|
7
|
+
* tool carries the folded records in its `details`, so every partial update the
|
|
8
|
+
* TUI sees is a complete picture of all tasks.
|
|
9
|
+
*
|
|
10
|
+
* The renderer half implements pi's per-tool rendering seam (`renderCall` /
|
|
11
|
+
* `renderResult`). Collapsed-while-running is the two-line pattern: a stable
|
|
12
|
+
* `agent — task` title over a churning `↳ current tool` line. Completion is a
|
|
13
|
+
* one-line receipt (`✓ agent · N tool uses · Xk tokens · Ys`); expanded shows
|
|
14
|
+
* the curated action log and the full report as markdown. String assembly is
|
|
15
|
+
* kept in pure helpers over a minimal {@link RenderTheme} so tests run against
|
|
16
|
+
* plain text — renderer exceptions are swallowed by pi (silently degrading to
|
|
17
|
+
* the bare title bar), so everything here must stay boringly total.
|
|
18
|
+
*/
|
|
19
|
+
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
21
|
+
import { narrationHeadline, toolLabel } from "./pipeline/activity.js";
|
|
22
|
+
import { SPINNER_FRAMES } from "./pipeline/activityFeed.js";
|
|
23
|
+
/** Bound on the retained action log so a chatty child cannot grow details unbounded. */
|
|
24
|
+
export const ACTION_LOG_MAX = 120;
|
|
25
|
+
/** How many of the newest actions the expanded view paints. */
|
|
26
|
+
const EXPANDED_ACTIONS_SHOWN = 30;
|
|
27
|
+
/** Collapsed report preview length, in lines. */
|
|
28
|
+
const REPORT_PREVIEW_LINES = 3;
|
|
29
|
+
/** Task preview width on the running title line. */
|
|
30
|
+
const TASK_PREVIEW_MAX = 64;
|
|
31
|
+
/** Spinner cadence; matches the /go feed's SPINNER_TICK_MS. */
|
|
32
|
+
const SPINNER_TICK_MS = 120;
|
|
33
|
+
const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
|
|
34
|
+
export function newTaskProgress(agent, task, startedAt) {
|
|
35
|
+
return {
|
|
36
|
+
agent,
|
|
37
|
+
task,
|
|
38
|
+
status: "running",
|
|
39
|
+
exitCode: -1,
|
|
40
|
+
startedAt,
|
|
41
|
+
toolCalls: 0,
|
|
42
|
+
toolErrors: 0,
|
|
43
|
+
usage: { ...EMPTY_USAGE },
|
|
44
|
+
actions: [],
|
|
45
|
+
droppedActions: 0,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function pushAction(p, entry) {
|
|
49
|
+
if (p.actions.length >= ACTION_LOG_MAX) {
|
|
50
|
+
p.actions.shift();
|
|
51
|
+
p.droppedActions += 1;
|
|
52
|
+
}
|
|
53
|
+
p.actions.push(entry);
|
|
54
|
+
}
|
|
55
|
+
/** Last text part of an assistant message, or undefined. */
|
|
56
|
+
function lastMessageText(ev) {
|
|
57
|
+
const parts = ev.message?.content ?? [];
|
|
58
|
+
for (let i = parts.length - 1; i >= 0; i--) {
|
|
59
|
+
const part = parts[i];
|
|
60
|
+
if (part?.type === "text" && typeof part.text === "string")
|
|
61
|
+
return part.text;
|
|
62
|
+
}
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Fold one child NDJSON event into the task's progress. Returns true when the
|
|
67
|
+
* record changed (the tool emits an update), false for events we drop.
|
|
68
|
+
*/
|
|
69
|
+
export function applyChildEvent(p, ev) {
|
|
70
|
+
switch (ev.type) {
|
|
71
|
+
case "tool_execution_start": {
|
|
72
|
+
const entry = {
|
|
73
|
+
kind: "action",
|
|
74
|
+
text: toolLabel(ev.toolName ?? "", ev.args),
|
|
75
|
+
state: "running",
|
|
76
|
+
};
|
|
77
|
+
if (ev.toolCallId !== undefined)
|
|
78
|
+
entry.toolCallId = ev.toolCallId;
|
|
79
|
+
pushAction(p, entry);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
case "tool_execution_end": {
|
|
83
|
+
p.toolCalls += 1;
|
|
84
|
+
if (ev.isError)
|
|
85
|
+
p.toolErrors += 1;
|
|
86
|
+
for (let i = p.actions.length - 1; i >= 0; i--) {
|
|
87
|
+
const a = p.actions[i];
|
|
88
|
+
if (a.kind !== "action" || a.state !== "running")
|
|
89
|
+
continue;
|
|
90
|
+
if (ev.toolCallId !== undefined && a.toolCallId !== ev.toolCallId)
|
|
91
|
+
continue;
|
|
92
|
+
a.state = ev.isError ? "error" : "done";
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
case "message_end": {
|
|
98
|
+
const msg = ev.message;
|
|
99
|
+
if (msg?.role !== "assistant")
|
|
100
|
+
return false;
|
|
101
|
+
p.usage.turns += 1;
|
|
102
|
+
const u = msg.usage;
|
|
103
|
+
if (u) {
|
|
104
|
+
p.usage.input += u.input ?? 0;
|
|
105
|
+
p.usage.output += u.output ?? 0;
|
|
106
|
+
p.usage.cacheRead += u.cacheRead ?? 0;
|
|
107
|
+
p.usage.cacheWrite += u.cacheWrite ?? 0;
|
|
108
|
+
p.usage.cost += u.cost?.total ?? 0;
|
|
109
|
+
}
|
|
110
|
+
const text = lastMessageText(ev);
|
|
111
|
+
const headline = text !== undefined ? narrationHeadline(text) : null;
|
|
112
|
+
if (headline)
|
|
113
|
+
pushAction(p, { kind: "narration", text: headline, state: "done" });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
default:
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
/** Stamp the runner's outcome onto the progress record. */
|
|
121
|
+
export function finalizeTask(p, result, endedAt) {
|
|
122
|
+
// An aborted child can close with exit 0 (SIGTERM close reports a null code),
|
|
123
|
+
// so the stop reason outranks the exit code for the status glyph.
|
|
124
|
+
p.status = result.exitCode === 0 && result.stopReason !== "aborted" ? "done" : "error";
|
|
125
|
+
p.exitCode = result.exitCode;
|
|
126
|
+
p.endedAt = endedAt;
|
|
127
|
+
p.usage = { ...result.usage };
|
|
128
|
+
p.toolCalls = result.toolCalls;
|
|
129
|
+
p.toolErrors = result.toolErrors;
|
|
130
|
+
p.report = result.finalOutput;
|
|
131
|
+
if (result.stopReason)
|
|
132
|
+
p.stopReason = result.stopReason;
|
|
133
|
+
if (result.errorMessage)
|
|
134
|
+
p.errorMessage = result.errorMessage;
|
|
135
|
+
}
|
|
136
|
+
// ── Formatting helpers ────────────────────────────────────────────────────────
|
|
137
|
+
/** Collapse whitespace and clip to `max`, appending an ellipsis when cut. */
|
|
138
|
+
function clip(text, max) {
|
|
139
|
+
const collapsed = text.replace(/\s+/g, " ").trim();
|
|
140
|
+
if (collapsed.length <= max)
|
|
141
|
+
return collapsed;
|
|
142
|
+
return `${collapsed.slice(0, max - 1)}…`;
|
|
143
|
+
}
|
|
144
|
+
function trimTrailingZero(v) {
|
|
145
|
+
const s = v.toFixed(1);
|
|
146
|
+
return s.endsWith(".0") ? s.slice(0, -2) : s;
|
|
147
|
+
}
|
|
148
|
+
/** 532 → "532", 41_234 → "41.2k", 1_240_000 → "1.2M". */
|
|
149
|
+
export function formatTokens(n) {
|
|
150
|
+
if (n < 1000)
|
|
151
|
+
return String(n);
|
|
152
|
+
if (n < 1_000_000)
|
|
153
|
+
return `${trimTrailingZero(n / 1000)}k`;
|
|
154
|
+
return `${trimTrailingZero(n / 1_000_000)}M`;
|
|
155
|
+
}
|
|
156
|
+
/** 42_000 → "42s", 81_000 → "1m 21s", 3_720_000 → "1h 2m". */
|
|
157
|
+
export function formatDuration(ms) {
|
|
158
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
159
|
+
if (s < 60)
|
|
160
|
+
return `${s}s`;
|
|
161
|
+
const m = Math.floor(s / 60);
|
|
162
|
+
if (m < 60)
|
|
163
|
+
return `${m}m ${s % 60}s`;
|
|
164
|
+
return `${Math.floor(m / 60)}h ${m % 60}m`;
|
|
165
|
+
}
|
|
166
|
+
function countToolUses(n) {
|
|
167
|
+
return n === 1 ? "1 tool use" : `${n} tool uses`;
|
|
168
|
+
}
|
|
169
|
+
/** Tokens the child actually generated/consumed (cache reads excluded). */
|
|
170
|
+
function taskTokens(p) {
|
|
171
|
+
return p.usage.input + p.usage.output;
|
|
172
|
+
}
|
|
173
|
+
/** The most recent still-running tool action, if any. */
|
|
174
|
+
function currentActionText(p) {
|
|
175
|
+
for (let i = p.actions.length - 1; i >= 0; i--) {
|
|
176
|
+
const a = p.actions[i];
|
|
177
|
+
if (a.kind === "action" && a.state === "running")
|
|
178
|
+
return a.text;
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
}
|
|
182
|
+
/** What the child is doing right now: current tool → tool-use count → starting. */
|
|
183
|
+
function liveDetailText(p) {
|
|
184
|
+
return currentActionText(p) ?? (p.toolCalls > 0 ? countToolUses(p.toolCalls) : "starting…");
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The two-line live status for one running task: a stable `agent — task` title
|
|
188
|
+
* over the churning current-action line with elapsed time and live tokens.
|
|
189
|
+
*/
|
|
190
|
+
export function runningLines(p, theme, now, frame) {
|
|
191
|
+
const title = `${frame} ${theme.fg("accent", p.agent)} — ${theme.fg("dim", clip(p.task, TASK_PREVIEW_MAX))}`;
|
|
192
|
+
let detail = ` ${theme.fg("muted", "↳")} ${theme.fg("toolOutput", liveDetailText(p))}`;
|
|
193
|
+
detail += theme.fg("dim", ` · ${formatDuration(now - p.startedAt)}`);
|
|
194
|
+
const tokens = taskTokens(p);
|
|
195
|
+
if (tokens > 0)
|
|
196
|
+
detail += theme.fg("dim", ` · ${formatTokens(tokens)} tokens`);
|
|
197
|
+
return [title, detail];
|
|
198
|
+
}
|
|
199
|
+
/** One-line completion receipt: `✓ agent · N tool uses · Xk tokens · Ys · $c`. */
|
|
200
|
+
export function receiptLine(p, theme) {
|
|
201
|
+
const parts = [
|
|
202
|
+
countToolUses(p.toolCalls),
|
|
203
|
+
`${formatTokens(taskTokens(p))} tokens`,
|
|
204
|
+
formatDuration((p.endedAt ?? p.startedAt) - p.startedAt),
|
|
205
|
+
];
|
|
206
|
+
if (p.toolErrors > 0)
|
|
207
|
+
parts.push(`${p.toolErrors} tool ${p.toolErrors === 1 ? "error" : "errors"}`);
|
|
208
|
+
if (p.usage.cost > 0)
|
|
209
|
+
parts.push(`$${p.usage.cost.toFixed(2)}`);
|
|
210
|
+
const name = theme.bold(theme.fg("accent", p.agent));
|
|
211
|
+
if (p.status === "error") {
|
|
212
|
+
const reason = p.stopReason ?? (p.exitCode >= 0 ? `exit ${p.exitCode}` : "failed");
|
|
213
|
+
return `${theme.fg("error", "✗")} ${name} ${theme.fg("error", `failed (${reason})`)} ${theme.fg("dim", `· ${parts.join(" · ")}`)}`;
|
|
214
|
+
}
|
|
215
|
+
return `${theme.fg("success", "✓")} ${name} ${theme.fg("dim", `· ${parts.join(" · ")}`)}`;
|
|
216
|
+
}
|
|
217
|
+
/** Aggregate receipt across parallel tasks. */
|
|
218
|
+
function totalLine(tasks, theme) {
|
|
219
|
+
const toolCalls = tasks.reduce((n, p) => n + p.toolCalls, 0);
|
|
220
|
+
const tokens = tasks.reduce((n, p) => n + taskTokens(p), 0);
|
|
221
|
+
const cost = tasks.reduce((n, p) => n + p.usage.cost, 0);
|
|
222
|
+
const start = Math.min(...tasks.map((p) => p.startedAt));
|
|
223
|
+
const end = Math.max(...tasks.map((p) => p.endedAt ?? p.startedAt));
|
|
224
|
+
const parts = [countToolUses(toolCalls), `${formatTokens(tokens)} tokens`, formatDuration(end - start)];
|
|
225
|
+
if (cost > 0)
|
|
226
|
+
parts.push(`$${cost.toFixed(2)}`);
|
|
227
|
+
return theme.fg("dim", `${tasks.length} subagents · ${parts.join(" · ")}`);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Plain-text (no theme) summary for the partial result's `content`, so headless
|
|
231
|
+
* consumers and pi's fallback renderer still see live progress.
|
|
232
|
+
*/
|
|
233
|
+
export function progressSummaryText(tasks, now) {
|
|
234
|
+
const plain = { bold: (s) => s, fg: (_c, s) => s };
|
|
235
|
+
return tasks
|
|
236
|
+
.map((p) => p.status === "running"
|
|
237
|
+
? `${p.agent}: ${liveDetailText(p)} · ${formatDuration(now - p.startedAt)}`
|
|
238
|
+
: receiptLine(p, plain))
|
|
239
|
+
.join("\n");
|
|
240
|
+
}
|
|
241
|
+
/** The harness "Working…" replacement while subagents run. */
|
|
242
|
+
export function formatWorkingMessage(tasks, now) {
|
|
243
|
+
const toolCalls = tasks.reduce((n, p) => n + p.toolCalls, 0);
|
|
244
|
+
const started = Math.min(...tasks.map((p) => p.startedAt));
|
|
245
|
+
const elapsed = formatDuration(now - started);
|
|
246
|
+
if (tasks.length === 1) {
|
|
247
|
+
return `subagent ${tasks[0].agent} · ${countToolUses(toolCalls)} · ${elapsed}`;
|
|
248
|
+
}
|
|
249
|
+
const running = tasks.filter((p) => p.status === "running").length;
|
|
250
|
+
const label = running > 0 && running < tasks.length ? `${running}/${tasks.length} subagents` : `${tasks.length} subagents`;
|
|
251
|
+
return `${label} · ${countToolUses(toolCalls)} · ${elapsed}`;
|
|
252
|
+
}
|
|
253
|
+
/** Title painted the moment the call streams in (before any execution output). */
|
|
254
|
+
export function renderSubagentCall(args, theme, _context) {
|
|
255
|
+
const title = theme.fg("toolTitle", theme.bold("subagent"));
|
|
256
|
+
const a = args ?? {};
|
|
257
|
+
if (a.tasks && a.tasks.length > 0) {
|
|
258
|
+
let text = `${title} ${theme.fg("accent", `${a.tasks.length} parallel tasks`)}`;
|
|
259
|
+
for (const t of a.tasks) {
|
|
260
|
+
text += `\n ${theme.fg("accent", t.agent ?? "general")} ${theme.fg("dim", clip(t.task ?? "…", TASK_PREVIEW_MAX))}`;
|
|
261
|
+
}
|
|
262
|
+
return new Text(text, 0, 0);
|
|
263
|
+
}
|
|
264
|
+
let text = `${title} ${theme.fg("accent", a.agent ?? "general")}`;
|
|
265
|
+
if (a.task)
|
|
266
|
+
text += `\n ${theme.fg("dim", clip(a.task, 2 * TASK_PREVIEW_MAX))}`;
|
|
267
|
+
return new Text(text, 0, 0);
|
|
268
|
+
}
|
|
269
|
+
function fallbackText(result) {
|
|
270
|
+
const first = result.content.find((c) => c.type === "text" && typeof c.text === "string");
|
|
271
|
+
return new Text(first?.text ?? "(no output)", 0, 0);
|
|
272
|
+
}
|
|
273
|
+
function actionGlyph(a, theme) {
|
|
274
|
+
if (a.kind === "narration")
|
|
275
|
+
return theme.fg("muted", "·");
|
|
276
|
+
if (a.state === "error")
|
|
277
|
+
return theme.fg("error", "✗");
|
|
278
|
+
if (a.state === "running")
|
|
279
|
+
return theme.fg("muted", "→");
|
|
280
|
+
return theme.fg("muted", "→");
|
|
281
|
+
}
|
|
282
|
+
function actionLogLines(p, theme, shown) {
|
|
283
|
+
const lines = [];
|
|
284
|
+
const tail = p.actions.slice(-shown);
|
|
285
|
+
const skipped = p.droppedActions + (p.actions.length - tail.length);
|
|
286
|
+
if (skipped > 0)
|
|
287
|
+
lines.push(theme.fg("muted", ` … ${skipped} earlier actions`));
|
|
288
|
+
for (const a of tail) {
|
|
289
|
+
const color = a.kind === "narration" ? "dim" : a.state === "error" ? "error" : "toolOutput";
|
|
290
|
+
lines.push(` ${actionGlyph(a, theme)} ${theme.fg(color, clip(a.text, 110))}`);
|
|
291
|
+
}
|
|
292
|
+
return lines;
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Fail-open degradation is still worth seeing: warn (not error — falling back to
|
|
296
|
+
* plain text is recoverable, nothing to page on) so an unexpected markdown
|
|
297
|
+
* failure is observable. Once per component, because the render path repaints on
|
|
298
|
+
* every frame and the "no TUI" case would otherwise flood the log.
|
|
299
|
+
*/
|
|
300
|
+
function warnMarkdownFallback(phase, err) {
|
|
301
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
302
|
+
try {
|
|
303
|
+
console.warn(JSON.stringify({
|
|
304
|
+
source: "yagni-subagent-render",
|
|
305
|
+
level: "warning",
|
|
306
|
+
message: "markdown rendering unavailable; falling back to plain text",
|
|
307
|
+
phase,
|
|
308
|
+
error: message,
|
|
309
|
+
}));
|
|
310
|
+
}
|
|
311
|
+
catch {
|
|
312
|
+
console.warn(`[yagni-subagent-render] markdown ${phase} failed: ${message}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* A prose body as markdown when the TUI's markdown theme is available.
|
|
317
|
+
* `getMarkdownTheme()` hands back a lazy proxy that only throws when a style is
|
|
318
|
+
* first USED, so the fallback must wrap `render`, not construction — otherwise
|
|
319
|
+
* an uninitialized theme would blow up mid-paint and pi would silently degrade
|
|
320
|
+
* the whole row to the bare title bar.
|
|
321
|
+
*/
|
|
322
|
+
export function markdownOrPlain(body, theme) {
|
|
323
|
+
const plain = new Text(theme.fg("toolOutput", body.trim()), 0, 0);
|
|
324
|
+
let markdown;
|
|
325
|
+
try {
|
|
326
|
+
markdown = new Markdown(body.trim(), 0, 0, getMarkdownTheme());
|
|
327
|
+
}
|
|
328
|
+
catch (err) {
|
|
329
|
+
warnMarkdownFallback("construct", err);
|
|
330
|
+
return plain;
|
|
331
|
+
}
|
|
332
|
+
let warned = false;
|
|
333
|
+
return {
|
|
334
|
+
render(width) {
|
|
335
|
+
try {
|
|
336
|
+
return markdown.render(width);
|
|
337
|
+
}
|
|
338
|
+
catch (err) {
|
|
339
|
+
if (!warned) {
|
|
340
|
+
warned = true;
|
|
341
|
+
warnMarkdownFallback("render", err);
|
|
342
|
+
}
|
|
343
|
+
return plain.render(width);
|
|
344
|
+
}
|
|
345
|
+
},
|
|
346
|
+
invalidate() {
|
|
347
|
+
markdown.invalidate?.();
|
|
348
|
+
plain.invalidate();
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Result renderer: live two-line status per task while partial; receipts plus
|
|
354
|
+
* report preview when collapsed; action log plus full markdown report when
|
|
355
|
+
* expanded. Drives its own refresh while running via an unref'd interval on
|
|
356
|
+
* `context.state` (pi has no unmount hook — the final render clears it).
|
|
357
|
+
*/
|
|
358
|
+
export function renderSubagentResult(result, options, theme, context) {
|
|
359
|
+
const details = result.details;
|
|
360
|
+
const tasks = details?.tasks;
|
|
361
|
+
const state = context.state ?? {};
|
|
362
|
+
if (options.isPartial) {
|
|
363
|
+
if (!state.timer) {
|
|
364
|
+
state.timer = setInterval(() => context.invalidate(), SPINNER_TICK_MS);
|
|
365
|
+
state.timer.unref?.();
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
else if (state.timer) {
|
|
369
|
+
clearInterval(state.timer);
|
|
370
|
+
delete state.timer;
|
|
371
|
+
}
|
|
372
|
+
if (!tasks || tasks.length === 0)
|
|
373
|
+
return fallbackText(result);
|
|
374
|
+
if (options.isPartial) {
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
const frame = SPINNER_FRAMES[Math.floor(now / SPINNER_TICK_MS) % SPINNER_FRAMES.length];
|
|
377
|
+
const lines = [];
|
|
378
|
+
for (const p of tasks) {
|
|
379
|
+
if (lines.length > 0)
|
|
380
|
+
lines.push("");
|
|
381
|
+
if (p.status === "running")
|
|
382
|
+
lines.push(...runningLines(p, theme, now, frame));
|
|
383
|
+
else
|
|
384
|
+
lines.push(receiptLine(p, theme));
|
|
385
|
+
if (options.expanded)
|
|
386
|
+
lines.push(...actionLogLines(p, theme, EXPANDED_ACTIONS_SHOWN));
|
|
387
|
+
}
|
|
388
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
389
|
+
}
|
|
390
|
+
if (options.expanded) {
|
|
391
|
+
const container = new Container();
|
|
392
|
+
tasks.forEach((p, i) => {
|
|
393
|
+
if (i > 0)
|
|
394
|
+
container.addChild(new Spacer(1));
|
|
395
|
+
container.addChild(new Text(receiptLine(p, theme), 0, 0));
|
|
396
|
+
container.addChild(new Text(theme.fg("dim", ` ${clip(p.task, 2 * TASK_PREVIEW_MAX)}`), 0, 0));
|
|
397
|
+
if (p.errorMessage)
|
|
398
|
+
container.addChild(new Text(theme.fg("error", ` ${clip(p.errorMessage, 200)}`), 0, 0));
|
|
399
|
+
const log = actionLogLines(p, theme, EXPANDED_ACTIONS_SHOWN);
|
|
400
|
+
if (log.length > 0)
|
|
401
|
+
container.addChild(new Text(log.join("\n"), 0, 0));
|
|
402
|
+
const report = (p.report ?? "").trim();
|
|
403
|
+
if (report) {
|
|
404
|
+
container.addChild(new Spacer(1));
|
|
405
|
+
container.addChild(markdownOrPlain(report, theme));
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
if (tasks.length > 1) {
|
|
409
|
+
container.addChild(new Spacer(1));
|
|
410
|
+
container.addChild(new Text(totalLine(tasks, theme), 0, 0));
|
|
411
|
+
}
|
|
412
|
+
return container;
|
|
413
|
+
}
|
|
414
|
+
// Collapsed, final: receipts + a short report preview per task.
|
|
415
|
+
const lines = [];
|
|
416
|
+
let truncated = false;
|
|
417
|
+
for (const p of tasks) {
|
|
418
|
+
if (lines.length > 0)
|
|
419
|
+
lines.push("");
|
|
420
|
+
lines.push(receiptLine(p, theme));
|
|
421
|
+
if (p.errorMessage)
|
|
422
|
+
lines.push(theme.fg("error", ` ${clip(p.errorMessage, 160)}`));
|
|
423
|
+
const report = (p.report ?? "").trim();
|
|
424
|
+
if (report) {
|
|
425
|
+
const reportLines = report.split("\n");
|
|
426
|
+
for (const l of reportLines.slice(0, REPORT_PREVIEW_LINES)) {
|
|
427
|
+
lines.push(theme.fg("toolOutput", ` ${l}`));
|
|
428
|
+
}
|
|
429
|
+
if (reportLines.length > REPORT_PREVIEW_LINES)
|
|
430
|
+
truncated = true;
|
|
431
|
+
}
|
|
432
|
+
if (p.actions.length > 0)
|
|
433
|
+
truncated = true;
|
|
434
|
+
}
|
|
435
|
+
if (tasks.length > 1)
|
|
436
|
+
lines.push("", totalLine(tasks, theme));
|
|
437
|
+
if (truncated)
|
|
438
|
+
lines.push(theme.fg("muted", " (ctrl+o to expand)"));
|
|
439
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
440
|
+
}
|
|
441
|
+
//# sourceMappingURL=subagentRender.js.map
|
|
@@ -21,6 +21,7 @@ import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-cod
|
|
|
21
21
|
import { Type } from "typebox";
|
|
22
22
|
import { runStage } from "./pipeline/runner.js";
|
|
23
23
|
import type { ModelTier, PipelineStage } from "./pipeline/types.js";
|
|
24
|
+
import { renderSubagentCall, renderSubagentResult } from "./subagentRender.js";
|
|
24
25
|
export declare const SUBAGENT_TOOL_NAME = "subagent";
|
|
25
26
|
export declare const GENERAL_AGENT_NAME = "general";
|
|
26
27
|
export declare const MAX_PARALLEL_SUBAGENTS = 4;
|
|
@@ -105,6 +106,8 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
|
|
|
105
106
|
agent: Type.TOptional<Type.TString>;
|
|
106
107
|
}>>>;
|
|
107
108
|
}>;
|
|
109
|
+
renderCall: typeof renderSubagentCall;
|
|
110
|
+
renderResult: typeof renderSubagentResult;
|
|
108
111
|
execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
|
|
109
112
|
content: Array<{
|
|
110
113
|
type: "text";
|
|
@@ -125,13 +128,7 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
|
|
|
125
128
|
text: string;
|
|
126
129
|
}[];
|
|
127
130
|
details: {
|
|
128
|
-
tasks:
|
|
129
|
-
agent: string;
|
|
130
|
-
task: string;
|
|
131
|
-
exitCode: number;
|
|
132
|
-
usage: import("./pipeline/types.js").StageUsage;
|
|
133
|
-
toolCalls: number;
|
|
134
|
-
}[];
|
|
131
|
+
tasks: import("./subagentRender.js").SubagentTaskProgress[];
|
|
135
132
|
};
|
|
136
133
|
}>;
|
|
137
134
|
};
|
|
@@ -24,6 +24,7 @@ import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
|
24
24
|
import { Type } from "typebox";
|
|
25
25
|
import { sanitizeCallerSegment } from "./config.js";
|
|
26
26
|
import { runStage } from "./pipeline/runner.js";
|
|
27
|
+
import { applyChildEvent, finalizeTask, formatWorkingMessage, newTaskProgress, progressSummaryText, renderSubagentCall, renderSubagentResult, } from "./subagentRender.js";
|
|
27
28
|
/**
|
|
28
29
|
* YAG-471 attribution: the `x-yagni-caller` prefix for a subagent invocation.
|
|
29
30
|
* The sanitized agent name is capped so the WHOLE label (prefix + name) stays
|
|
@@ -276,6 +277,8 @@ export function makeSubagentTool(deps = {}) {
|
|
|
276
277
|
"(list them with /agents); omit `agent` for the general-purpose one.",
|
|
277
278
|
promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
|
|
278
279
|
parameters,
|
|
280
|
+
renderCall: renderSubagentCall,
|
|
281
|
+
renderResult: renderSubagentResult,
|
|
279
282
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
280
283
|
const fail = (text) => ({
|
|
281
284
|
content: [{ type: "text", text }],
|
|
@@ -304,31 +307,56 @@ export function makeSubagentTool(deps = {}) {
|
|
|
304
307
|
}
|
|
305
308
|
resolved.push({ def, task: req.task });
|
|
306
309
|
}
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
310
|
+
// Live progress: one folded record per task rides every partial update's
|
|
311
|
+
// `details` (rendered by renderSubagentResult), the plain-text summary
|
|
312
|
+
// rides `content` (pi's fallback renderer and headless consumers), and
|
|
313
|
+
// the harness "Working…" line mirrors the aggregate while children run.
|
|
314
|
+
const progresses = resolved.map(({ def, task }) => newTaskProgress(def.name, task, Date.now()));
|
|
315
|
+
const ui = ctx?.hasUI ? ctx.ui : undefined;
|
|
316
|
+
let lastWorking;
|
|
317
|
+
const emit = () => {
|
|
318
|
+
const now = Date.now();
|
|
319
|
+
onUpdate?.({
|
|
320
|
+
content: [{ type: "text", text: progressSummaryText(progresses, now) }],
|
|
321
|
+
details: {
|
|
322
|
+
tasks: progresses.map((p) => ({ ...p, actions: [...p.actions], usage: { ...p.usage } })),
|
|
314
323
|
},
|
|
315
|
-
],
|
|
316
|
-
details: {},
|
|
317
|
-
});
|
|
318
|
-
const outcomes = await Promise.all(resolved.map(async ({ def, task }) => {
|
|
319
|
-
const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
|
|
320
|
-
const result = await run(stage, stageCtx, {
|
|
321
|
-
cwd,
|
|
322
|
-
signal,
|
|
323
|
-
personaBody: () => def.body,
|
|
324
|
-
// YAG-471: attribute this child's completions to the specific
|
|
325
|
-
// subagent, not the generic /go stage label the runner would
|
|
326
|
-
// otherwise derive from stage.id ("implement", reused as the
|
|
327
|
-
// synthetic subagent stage — see buildSubagentStage).
|
|
328
|
-
callerLabel: `${SUBAGENT_CALLER_PREFIX}${sanitizeCallerSegment(def.name, 64 - SUBAGENT_CALLER_PREFIX.length)}`,
|
|
329
324
|
});
|
|
330
|
-
|
|
331
|
-
|
|
325
|
+
const working = formatWorkingMessage(progresses, now);
|
|
326
|
+
if (ui && working !== lastWorking) {
|
|
327
|
+
lastWorking = working;
|
|
328
|
+
ui.setWorkingMessage?.(working);
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
emit();
|
|
332
|
+
let outcomes;
|
|
333
|
+
try {
|
|
334
|
+
outcomes = await Promise.all(resolved.map(async ({ def, task }, index) => {
|
|
335
|
+
const progress = progresses[index];
|
|
336
|
+
const { stage, ctx: stageCtx } = buildSubagentStage(def, task);
|
|
337
|
+
const result = await run(stage, stageCtx, {
|
|
338
|
+
cwd,
|
|
339
|
+
signal,
|
|
340
|
+
personaBody: () => def.body,
|
|
341
|
+
// YAG-471: attribute this child's completions to the specific
|
|
342
|
+
// subagent, not the generic /go stage label the runner would
|
|
343
|
+
// otherwise derive from stage.id ("implement", reused as the
|
|
344
|
+
// synthetic subagent stage — see buildSubagentStage).
|
|
345
|
+
callerLabel: `${SUBAGENT_CALLER_PREFIX}${sanitizeCallerSegment(def.name, 64 - SUBAGENT_CALLER_PREFIX.length)}`,
|
|
346
|
+
onEvent: (ev) => {
|
|
347
|
+
if (applyChildEvent(progress, ev))
|
|
348
|
+
emit();
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
finalizeTask(progress, result, Date.now());
|
|
352
|
+
emit();
|
|
353
|
+
return { agent: def.name, task, result };
|
|
354
|
+
}));
|
|
355
|
+
}
|
|
356
|
+
finally {
|
|
357
|
+
// Restore the default "Working…" text whether we resolved or threw.
|
|
358
|
+
ui?.setWorkingMessage?.();
|
|
359
|
+
}
|
|
332
360
|
const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
|
|
333
361
|
const sections = outcomes.map((o) => {
|
|
334
362
|
const output = o.result.finalOutput.trim();
|
|
@@ -342,15 +370,10 @@ export function makeSubagentTool(deps = {}) {
|
|
|
342
370
|
});
|
|
343
371
|
return {
|
|
344
372
|
content: [{ type: "text", text: sections.join("\n\n") }],
|
|
345
|
-
details:
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
exitCode: o.result.exitCode,
|
|
350
|
-
usage: o.result.usage,
|
|
351
|
-
toolCalls: o.result.toolCalls,
|
|
352
|
-
})),
|
|
353
|
-
},
|
|
373
|
+
// The folded progress records ARE the final details: a superset of the
|
|
374
|
+
// old {agent, task, exitCode, usage, toolCalls} shape, plus the action
|
|
375
|
+
// log and report that renderSubagentResult paints.
|
|
376
|
+
details: { tasks: progresses },
|
|
354
377
|
...(allFailed ? { isError: true } : {}),
|
|
355
378
|
};
|
|
356
379
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "0.3.0-staging.
|
|
3
|
+
"version": "0.3.0-staging.1077.1",
|
|
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)",
|
|
@@ -38,5 +38,5 @@
|
|
|
38
38
|
"@earendil-works/pi-tui": "0.84.1",
|
|
39
39
|
"typebox": "^1.3.11"
|
|
40
40
|
},
|
|
41
|
-
"yagniSourceSha": "
|
|
41
|
+
"yagniSourceSha": "38b2372ff55b9374b52e94a3836be6e8c942b8df"
|
|
42
42
|
}
|