@yagni-app/code 0.3.5 → 1.0.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.
Files changed (75) hide show
  1. package/README.md +42 -0
  2. package/dist/cli.js +231 -6
  3. package/dist/crashReport.d.ts +8 -0
  4. package/dist/crashReport.js +13 -1
  5. package/dist/doctor.d.ts +7 -0
  6. package/dist/doctor.js +33 -0
  7. package/dist/extension/askAdvisorTool.d.ts +7 -0
  8. package/dist/extension/askAdvisorTool.js +13 -3
  9. package/dist/extension/askUserQuestionTool.d.ts +54 -0
  10. package/dist/extension/askUserQuestionTool.js +621 -0
  11. package/dist/extension/askYagniTool.js +2 -0
  12. package/dist/extension/branding.d.ts +15 -0
  13. package/dist/extension/branding.js +76 -0
  14. package/dist/extension/chipEditor.d.ts +22 -1
  15. package/dist/extension/chipEditor.js +58 -5
  16. package/dist/extension/cmux/state.js +9 -16
  17. package/dist/extension/condensedTools.d.ts +93 -0
  18. package/dist/extension/condensedTools.js +392 -0
  19. package/dist/extension/crashReport.js +12 -0
  20. package/dist/extension/decisionCapture.js +3 -0
  21. package/dist/extension/decisions.js +4 -0
  22. package/dist/extension/diagnostics.d.ts +31 -0
  23. package/dist/extension/diagnostics.js +53 -55
  24. package/dist/extension/diffStat.d.ts +62 -0
  25. package/dist/extension/diffStat.js +158 -0
  26. package/dist/extension/errorSink.d.ts +64 -0
  27. package/dist/extension/errorSink.js +180 -0
  28. package/dist/extension/feedbackCommand.d.ts +38 -0
  29. package/dist/extension/feedbackCommand.js +151 -0
  30. package/dist/extension/footer.d.ts +2 -0
  31. package/dist/extension/footer.js +21 -8
  32. package/dist/extension/hooks.js +12 -12
  33. package/dist/extension/index.d.ts +7 -0
  34. package/dist/extension/index.js +161 -42
  35. package/dist/extension/mineBeat.js +13 -0
  36. package/dist/extension/permission/execPolicy.js +47 -0
  37. package/dist/extension/pipeline/goCommand.js +2 -0
  38. package/dist/extension/pipeline/invocation.d.ts +7 -0
  39. package/dist/extension/pipeline/invocation.js +7 -0
  40. package/dist/extension/pipeline/personas.js +4 -4
  41. package/dist/extension/pipeline/runner.d.ts +1 -0
  42. package/dist/extension/pipeline/runner.js +24 -3
  43. package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
  44. package/dist/extension/pipeline/sessionWorktree.js +225 -0
  45. package/dist/extension/scratchpad.d.ts +66 -0
  46. package/dist/extension/scratchpad.js +93 -0
  47. package/dist/extension/silentTurnReminder.js +18 -14
  48. package/dist/extension/subagents.d.ts +10 -0
  49. package/dist/extension/subagents.js +18 -4
  50. package/dist/extension/todos.d.ts +1 -0
  51. package/dist/extension/todos.js +15 -0
  52. package/dist/extension/toolRuns.d.ts +92 -0
  53. package/dist/extension/toolRuns.js +201 -0
  54. package/dist/extension/turnLog.js +17 -46
  55. package/dist/extension/webFetch.d.ts +85 -0
  56. package/dist/extension/webFetch.js +192 -0
  57. package/dist/extension/webFetchTool.d.ts +34 -0
  58. package/dist/extension/webFetchTool.js +106 -0
  59. package/dist/extension/workingLine.d.ts +49 -0
  60. package/dist/extension/workingLine.js +116 -0
  61. package/dist/feedback.d.ts +77 -0
  62. package/dist/feedback.js +500 -0
  63. package/dist/goHeadless.d.ts +3 -0
  64. package/dist/goHeadless.js +13 -0
  65. package/dist/launch.d.ts +8 -0
  66. package/dist/launch.js +6 -0
  67. package/dist/otel.d.ts +150 -0
  68. package/dist/otel.js +291 -0
  69. package/dist/outputFormat.d.ts +83 -0
  70. package/dist/outputFormat.js +207 -0
  71. package/dist/paths.d.ts +10 -0
  72. package/dist/paths.js +13 -0
  73. package/dist/worktreeArgs.d.ts +43 -0
  74. package/dist/worktreeArgs.js +96 -0
  75. package/package.json +4 -2
@@ -0,0 +1,192 @@
1
+ /**
2
+ * WebFetch core: fetch + convert + extract, adapted from Claude Code's
3
+ * WebFetchTool (src/tools/WebFetchTool/utils.ts). Pure and pi-free so it is
4
+ * fully unit-testable against fixtures, mirroring resilientFetch.ts.
5
+ *
6
+ * Three stages:
7
+ * 1. FETCH an arbitrary URL with a PLAIN fetch impl (deliberately NOT the
8
+ * authed fetch — see the token-leak note below).
9
+ * 2. CONVERT HTML → markdown via turndown; non-HTML passes through raw.
10
+ * 3. EXTRACT a `standard`-tier completion against the user's prompt.
11
+ *
12
+ * SECURITY (token leak): the extension's `makeAuthedFetch` transparently
13
+ * re-sends a second request WITH a fresh `Bearer $YAGNI_TOKEN` whenever the
14
+ * first returns 401 (tokenProvider.ts). Routing that wrapper at an arbitrary
15
+ * host would leak the token to any site that answers 401. The URL fetch must
16
+ * therefore use a plain global fetch, never opts.fetchImpl.
17
+ */
18
+ /** Upper bound on the markdown handed to the extraction model (Claude's cap). */
19
+ export const MAX_MARKDOWN_LENGTH = 100_000;
20
+ /** Upper bound on the raw HTTP body we will accept (Claude's PSR cap). */
21
+ export const MAX_HTTP_CONTENT_LENGTH = 10 * 1024 * 1024;
22
+ /** Per-request wall clock for the URL fetch (Claude's 60s). */
23
+ export const FETCH_TIMEOUT_MS = 60_000;
24
+ /** Same-host redirect hop cap (Claude's 10, matching common client defaults). */
25
+ export const MAX_REDIRECTS = 10;
26
+ /** URLs longer than this are rejected as a data-exfil surface (Claude). */
27
+ const MAX_URL_LENGTH = 2000;
28
+ /**
29
+ * Reject URLs that could exfiltrate credentials or reach the user's own
30
+ * machine/network. Ported from Claude's validateURL: >2k chars, embedded
31
+ * userinfo, and single-label (non-public) hostnames are all refused.
32
+ */
33
+ export function validateUrl(url) {
34
+ if (url.length > MAX_URL_LENGTH)
35
+ return false;
36
+ let parsed;
37
+ try {
38
+ parsed = new URL(url);
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ if (parsed.username || parsed.password)
44
+ return false;
45
+ const parts = parsed.hostname.split(".");
46
+ if (parts.length < 2)
47
+ return false;
48
+ return true;
49
+ }
50
+ /**
51
+ * Whether a redirect may be followed: same protocol/port, no userinfo, and the
52
+ * host differs only by an optional leading `www.`. Ported from Claude.
53
+ */
54
+ export function isPermittedRedirect(originalUrl, redirectUrl) {
55
+ try {
56
+ const orig = new URL(originalUrl);
57
+ const redir = new URL(redirectUrl);
58
+ if (redir.protocol !== orig.protocol)
59
+ return false;
60
+ if (redir.port !== orig.port)
61
+ return false;
62
+ if (redir.username || redir.password)
63
+ return false;
64
+ const stripWww = (host) => host.replace(/^www\./, "");
65
+ return stripWww(orig.hostname) === stripWww(redir.hostname);
66
+ }
67
+ catch {
68
+ return false;
69
+ }
70
+ }
71
+ /** Lazily import turndown once, on first HTML fetch. */
72
+ let turndownService;
73
+ async function getTurndown() {
74
+ if (!turndownService) {
75
+ const Turndown = (await import("turndown")).default;
76
+ turndownService = new Turndown();
77
+ }
78
+ return turndownService;
79
+ }
80
+ /**
81
+ * Fetch `url` (following only same-host redirects) and convert the body to
82
+ * markdown. Returns the converted content, or a {@link RedirectInfo} when the
83
+ * response routes to a different host (the caller re-invokes with the new URL).
84
+ */
85
+ export async function fetchMarkdown(url, opts = {}) {
86
+ if (!validateUrl(url))
87
+ throw new Error(`Invalid URL: "${url}"`);
88
+ const response = await fetchWithPermittedRedirects(url, opts, 0);
89
+ if ("type" in response)
90
+ return response;
91
+ const raw = Buffer.from(response.data);
92
+ const contentType = response.headers?.["content-type"] ?? "";
93
+ const bytes = raw.length;
94
+ const text = raw.toString("utf-8");
95
+ let content;
96
+ if (contentType.includes("text/html")) {
97
+ content = (await getTurndown()).turndown(text);
98
+ }
99
+ else {
100
+ content = text;
101
+ }
102
+ if (content.length > MAX_MARKDOWN_LENGTH) {
103
+ content = `${content.slice(0, MAX_MARKDOWN_LENGTH)}\n\n[Content truncated due to length...]`;
104
+ }
105
+ return {
106
+ content,
107
+ bytes,
108
+ code: response.status,
109
+ codeText: response.statusText,
110
+ contentType,
111
+ };
112
+ }
113
+ /** Low-level fetch with manual, same-host-only redirect following. */
114
+ async function fetchWithPermittedRedirects(url, opts, depth) {
115
+ if (depth > MAX_REDIRECTS) {
116
+ throw new Error(`Too many redirects (exceeded ${MAX_REDIRECTS})`);
117
+ }
118
+ const signal = opts.signal;
119
+ const response = await (opts.fetchImpl ?? fetch)(url, {
120
+ method: "GET",
121
+ redirect: "manual",
122
+ signal,
123
+ headers: {
124
+ Accept: "text/markdown, text/html, */*",
125
+ "User-Agent": "YAGNI-Code (web-fetch)",
126
+ },
127
+ });
128
+ if ([301, 302, 307, 308].includes(response.status)) {
129
+ const location = response.headers.get("location");
130
+ if (!location)
131
+ throw new Error("Redirect missing Location header");
132
+ const redirectUrl = new URL(location, url).toString();
133
+ if (!isPermittedRedirect(url, redirectUrl)) {
134
+ return { type: "redirect", originalUrl: url, redirectUrl, statusCode: response.status };
135
+ }
136
+ return fetchWithPermittedRedirects(redirectUrl, opts, depth + 1);
137
+ }
138
+ const contentLength = Number(response.headers.get("content-length") ?? "0");
139
+ if (Number.isFinite(contentLength) && contentLength > MAX_HTTP_CONTENT_LENGTH) {
140
+ throw new Error(`Response exceeds ${MAX_HTTP_CONTENT_LENGTH} bytes`);
141
+ }
142
+ const data = await response.arrayBuffer();
143
+ if (data.byteLength > MAX_HTTP_CONTENT_LENGTH) {
144
+ throw new Error(`Response exceeds ${MAX_HTTP_CONTENT_LENGTH} bytes`);
145
+ }
146
+ const headers = {};
147
+ response.headers.forEach((value, key) => {
148
+ headers[key] = value;
149
+ });
150
+ return { data, headers, status: response.status, statusText: response.statusText };
151
+ }
152
+ /**
153
+ * Build the extraction prompt: the fetched content plus the user's prompt plus
154
+ * the "answer only from the content" guardrails, mirroring Claude's
155
+ * makeSecondaryModelPrompt.
156
+ */
157
+ export function makeExtractionPrompt(markdownContent, prompt) {
158
+ const guidelines = "Provide a concise response based only on the content above. In your response:\n" +
159
+ " - Enforce a strict 125-character maximum for quotes from any source document.\n" +
160
+ " - Use quotation marks for exact language; any language outside the quotation should never be word-for-word the same.";
161
+ return `Web page content:\n---\n${markdownContent}\n---\n\n${prompt}\n\n${guidelines}`;
162
+ }
163
+ /**
164
+ * Run the extraction: one `standard`-tier completion against the metric proxy
165
+ * (the same /v1/chat/completions surface pi's `yagni` provider drives). The
166
+ * content was already fetched locally; only this summarization rides the proxy.
167
+ */
168
+ export async function extract(markdownContent, prompt, opts) {
169
+ const res = await (opts.fetchImpl ?? fetch)(`${opts.baseUrl}/v1/chat/completions`, {
170
+ method: "POST",
171
+ headers: {
172
+ "content-type": "application/json",
173
+ authorization: `Bearer ${opts.getToken() ?? ""}`,
174
+ ...opts.attribution(),
175
+ },
176
+ body: JSON.stringify({
177
+ model: "standard",
178
+ messages: [{ role: "user", content: makeExtractionPrompt(markdownContent, prompt) }],
179
+ }),
180
+ signal: opts.signal,
181
+ });
182
+ if (!res.ok) {
183
+ throw new Error(`web_fetch extraction failed: HTTP ${res.status}`);
184
+ }
185
+ const data = (await res.json());
186
+ const text = data.choices?.[0]?.message?.content;
187
+ if (typeof text !== "string") {
188
+ throw new Error("web_fetch extraction returned no content");
189
+ }
190
+ return { text };
191
+ }
192
+ //# sourceMappingURL=webFetch.js.map
@@ -0,0 +1,34 @@
1
+ import type { ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Type } from "typebox";
3
+ import { makeExtractionPrompt, MAX_MARKDOWN_LENGTH } from "./webFetch.js";
4
+ export interface MakeWebFetchToolOptions {
5
+ baseUrl: string;
6
+ getToken: () => string | undefined;
7
+ /**
8
+ * The authed fetch (makeAuthedFetch) used for the EXTRACTION call only. It is
9
+ * deliberately never used for the arbitrary URL fetch — see the token-leak
10
+ * note in webFetch.ts.
11
+ */
12
+ fetchImpl?: typeof fetch;
13
+ /**
14
+ * The PLAIN fetch used for the arbitrary URL fetch (defaults to global fetch,
15
+ * never the authed wrapper). Injectable so tests can stub the network without
16
+ * risking a real outbound request.
17
+ */
18
+ webFetchImpl?: typeof fetch;
19
+ }
20
+ declare const parameters: Type.TObject<{
21
+ url: Type.TString;
22
+ prompt: Type.TString;
23
+ }>;
24
+ /**
25
+ * Build the `web_fetch` tool definition — YAG-578.
26
+ *
27
+ * Fetches a URL, converts HTML to markdown, then extracts the requested
28
+ * information with a `standard`-tier completion. Read-only, client-side; it is
29
+ * the tidy replacement for a `bash` + `curl` + `python` fetch-and-strip dance.
30
+ */
31
+ export declare function makeWebFetchTool(opts: MakeWebFetchToolOptions): ToolDefinition<typeof parameters, undefined>;
32
+ /** Re-exported for tests that assemble the extraction prompt. */
33
+ export { MAX_MARKDOWN_LENGTH, makeExtractionPrompt };
34
+ //# sourceMappingURL=webFetchTool.d.ts.map
@@ -0,0 +1,106 @@
1
+ import { Container, Text } from "@earendil-works/pi-tui";
2
+ import { Type } from "typebox";
3
+ import { attributionHeaders } from "./config.js";
4
+ import { markdownOrPlain } from "./subagentRender.js";
5
+ import { extract, fetchMarkdown, makeExtractionPrompt, MAX_MARKDOWN_LENGTH, } from "./webFetch.js";
6
+ const parameters = Type.Object({
7
+ url: Type.String({ description: "The URL to fetch content from" }),
8
+ prompt: Type.String({ description: "The prompt to run on the fetched content" }),
9
+ });
10
+ /** Collapsed preview length, in lines. */
11
+ const PREVIEW_LINES = 8;
12
+ function clipLine(text, max) {
13
+ const collapsed = text.replace(/\s+/g, " ").trim();
14
+ if (collapsed.length <= max)
15
+ return collapsed;
16
+ return `${collapsed.slice(0, max - 1)}…`;
17
+ }
18
+ function hostOf(url) {
19
+ try {
20
+ return new URL(url).hostname;
21
+ }
22
+ catch {
23
+ return url;
24
+ }
25
+ }
26
+ /**
27
+ * Build the `web_fetch` tool definition — YAG-578.
28
+ *
29
+ * Fetches a URL, converts HTML to markdown, then extracts the requested
30
+ * information with a `standard`-tier completion. Read-only, client-side; it is
31
+ * the tidy replacement for a `bash` + `curl` + `python` fetch-and-strip dance.
32
+ */
33
+ export function makeWebFetchTool(opts) {
34
+ return {
35
+ name: "web_fetch",
36
+ label: "Web fetch",
37
+ description: "Fetch a URL, convert HTML to markdown, and extract the requested information " +
38
+ "using a small model. Read-only. Use this to read an arbitrary web page or docs " +
39
+ "URL (an API reference, a changelog, a docs page) instead of reaching for bash + curl. " +
40
+ "It will fail for authenticated or private URLs (Google Docs, Jira, GitHub, etc.) — " +
41
+ "use a specialized connector or MCP tool for those.",
42
+ promptSnippet: "web_fetch: fetch a URL, convert to markdown, and summarize against a prompt (read-only).",
43
+ promptGuidelines: [
44
+ "Use web_fetch to read a URL's content instead of bash + curl when you need a page or a summary of it.",
45
+ ],
46
+ parameters,
47
+ // Self-framed: the condensed transcript look has no tinted tool boxes.
48
+ renderShell: "self",
49
+ renderCall(args, theme) {
50
+ const t = theme;
51
+ const url = clipLine(args?.url ?? "…", 80);
52
+ const prompt = args?.prompt ? clipLine(args.prompt, 60) : "";
53
+ let text = `${t.fg("toolTitle", t.bold("web_fetch"))} ${t.fg("accent", url)}`;
54
+ if (prompt)
55
+ text += ` — ${t.fg("dim", prompt)}`;
56
+ return new Text(text, 0, 0);
57
+ },
58
+ renderResult(result, { expanded, isPartial }, theme) {
59
+ const t = theme;
60
+ const content = result.content.find((c) => c.type === "text");
61
+ const text = content && "text" in content ? content.text : "";
62
+ if (isPartial)
63
+ return new Text(t.fg("muted", text || "Fetching…"), 0, 0);
64
+ if (expanded) {
65
+ const container = new Container();
66
+ container.addChild(markdownOrPlain(text || "(no content)", t));
67
+ return container;
68
+ }
69
+ const lines = text.trim().split("\n");
70
+ const out = lines.slice(0, PREVIEW_LINES).map((l) => t.fg("toolOutput", l));
71
+ if (lines.length > PREVIEW_LINES)
72
+ out.push(t.fg("muted", " (ctrl+o to expand)"));
73
+ return new Text(out.join("\n"), 0, 0);
74
+ },
75
+ async execute(_toolCallId, params, signal, onUpdate) {
76
+ onUpdate?.({ content: [{ type: "text", text: "Fetching…" }], details: undefined });
77
+ const fetched = await fetchMarkdown(params.url, { fetchImpl: opts.webFetchImpl, signal });
78
+ // A cross-host redirect is not auto-followed; ask the model to re-call.
79
+ if (fetched.type === "redirect") {
80
+ const r = fetched;
81
+ const statusText = r.statusCode === 301 ? "Moved Permanently"
82
+ : r.statusCode === 308 ? "Permanent Redirect"
83
+ : r.statusCode === 307 ? "Temporary Redirect"
84
+ : "Found";
85
+ const message = `REDIRECT DETECTED: The URL redirects to a different host.\n\n` +
86
+ `Original URL: ${r.originalUrl}\nRedirect URL: ${r.redirectUrl}\n` +
87
+ `Status: ${r.statusCode} ${statusText}\n\n` +
88
+ `To complete your request, call web_fetch again with these parameters:\n` +
89
+ `- url: "${r.redirectUrl}"\n- prompt: "${params.prompt}"`;
90
+ return { content: [{ type: "text", text: message }], details: undefined };
91
+ }
92
+ const content = fetched.content;
93
+ const { text } = await extract(content, params.prompt, {
94
+ baseUrl: opts.baseUrl,
95
+ getToken: opts.getToken,
96
+ fetchImpl: opts.fetchImpl,
97
+ attribution: () => attributionHeaders(),
98
+ signal,
99
+ });
100
+ return { content: [{ type: "text", text }], details: undefined };
101
+ },
102
+ };
103
+ }
104
+ /** Re-exported for tests that assemble the extraction prompt. */
105
+ export { MAX_MARKDOWN_LENGTH, makeExtractionPrompt };
106
+ //# sourceMappingURL=webFetchTool.js.map
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The Claude Code-style streaming status line: `Shaping… (12m 54s · ↓ 47.5k
3
+ * tokens)` in place of pi's static "Working...".
4
+ *
5
+ * One manager owns `ctx.ui.setWorkingMessage` for the whole session so the
6
+ * verb, the elapsed clock, and the token counter never fight the subagent /
7
+ * advisor progress text: those tools publish their activity line through
8
+ * {@link WorkingLineHandle.setActivity} (instead of calling setWorkingMessage
9
+ * directly), and the manager splices it in as the head of the same composed
10
+ * message. Elapsed time spans the whole agent loop (agent_start → agent_end);
11
+ * the token counter accumulates assistant output tokens across the loop's
12
+ * messages (message_end), which is when pi learns usage — subagent tokens live
13
+ * in the subagent's own activity text, not this counter.
14
+ *
15
+ * TUI-only by the agent_start guard; a headless /go child or desktop surface
16
+ * never gets a working line. Everything is fail-soft: a status line must never
17
+ * break a turn.
18
+ */
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ /** What subagents/advisor publish instead of calling setWorkingMessage. */
21
+ export interface WorkingLineHandle {
22
+ setActivity(text?: string): void;
23
+ }
24
+ /** A no-op handle for callers wired without a manager (tests, children). */
25
+ export declare const NULL_WORKING_LINE: WorkingLineHandle;
26
+ /**
27
+ * The verb pool. Neutral gerunds — one is picked per agent loop, so long
28
+ * sessions read as a person at work rather than a stuck spinner.
29
+ */
30
+ export declare const WORKING_VERBS: readonly string[];
31
+ /** Pulse frames for the streaming indicator (pi renders them verbatim). */
32
+ export declare const WORKING_INDICATOR_FRAMES: string[];
33
+ export interface ComposeWorkingOpts {
34
+ /** Override head from a running subagent/advisor (verb used when absent). */
35
+ activity?: string | undefined;
36
+ verb: string;
37
+ elapsedMs: number;
38
+ outputTokens: number;
39
+ }
40
+ /** `Shaping… (12m 54s · ↓ 47.5k tokens)` — pure, exported for tests. */
41
+ export declare function composeWorkingMessage(opts: ComposeWorkingOpts): string;
42
+ export interface RegisterWorkingLineDeps {
43
+ now?: () => number;
44
+ pickVerb?: (verbs: readonly string[]) => string;
45
+ /** Refresh cadence for the elapsed clock. */
46
+ tickMs?: number;
47
+ }
48
+ export declare function registerWorkingLine(pi: ExtensionAPI, deps?: RegisterWorkingLineDeps): WorkingLineHandle;
49
+ //# sourceMappingURL=workingLine.d.ts.map
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The Claude Code-style streaming status line: `Shaping… (12m 54s · ↓ 47.5k
3
+ * tokens)` in place of pi's static "Working...".
4
+ *
5
+ * One manager owns `ctx.ui.setWorkingMessage` for the whole session so the
6
+ * verb, the elapsed clock, and the token counter never fight the subagent /
7
+ * advisor progress text: those tools publish their activity line through
8
+ * {@link WorkingLineHandle.setActivity} (instead of calling setWorkingMessage
9
+ * directly), and the manager splices it in as the head of the same composed
10
+ * message. Elapsed time spans the whole agent loop (agent_start → agent_end);
11
+ * the token counter accumulates assistant output tokens across the loop's
12
+ * messages (message_end), which is when pi learns usage — subagent tokens live
13
+ * in the subagent's own activity text, not this counter.
14
+ *
15
+ * TUI-only by the agent_start guard; a headless /go child or desktop surface
16
+ * never gets a working line. Everything is fail-soft: a status line must never
17
+ * break a turn.
18
+ */
19
+ import { usageFromMessage } from "./costHud.js";
20
+ import { formatDuration, formatTokens } from "./subagentRender.js";
21
+ /** A no-op handle for callers wired without a manager (tests, children). */
22
+ export const NULL_WORKING_LINE = { setActivity: () => { } };
23
+ /**
24
+ * The verb pool. Neutral gerunds — one is picked per agent loop, so long
25
+ * sessions read as a person at work rather than a stuck spinner.
26
+ */
27
+ export const WORKING_VERBS = [
28
+ "Working",
29
+ "Thinking",
30
+ "Exploring",
31
+ "Tracing",
32
+ "Shaping",
33
+ "Wiring",
34
+ "Weighing",
35
+ "Sketching",
36
+ "Assembling",
37
+ "Distilling",
38
+ "Untangling",
39
+ "Polishing",
40
+ ];
41
+ /** Pulse frames for the streaming indicator (pi renders them verbatim). */
42
+ export const WORKING_INDICATOR_FRAMES = ["·", "✢", "✳", "✶", "✳", "✢"];
43
+ /** `Shaping… (12m 54s · ↓ 47.5k tokens)` — pure, exported for tests. */
44
+ export function composeWorkingMessage(opts) {
45
+ const head = opts.activity ?? `${opts.verb}…`;
46
+ const stats = [
47
+ formatDuration(opts.elapsedMs),
48
+ opts.outputTokens > 0 ? `↓ ${formatTokens(opts.outputTokens)} tokens` : undefined,
49
+ ]
50
+ .filter(Boolean)
51
+ .join(" · ");
52
+ return `${head} (${stats})`;
53
+ }
54
+ export function registerWorkingLine(pi, deps = {}) {
55
+ const now = deps.now ?? (() => Date.now());
56
+ const pickVerb = deps.pickVerb ?? ((verbs) => verbs[Math.floor(Math.random() * verbs.length)]);
57
+ const tickMs = deps.tickMs ?? 1_000;
58
+ let ui;
59
+ let timer;
60
+ let startedAt;
61
+ let outputTokens = 0;
62
+ let verb = WORKING_VERBS[0];
63
+ let activity;
64
+ const refresh = () => {
65
+ if (!ui || startedAt === undefined)
66
+ return;
67
+ try {
68
+ ui.setWorkingMessage?.(composeWorkingMessage({ activity, verb, elapsedMs: now() - startedAt, outputTokens }));
69
+ }
70
+ catch {
71
+ // The status line must never break a turn.
72
+ }
73
+ };
74
+ const stop = () => {
75
+ if (timer)
76
+ clearInterval(timer);
77
+ timer = undefined;
78
+ startedAt = undefined;
79
+ activity = undefined;
80
+ try {
81
+ ui?.setWorkingMessage?.();
82
+ }
83
+ catch {
84
+ // Restoring the default label is best-effort.
85
+ }
86
+ };
87
+ pi.on("agent_start", (_event, ctx) => {
88
+ if (ctx.mode !== "tui" || !ctx.hasUI)
89
+ return;
90
+ ui = ctx.ui;
91
+ startedAt = now();
92
+ outputTokens = 0;
93
+ activity = undefined;
94
+ verb = pickVerb(WORKING_VERBS);
95
+ refresh();
96
+ if (timer)
97
+ clearInterval(timer);
98
+ timer = setInterval(refresh, tickMs);
99
+ timer.unref?.();
100
+ });
101
+ pi.on("agent_end", () => stop());
102
+ pi.on("message_end", (event) => {
103
+ const message = event.message;
104
+ if (message?.role !== "assistant")
105
+ return;
106
+ outputTokens += usageFromMessage(message).output;
107
+ refresh();
108
+ });
109
+ return {
110
+ setActivity(text) {
111
+ activity = text;
112
+ refresh();
113
+ },
114
+ };
115
+ }
116
+ //# sourceMappingURL=workingLine.js.map
@@ -0,0 +1,77 @@
1
+ /**
2
+ * `yagni feedback [sessionId]` — file a bug report from the shell (YAG-592).
3
+ *
4
+ * A shortcut to trigger what `/feedback` does inside a session, but from
5
+ * outside the TUI. Two modes:
6
+ *
7
+ * Case A (no session arg): list the 10 most recent sessions for the current
8
+ * cwd, let the user pick, prompt for a description, confirm, submit.
9
+ * Case B (session ID provided): skip the list, go straight to description
10
+ * prompt → confirm → submit.
11
+ *
12
+ * Self-contained: no cross-package imports. The scrub (`scrubSecrets`) and the
13
+ * error-trail reader (`readSessionTrail`) are local copies of the extension's
14
+ * logic — keep in sync with `pi-extension-yagni/src/pipeline/scrubSecrets.ts`
15
+ * and `pi-extension-yagni/src/errorSink.ts`. The backend re-scrubs server-side
16
+ * (`backend/src/yagniCode/feedback.ts`), so the client-side scrub is the first
17
+ * line of defense, not the only one.
18
+ */
19
+ export interface FeedbackDeps {
20
+ loadCredentials?: () => Promise<{
21
+ token?: string;
22
+ baseUrl: string;
23
+ name: string;
24
+ }>;
25
+ fetchImpl?: typeof fetch;
26
+ env?: NodeJS.ProcessEnv;
27
+ cwd?: string;
28
+ /** Override the agent dir (sessions live under `<agentDir>/sessions/...`). */
29
+ agentDirPath?: string;
30
+ /** Override the state dir (error sink lives under `<stateDir>/logs/...`). */
31
+ stateDir?: string;
32
+ writeOut?: (line: string) => void;
33
+ writeErr?: (line: string) => void;
34
+ /** Seam for readline — tests inject a fake that returns scripted answers. */
35
+ readline?: {
36
+ question: (q: string) => Promise<string>;
37
+ close: () => void;
38
+ };
39
+ }
40
+ export interface SessionInfo {
41
+ id: string;
42
+ filePath: string;
43
+ startTime: string;
44
+ durationMs: number | null;
45
+ firstMessage: string;
46
+ }
47
+ /**
48
+ * Encode a cwd into pi's session directory name format:
49
+ * `/Users/foo/bar` → `--Users-foo-bar--`
50
+ * Mirrors pi's `migrations.js`: `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`
51
+ */
52
+ export declare function encodeCwd(cwd: string): string;
53
+ /**
54
+ * List the most recent sessions for the current cwd.
55
+ * Scans `<agentDir>/sessions/<encoded-cwd>/*.jsonl`, sorted by start time desc.
56
+ */
57
+ export declare function listRecentSessions(agentDirPath: string, cwd: string, limit?: number, termCols?: number): SessionInfo[];
58
+ /**
59
+ * Find a session file by UUID across all project dirs.
60
+ */
61
+ export declare function findSessionById(agentDirPath: string, sessionId: string): {
62
+ filePath: string;
63
+ startTime: string;
64
+ } | null;
65
+ /**
66
+ * Read the durable transcript, clamped by byte size. Returns empty on any
67
+ * failure or when too large. Mirrors the extension's `readTranscript`.
68
+ */
69
+ export declare function readTranscript(sessionFile: string | undefined): string;
70
+ /**
71
+ * Read the session-scoped error trail from today's error sink file.
72
+ * Filters by sessionId and excludes debug-level entries. Scrubs each line.
73
+ * Mirrors the extension's `readSessionTrail` — keep in sync.
74
+ */
75
+ export declare function readSessionTrail(sessionId: string, stateDir: string, maxBytes?: number): string;
76
+ export declare function feedbackCommand(args: string[], deps?: FeedbackDeps, cliVersion?: string): Promise<number>;
77
+ //# sourceMappingURL=feedback.d.ts.map