@yagni-app/code-staging 1.1.0-staging.1328.1 → 1.1.0-staging.1334.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/cli.d.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  * plus a configured pi spawn. Everything that makes this "YAGNI Code" lives in
13
13
  * pi-extension-yagni and the YAGNI backend.
14
14
  */
15
+ import { promptKeepOrRemoveWorktree } from "./worktreeExitPrompt.js";
15
16
  /**
16
17
  * Seed `editorPaddingX` into the per-profile pi `settings.json` so the prompt
17
18
  * input aligns with the chat/output area (`outputPad`) and the status bar.
@@ -42,6 +43,56 @@ export declare function seedCollapseChangelog(piAgentDir: string): void;
42
43
  * persists the user's choice, so this default is always reversible.
43
44
  */
44
45
  export declare function seedHideThinkingBlock(piAgentDir: string): boolean;
46
+ /** The structural slice of the extension's session-worktree entry (by file path). */
47
+ interface SessionWorktreeModule {
48
+ createOrResume: (name: string | undefined, deps: {
49
+ repoCwd: string;
50
+ }) => Promise<{
51
+ worktreePath: string;
52
+ branch: string;
53
+ existed: boolean;
54
+ }>;
55
+ /** Optional so a stale bundled extension (without the exit prompt support) degrades to the durable summary. */
56
+ removeSessionWorktree?: (worktreePath: string, branch: string, deps: {
57
+ repoCwd: string;
58
+ }) => Promise<{
59
+ removed: boolean;
60
+ branchRemoved: boolean;
61
+ note?: string;
62
+ branchUnmerged?: boolean;
63
+ }>;
64
+ }
65
+ /** Seams `handleWorktreeExit` needs; the real one lives in the extension. */
66
+ type WorktreeRemover = NonNullable<SessionWorktreeModule["removeSessionWorktree"]>;
67
+ /** Minimal stream shapes for testability (process.stdin/stderr satisfy these). */
68
+ interface WriteOnlyStream {
69
+ write(chunk: string): unknown;
70
+ isTTY?: boolean;
71
+ }
72
+ interface ReadablePromptStream {
73
+ isTTY?: boolean;
74
+ }
75
+ /** Pure: the one-line worktree summary for the kept path. */
76
+ export declare function worktreeKeptSummary(worktreePath: string, branch: string, existed: boolean): string;
77
+ /**
78
+ * The post-session worktree exit: prompt Keep/Remove on interactive text
79
+ * sessions, perform the removal when chosen, and print exactly ONE summary
80
+ * (kept / not removed / removed) to stderr. Fail-safe in every direction:
81
+ * an aborted prompt, a refused removal, or an erroring remover all leave the
82
+ * worktree in place with the durable resume summary printed.
83
+ */
84
+ export declare function handleWorktreeExit(deps: {
85
+ worktreePath: string;
86
+ branch: string;
87
+ existed: boolean;
88
+ outputFormat: string;
89
+ remainingArgs: string[];
90
+ stdin: ReadablePromptStream;
91
+ stderr: WriteOnlyStream;
92
+ cwd: string;
93
+ removeWorktree?: WorktreeRemover;
94
+ prompt?: typeof promptKeepOrRemoveWorktree;
95
+ }): Promise<void>;
45
96
  export declare const HELP_TEXT: string;
46
97
  /** Parse `use <name> [--base-url <url>]` argv into its parts. */
47
98
  export declare function parseUseArgs(args: string[]): {
@@ -82,4 +133,5 @@ export declare function main(argv: string[]): Promise<number>;
82
133
  * test import (argv[1] points at the test runner) does not.
83
134
  */
84
135
  export declare function isEntrypoint(argv1: string | undefined, moduleUrl: string): boolean;
136
+ export {};
85
137
  //# sourceMappingURL=cli.d.ts.map
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { spawn } from "node:child_process";
16
16
  import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
17
- import { join } from "node:path";
17
+ import { dirname, join } from "node:path";
18
18
  import { createInterface } from "node:readline/promises";
19
19
  import { fileURLToPath, pathToFileURL } from "node:url";
20
20
  import { PI_CONFIG_NAME } from "./branding.js";
@@ -38,7 +38,8 @@ import { currentCliVersion, maybeNudgeAndRefresh, upgradeCommand } from "./upgra
38
38
  import { maybeRefreshAtLaunch } from "./refresh.js";
39
39
  import { exitCodeFor, installSignalForwarding } from "./signalForward.js";
40
40
  import { PAD_X } from "./padding.js";
41
- import { parseWorktreeFlag, validateWorktreeLaunchArgs } from "./worktreeArgs.js";
41
+ import { canPromptWorktreeCleanup, parseWorktreeFlag, validateWorktreeLaunchArgs, } from "./worktreeArgs.js";
42
+ import { promptKeepOrRemoveWorktree } from "./worktreeExitPrompt.js";
42
43
  import { ensureShadowPiPackage } from "./piPackage.js";
43
44
  import { resolveExtensionPath, resolvePiCliPath, resolvePiPackageDir, resolveSessionWorktreePath } from "./paths.js";
44
45
  import { credentialsFromProfile, getActiveProfileName, listProfiles, migrateLegacyCredentials, persistProfileTokenRotation, profilePath, readActiveProfile, useProfile, } from "./profiles.js";
@@ -371,7 +372,8 @@ async function defaultLoadSessionWorktree() {
371
372
  *
372
373
  * Delegates all git behavior to the extension's `sessionWorktree` entry; this
373
374
  * launcher path only resolves credentials, builds the plan, and spawns pi with
374
- * `cwd` = the worktree. The worktree is DURABLE — nothing is ever removed.
375
+ * `cwd` = the worktree. Durable by default: nothing is removed unless the
376
+ * user explicitly picks Remove at the interactive exit prompt.
375
377
  */
376
378
  async function runWorktreeLaunch(passthroughArgs, worktreeName, loadSessionWorktree = defaultLoadSessionWorktree) {
377
379
  // Validate the name + argv before any side effect (slug guard + `-c`
@@ -486,13 +488,104 @@ async function runWorktreeLaunch(passthroughArgs, worktreeName, loadSessionWorkt
486
488
  outputFormat,
487
489
  cwd: result.worktreePath,
488
490
  });
489
- // Durable by default: nothing is removed. Tell the user where their work
490
- // lives (stderr only — never stdout, to keep json clean).
491
- process.stderr.write(`[yagni] worktree ${result.existed ? "resumed" : "created"}: ${result.worktreePath}\n` +
492
- `[yagni] branch: ${result.branch}\n` +
493
- `[yagni] resume: cd ${result.worktreePath} && yagni\n`);
491
+ // Interactive text sessions get a Keep/Remove list (Claude Code parity).
492
+ // Machine-readable runs (`--output-format json|stream-json`), one-shot `-p`
493
+ // runs, and piped stdin keep the durable-by-default behavior: no prompt,
494
+ // nothing removed. A stale bundled extension without
495
+ // `removeSessionWorktree` also degrades to the durable summary. All output
496
+ // is stderr — stdout stays clean for every output format.
497
+ await handleWorktreeExit({
498
+ worktreePath: result.worktreePath,
499
+ branch: result.branch,
500
+ existed: result.existed,
501
+ outputFormat,
502
+ remainingArgs,
503
+ stdin: process.stdin,
504
+ stderr: process.stderr,
505
+ cwd: process.cwd(),
506
+ removeWorktree: sessionWorktree.removeSessionWorktree,
507
+ });
494
508
  return exitCode;
495
509
  }
510
+ /** Pure: the one-line worktree summary for the kept path. */
511
+ export function worktreeKeptSummary(worktreePath, branch, existed) {
512
+ return (`[yagni] worktree ${existed ? "resumed" : "created"}: ${worktreePath}\n` +
513
+ `[yagni] branch: ${branch}\n` +
514
+ `[yagni] resume: cd ${worktreePath} && yagni\n`);
515
+ }
516
+ /**
517
+ * The post-session worktree exit: prompt Keep/Remove on interactive text
518
+ * sessions, perform the removal when chosen, and print exactly ONE summary
519
+ * (kept / not removed / removed) to stderr. Fail-safe in every direction:
520
+ * an aborted prompt, a refused removal, or an erroring remover all leave the
521
+ * worktree in place with the durable resume summary printed.
522
+ */
523
+ export async function handleWorktreeExit(deps) {
524
+ const { worktreePath, branch, existed, outputFormat, remainingArgs, stdin, stderr, cwd, removeWorktree, } = deps;
525
+ const prompt = deps.prompt ?? promptKeepOrRemoveWorktree;
526
+ const canPrompt = canPromptWorktreeCleanup(outputFormat, remainingArgs, stdin.isTTY === true) &&
527
+ removeWorktree !== undefined;
528
+ let choice;
529
+ if (canPrompt) {
530
+ try {
531
+ choice = await prompt({
532
+ input: stdin,
533
+ output: stderr,
534
+ label: `${worktreePath} — keep or remove?`,
535
+ });
536
+ }
537
+ catch (err) {
538
+ // A genuine prompt failure (stdin/raw-mode error) must not read as a
539
+ // silent no-op: say it failed, then keep the worktree as with any abort.
540
+ stderr.write(`[yagni] exit prompt failed: ${err instanceof Error ? err.message : String(err)}; worktree kept\n`);
541
+ choice = undefined;
542
+ }
543
+ }
544
+ if (choice !== "remove") {
545
+ // Keep (default), aborted prompt, or prompt suppressed: durable summary.
546
+ stderr.write(worktreeKeptSummary(worktreePath, branch, existed));
547
+ return;
548
+ }
549
+ let outcome;
550
+ try {
551
+ outcome = await removeWorktree(worktreePath, branch, { repoCwd: cwd });
552
+ }
553
+ catch (err) {
554
+ // The remover throwing (vs returning removed:false) still keeps the work;
555
+ // never let cleanup turn a successful session into a nonzero exit.
556
+ stderr.write(`[yagni] worktree NOT removed: ${err instanceof Error ? err.message : String(err)}\n` +
557
+ worktreeKeptSummary(worktreePath, branch, existed));
558
+ return;
559
+ }
560
+ if (!outcome.removed) {
561
+ // Git refused (dirty worktree) or failed — the work and the resume path
562
+ // are both intact, so say so and print the kept summary (not a duplicate).
563
+ stderr.write(`[yagni] worktree NOT removed${outcome.note ? `: ${outcome.note}` : ""}\n` +
564
+ worktreeKeptSummary(worktreePath, branch, existed));
565
+ return;
566
+ }
567
+ stderr.write(`[yagni] worktree removed\n` +
568
+ branchKeptMessage(outcome, branch, worktreePath));
569
+ }
570
+ /**
571
+ * The branch-status tail of the removed summary. The extension's safe `-d`
572
+ * (never `-D`) keeps an unmerged branch on disk when its worktree is removed,
573
+ * so the recovery hint points at an ABSOLUTE path (works from any cwd; a
574
+ * relative .worktrees/<slug> only resolves from the main repo root). A
575
+ * branch kept for another reason (checked out elsewhere, git error) states
576
+ * that reason itself instead of the generic unmerged framing.
577
+ */
578
+ function branchKeptMessage(outcome, branch, worktreePath) {
579
+ if (outcome.branchRemoved)
580
+ return "";
581
+ const slug = branch.replace(/^agent\//, "");
582
+ const recovery = `[yagni] branch ${branch} kept — recover with: ` +
583
+ `git worktree add "${join(dirname(worktreePath), slug)}" ${branch}\n`;
584
+ if (outcome.branchUnmerged)
585
+ return recovery;
586
+ const reason = (outcome.note ?? "unknown reason").replace(/\s+/g, " ").trim();
587
+ return `[yagni] branch ${branch} kept: ${reason}\n` + recovery;
588
+ }
496
589
  export const HELP_TEXT = [
497
590
  "YAGNI Code — a business-context-grounded terminal coding agent.",
498
591
  "",
@@ -49,6 +49,7 @@ import { makeDecisionCapture } from "./decisionCapture.js";
49
49
  import { registerAmbientRecall } from "./recall.js";
50
50
  import { resilientFetch } from "./resilientFetch.js";
51
51
  import { installUncaughtExceptionMonitor, makeCrashReporter, runningUnderTest } from "./crashReport.js";
52
+ import { createToolOutcomeBatcher } from "./toolOutcomes.js";
52
53
  import { flushSpool as defaultFlushSpool } from "./spool.js";
53
54
  import { makeAuthedFetch, makeTokenProvider } from "./tokenProvider.js";
54
55
  import { attributionHeaders, fetchCatalog as defaultFetchCatalog, fetchContextBrief as defaultFetchContextBrief, getToken, getTokenExpiresAt as defaultGetTokenExpiresAt, getWorkspaceId as defaultGetWorkspaceId, isDriverCaller, resolveBaseUrl, tokenExpiryNotice, } from "./config.js";
@@ -430,6 +431,30 @@ export async function registerYagni(pi, deps = {}) {
430
431
  previousMode = m;
431
432
  });
432
433
  }
434
+ // Tool-outcome counts for the fleet dashboards (toolOutcomes.ts): which
435
+ // tool failures are the model's normal business and which are the tool
436
+ // machinery breaking. Counts and a closed reason vocabulary only; off in
437
+ // eval mode and under the crash-report opt-out; posted on a timer and at
438
+ // session shutdown, fail-soft throughout.
439
+ const toolOutcomes = createToolOutcomeBatcher({
440
+ baseUrl,
441
+ getToken: getTokenFn,
442
+ headers: attributionHeaders(deps.env),
443
+ fetchImpl: deps.fetchImpl,
444
+ env: deps.env,
445
+ enabled: !evalMode,
446
+ });
447
+ pi.on("tool_execution_start", (event) => {
448
+ if (event?.toolCallId && event?.toolName)
449
+ toolOutcomes.toolStart(event.toolCallId, event.toolName);
450
+ });
451
+ pi.on("tool_execution_end", (event) => {
452
+ if (event?.toolCallId)
453
+ toolOutcomes.toolEnd(event.toolCallId, { isError: !!event.isError, result: event.result });
454
+ });
455
+ pi.on("session_shutdown", async () => {
456
+ await toolOutcomes.close();
457
+ });
433
458
  const footerInvalidateHandle = { invalidateGit: () => { }, requestRender: () => { } };
434
459
  const guardianState = makeGuardianState();
435
460
  // Disabled by the local env override OR the workspace kill switch
@@ -10,6 +10,8 @@
10
10
  * - **Add-only creation.** Every git op is `worktree add`, `fetch`, `show-ref`,
11
11
  * `symbolic-ref`, or `rev-parse`. Nothing deletes, force-resets, or
12
12
  * `branch -D`s — the worktree is DURABLE by default and never auto-removed.
13
+ * The ONE removal path is `removeSessionWorktree`, which runs only after an
14
+ * explicit "Remove worktree" choice at session exit (never automatic).
13
15
  * - **Get-or-resume.** An existing worktree dir is resumed, never recreated.
14
16
  * - **Lazy fetch.** Base `origin/<default>` is read from the local ref when
15
17
  * present; `git fetch` only runs when that ref is absent, and always with
@@ -23,6 +25,18 @@
23
25
  * (both gitignored in-repo). PR refs (`#N`, GitHub PR URLs) map to `pr-<N>` and
24
26
  * base on `FETCH_HEAD`.
25
27
  */
28
+ export interface RemoveSessionWorktreeResult {
29
+ /** True when the worktree directory is gone from disk. */
30
+ removed: boolean;
31
+ /** True when the `agent/<slug>` branch was also deleted (`-d`, safe delete). */
32
+ branchRemoved: boolean;
33
+ /** Non-fatal note when cleanup was attempted but could not finish. */
34
+ note?: string;
35
+ /** True when the branch was kept because it is not fully merged (the benign
36
+ * case the recovery hint describes); false when it was kept for another
37
+ * reason (checked out elsewhere, git error) — the note carries that. */
38
+ branchUnmerged?: boolean;
39
+ }
26
40
  export interface SessionWorktreeResult {
27
41
  /** Absolute destination (under `<mainRepo>/.worktrees/<slug>`). */
28
42
  worktreePath: string;
@@ -61,4 +75,29 @@ export declare function parsePRReference(input: string): number | null;
61
75
  * branch/worktree: validation happens first, and `git worktree add` is atomic.
62
76
  */
63
77
  export declare function createOrResume(name: string | undefined, deps: CreateOrResumeDeps): Promise<SessionWorktreeResult>;
78
+ /**
79
+ * Remove a session worktree the user explicitly chose to discard at exit.
80
+ *
81
+ * The user's only entry into this is the launcher's "Remove worktree" prompt;
82
+ * it is NEVER called automatically. Cleanup checks the worktree again at exit:
83
+ *
84
+ * - Require the original branch: removing a clean detached HEAD would discard
85
+ * its only reference and reflog, even without `--force`.
86
+ * - Refuse ignored content, which Git's dirty check does not protect. This
87
+ * includes dependency directories and local env files; review those manually.
88
+ * - `git worktree remove` without `--force` refuses tracked changes and
89
+ * non-ignored untracked files. `git branch -d` retains branches Git considers
90
+ * unmerged (against their upstream, or the main checkout's HEAD).
91
+ *
92
+ * These checks do not lock out concurrent edits by other sessions or Git clients.
93
+ *
94
+ * `repoCwd` is only used to locate the repo (resolved to the main repo root
95
+ * before any git op, same as `createOrResume`): the user may have launched
96
+ * `yagni -w` from inside the worktree being removed, and after `git worktree
97
+ * remove` that cwd no longer exists on disk.
98
+ */
99
+ export declare function removeSessionWorktree(worktreePath: string, branch: string, deps: {
100
+ repoCwd: string;
101
+ gitImpl?: SessionGit;
102
+ }): Promise<RemoveSessionWorktreeResult>;
64
103
  //# sourceMappingURL=sessionWorktree.d.ts.map
@@ -10,6 +10,8 @@
10
10
  * - **Add-only creation.** Every git op is `worktree add`, `fetch`, `show-ref`,
11
11
  * `symbolic-ref`, or `rev-parse`. Nothing deletes, force-resets, or
12
12
  * `branch -D`s — the worktree is DURABLE by default and never auto-removed.
13
+ * The ONE removal path is `removeSessionWorktree`, which runs only after an
14
+ * explicit "Remove worktree" choice at session exit (never automatic).
13
15
  * - **Get-or-resume.** An existing worktree dir is resumed, never recreated.
14
16
  * - **Lazy fetch.** Base `origin/<default>` is read from the local ref when
15
17
  * present; `git fetch` only runs when that ref is absent, and always with
@@ -103,7 +105,7 @@ async function resolveMainRepo(gitImpl, repoCwd) {
103
105
  topLevel = await gitImpl(["rev-parse", "--show-toplevel"], repoCwd);
104
106
  }
105
107
  catch (err) {
106
- throw new Error(`Cannot create a worktree: not inside a git repository. ` +
108
+ throw new Error(`Cannot resolve the repository root: not inside a git repository. ` +
107
109
  `${err instanceof Error ? err.message : String(err)}`);
108
110
  }
109
111
  const common = await gitImpl(["rev-parse", "--git-common-dir"], repoCwd);
@@ -222,4 +224,80 @@ export async function createOrResume(name, deps) {
222
224
  }
223
225
  return { worktreePath, branch, existed: false };
224
226
  }
227
+ /**
228
+ * Remove a session worktree the user explicitly chose to discard at exit.
229
+ *
230
+ * The user's only entry into this is the launcher's "Remove worktree" prompt;
231
+ * it is NEVER called automatically. Cleanup checks the worktree again at exit:
232
+ *
233
+ * - Require the original branch: removing a clean detached HEAD would discard
234
+ * its only reference and reflog, even without `--force`.
235
+ * - Refuse ignored content, which Git's dirty check does not protect. This
236
+ * includes dependency directories and local env files; review those manually.
237
+ * - `git worktree remove` without `--force` refuses tracked changes and
238
+ * non-ignored untracked files. `git branch -d` retains branches Git considers
239
+ * unmerged (against their upstream, or the main checkout's HEAD).
240
+ *
241
+ * These checks do not lock out concurrent edits by other sessions or Git clients.
242
+ *
243
+ * `repoCwd` is only used to locate the repo (resolved to the main repo root
244
+ * before any git op, same as `createOrResume`): the user may have launched
245
+ * `yagni -w` from inside the worktree being removed, and after `git worktree
246
+ * remove` that cwd no longer exists on disk.
247
+ */
248
+ export async function removeSessionWorktree(worktreePath, branch, deps) {
249
+ const gitImpl = deps.gitImpl ?? defaultGit;
250
+ // Resolve the MAIN repo root first: every later git op must run from a cwd
251
+ // that survives the removal (repoCwd may be the worktree being removed).
252
+ const repoRoot = await resolveMainRepo(gitImpl, deps.repoCwd);
253
+ try {
254
+ const ignored = await gitImpl(["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], worktreePath);
255
+ if (ignored) {
256
+ const paths = ignored.split("\0").filter(Boolean);
257
+ const preview = paths.slice(0, 5).map((path) => JSON.stringify(path)).join(", ");
258
+ const more = paths.length > 5 ? ` (+${paths.length - 5} more)` : "";
259
+ return {
260
+ removed: false,
261
+ branchRemoved: false,
262
+ note: `worktree contains ignored content: ${preview}${more}; review and remove it manually before retrying cleanup`,
263
+ };
264
+ }
265
+ const headRef = await gitImpl(["rev-parse", "--symbolic-full-name", "HEAD"], worktreePath);
266
+ if (headRef !== `refs/heads/${branch}`) {
267
+ return {
268
+ removed: false,
269
+ branchRemoved: false,
270
+ note: headRef === "HEAD"
271
+ ? "worktree HEAD is detached; preserve that work on a branch before retrying cleanup"
272
+ : `worktree HEAD is ${JSON.stringify(headRef)}, no longer on branch ${branch}; preserve that work before retrying cleanup`,
273
+ };
274
+ }
275
+ await gitImpl(["worktree", "remove", worktreePath], repoRoot);
276
+ }
277
+ catch (err) {
278
+ return {
279
+ removed: false,
280
+ branchRemoved: false,
281
+ note: err instanceof Error ? err.message : String(err),
282
+ };
283
+ }
284
+ // Safe delete: only removes a fully-merged branch. Unmerged work stays
285
+ // recoverable on the branch — the directory is gone but the ref is not.
286
+ try {
287
+ await gitImpl(["branch", "-d", branch], repoRoot);
288
+ return { removed: true, branchRemoved: true };
289
+ }
290
+ catch (err) {
291
+ // Distinguish the benign case (unmerged branch, deliberately kept) from
292
+ // a git-level failure so the exit summary can say which it was.
293
+ const message = err instanceof Error ? err.message : String(err);
294
+ const unmerged = /not fully merged|unmerged/i.test(message);
295
+ return {
296
+ removed: true,
297
+ branchRemoved: false,
298
+ branchUnmerged: unmerged,
299
+ ...(unmerged ? {} : { note: message }),
300
+ };
301
+ }
302
+ }
225
303
  //# sourceMappingURL=sessionWorktree.js.map
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tool-call outcome telemetry: counts per (tool family, outcome, reason),
3
+ * batched and posted to the backend's /api/yagni-code/tool-outcomes so the
4
+ * fleet dashboards can tell a NORMAL tool failure (the model ran a command
5
+ * that exited 1, read a file that is not there, tried an edit that did not
6
+ * match) from the tool machinery actually breaking (an MCP transport, an
7
+ * internal exception, a timeout).
8
+ *
9
+ * Content never leaves the machine: the classifier reads the result text
10
+ * locally and emits only a closed reason vocabulary, and tool names collapse
11
+ * to a closed family list HERE, before buffering, so an MCP server name never
12
+ * reaches the wire. The payload is family, outcome, reason, count, summed
13
+ * duration. Opt-out and test suppression follow the crash reporter
14
+ * (`YAGNI_DISABLE_CRASH_REPORTS=1` turns both off), eval mode is off like
15
+ * every other external side effect, and everything is fail-soft: one
16
+ * attempt, short timeout, never throws, never blocks a turn. A failed post is
17
+ * counted and written to the local error trail (source `telemetry`) so "why
18
+ * is the dashboard empty" has something to read.
19
+ */
20
+ export type ToolOutcome = "ok" | "expected_error" | "real_error";
21
+ export type ToolOutcomeReason = "ok" | "exit_nonzero" | "not_found" | "no_match" | "denied" | "cancelled" | "invalid_input" | "timeout" | "mcp_transport" | "internal" | "unknown";
22
+ export declare const TOOL_FAMILIES: readonly ["bash", "read", "edit", "write", "grep", "find", "ls", "mcp", "subagent", "web", "other"];
23
+ export type ToolFamily = (typeof TOOL_FAMILIES)[number];
24
+ /** Collapse a tool name to its family. Never returns anything outside TOOL_FAMILIES. */
25
+ export declare function toolFamilyOf(toolName: string): ToolFamily;
26
+ export interface ToolOutcomeClass {
27
+ outcome: ToolOutcome;
28
+ reason: ToolOutcomeReason;
29
+ }
30
+ /** Best-effort text from a pi tool result (string, content blocks, or an Error). */
31
+ export declare function toolResultText(result: unknown): string;
32
+ /**
33
+ * Classify one tool call. The order matters: transport and internal faults
34
+ * win over the softer patterns because a stack trace can mention a file.
35
+ */
36
+ export declare function classifyToolOutcome(input: {
37
+ toolName: string;
38
+ isError: boolean;
39
+ result?: unknown;
40
+ }): ToolOutcomeClass;
41
+ export interface ToolOutcomeSample {
42
+ /** A TOOL_FAMILIES value, never the raw tool name. */
43
+ tool: ToolFamily;
44
+ outcome: ToolOutcome;
45
+ reason: ToolOutcomeReason;
46
+ count: number;
47
+ durationMsSum: number;
48
+ }
49
+ export interface ToolOutcomeBatcherOpts {
50
+ baseUrl: string;
51
+ getToken: () => string | undefined;
52
+ /** Attribution headers (`x-yagni-caller` / session / run) from config.ts. */
53
+ headers?: Record<string, string>;
54
+ fetchImpl?: typeof fetch;
55
+ env?: NodeJS.ProcessEnv;
56
+ /** Flush cadence; the interval timer is unref'd so it never holds the process. */
57
+ flushIntervalMs?: number;
58
+ timeoutMs?: number;
59
+ now?: () => number;
60
+ /** Disabled entirely (eval mode, tests). */
61
+ enabled?: boolean;
62
+ /** Local trail sink for a failed post (defaults to the unified error sink). */
63
+ logSink?: (event: {
64
+ event: string;
65
+ fields: Record<string, unknown>;
66
+ }) => void;
67
+ }
68
+ export interface ToolOutcomeBatcher {
69
+ toolStart(toolCallId: string, toolName: string): void;
70
+ toolEnd(toolCallId: string, outcome: {
71
+ isError: boolean;
72
+ result?: unknown;
73
+ }): void;
74
+ /** Post whatever is buffered. Resolves on every outcome; never throws. */
75
+ flush(): Promise<void>;
76
+ /** Stop the timer and flush once. */
77
+ close(): Promise<void>;
78
+ /** Test/introspection seam: the buffered samples. */
79
+ pending(): ToolOutcomeSample[];
80
+ /** Batches that failed to post (network, non-2xx, no token) since start. */
81
+ dropped(): number;
82
+ }
83
+ export declare const TOOL_OUTCOME_FLUSH_INTERVAL_MS = 60000;
84
+ export declare const TOOL_OUTCOME_TIMEOUT_MS = 2000;
85
+ export declare function createToolOutcomeBatcher(opts: ToolOutcomeBatcherOpts): ToolOutcomeBatcher;
86
+ //# sourceMappingURL=toolOutcomes.d.ts.map
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Tool-call outcome telemetry: counts per (tool family, outcome, reason),
3
+ * batched and posted to the backend's /api/yagni-code/tool-outcomes so the
4
+ * fleet dashboards can tell a NORMAL tool failure (the model ran a command
5
+ * that exited 1, read a file that is not there, tried an edit that did not
6
+ * match) from the tool machinery actually breaking (an MCP transport, an
7
+ * internal exception, a timeout).
8
+ *
9
+ * Content never leaves the machine: the classifier reads the result text
10
+ * locally and emits only a closed reason vocabulary, and tool names collapse
11
+ * to a closed family list HERE, before buffering, so an MCP server name never
12
+ * reaches the wire. The payload is family, outcome, reason, count, summed
13
+ * duration. Opt-out and test suppression follow the crash reporter
14
+ * (`YAGNI_DISABLE_CRASH_REPORTS=1` turns both off), eval mode is off like
15
+ * every other external side effect, and everything is fail-soft: one
16
+ * attempt, short timeout, never throws, never blocks a turn. A failed post is
17
+ * counted and written to the local error trail (source `telemetry`) so "why
18
+ * is the dashboard empty" has something to read.
19
+ */
20
+ import { crashReportsSuppressed } from "./crashReport.js";
21
+ import { logEvent } from "./errorSink.js";
22
+ import { isDesktopSurface } from "./surface.js";
23
+ export const TOOL_FAMILIES = ["bash", "read", "edit", "write", "grep", "find", "ls", "mcp", "subagent", "web", "other"];
24
+ /** Collapse a tool name to its family. Never returns anything outside TOOL_FAMILIES. */
25
+ export function toolFamilyOf(toolName) {
26
+ const name = toolName.toLowerCase();
27
+ if (name.startsWith("mcp__") || name.startsWith("mcp:"))
28
+ return "mcp";
29
+ if (name === "bash" || name === "shell" || name === "exec")
30
+ return "bash";
31
+ if (name === "read" || name === "read_file" || name === "view")
32
+ return "read";
33
+ if (name === "edit" || name === "multiedit" || name === "str_replace")
34
+ return "edit";
35
+ if (name === "write" || name === "write_file" || name === "create")
36
+ return "write";
37
+ if (name === "grep" || name === "search")
38
+ return "grep";
39
+ if (name === "find" || name === "glob")
40
+ return "find";
41
+ if (name === "ls" || name === "list")
42
+ return "ls";
43
+ if (name.includes("subagent") || name === "agent" || name === "task")
44
+ return "subagent";
45
+ if (name.startsWith("web") || name.includes("fetch") || name.includes("browser"))
46
+ return "web";
47
+ return "other";
48
+ }
49
+ /** Only this much of a result is inspected; the classifier is pattern-based. */
50
+ const CLASSIFY_TEXT_CAP = 4_000;
51
+ /** Best-effort text from a pi tool result (string, content blocks, or an Error). */
52
+ export function toolResultText(result) {
53
+ if (typeof result === "string")
54
+ return result.slice(0, CLASSIFY_TEXT_CAP);
55
+ if (result instanceof Error)
56
+ return `${result.name}: ${result.message}`.slice(0, CLASSIFY_TEXT_CAP);
57
+ if (typeof result !== "object" || result === null)
58
+ return "";
59
+ const r = result;
60
+ if (typeof r.text === "string")
61
+ return r.text.slice(0, CLASSIFY_TEXT_CAP);
62
+ if (typeof r.error === "string")
63
+ return r.error.slice(0, CLASSIFY_TEXT_CAP);
64
+ if (typeof r.message === "string")
65
+ return r.message.slice(0, CLASSIFY_TEXT_CAP);
66
+ if (Array.isArray(r.content)) {
67
+ const parts = [];
68
+ let size = 0;
69
+ for (const block of r.content) {
70
+ const text = typeof block === "string" ? block : block?.text;
71
+ if (typeof text !== "string")
72
+ continue;
73
+ parts.push(text);
74
+ size += text.length;
75
+ if (size >= CLASSIFY_TEXT_CAP)
76
+ break;
77
+ }
78
+ return parts.join("\n").slice(0, CLASSIFY_TEXT_CAP);
79
+ }
80
+ return "";
81
+ }
82
+ const NOT_FOUND_RE = /no such file|not found|does not exist|enoent|cannot find|unknown file|no matches? found/i;
83
+ const NO_MATCH_RE = /old_string|did not match|not unique|could not find the (?:string|text)|no occurrences|nothing to replace/i;
84
+ const DENIED_RE = /permission denied|denied by|blocked by|not allowed|not permitted|refused|guardian|plan mode|requires approval|eacces|eperm/i;
85
+ const CANCELLED_RE = /cancel+ed|aborted|interrupted|user (?:declined|rejected|stopped)/i;
86
+ const INVALID_RE = /invalid (?:argument|input|json|parameter)|missing required|expected .* to be|is required|malformed|validation/i;
87
+ const TIMEOUT_RE = /timed? ?out|deadline exceeded|etimedout/i;
88
+ const MCP_RE = /mcp|transport|econnrefused|econnreset|socket hang up|server (?:closed|disconnected|unavailable)|jsonrpc|connection (?:closed|lost|refused)/i;
89
+ const INTERNAL_RE = /^(?:type|reference|range|syntax)error\b|internal error|unhandled|stack trace|cannot read propert|is not a function|undefined is not/i;
90
+ const EXIT_RE = /exit(?:ed)? (?:with )?(?:code|status)[: ]+(\d+)|command failed|non-zero exit|\bexit code\b/i;
91
+ /**
92
+ * Classify one tool call. The order matters: transport and internal faults
93
+ * win over the softer patterns because a stack trace can mention a file.
94
+ */
95
+ export function classifyToolOutcome(input) {
96
+ if (!input.isError)
97
+ return { outcome: "ok", reason: "ok" };
98
+ const text = toolResultText(input.result);
99
+ // Accepts a raw tool name or an already-collapsed family (the batcher
100
+ // classifies by family).
101
+ const mcp = input.toolName === "mcp" || toolFamilyOf(input.toolName) === "mcp";
102
+ if (INTERNAL_RE.test(text))
103
+ return { outcome: "real_error", reason: "internal" };
104
+ if (mcp && MCP_RE.test(text))
105
+ return { outcome: "real_error", reason: "mcp_transport" };
106
+ if (TIMEOUT_RE.test(text))
107
+ return { outcome: "real_error", reason: "timeout" };
108
+ if (CANCELLED_RE.test(text))
109
+ return { outcome: "expected_error", reason: "cancelled" };
110
+ if (DENIED_RE.test(text))
111
+ return { outcome: "expected_error", reason: "denied" };
112
+ if (NO_MATCH_RE.test(text))
113
+ return { outcome: "expected_error", reason: "no_match" };
114
+ if (NOT_FOUND_RE.test(text))
115
+ return { outcome: "expected_error", reason: "not_found" };
116
+ if (INVALID_RE.test(text))
117
+ return { outcome: "expected_error", reason: "invalid_input" };
118
+ if (EXIT_RE.test(text) || input.toolName === "bash")
119
+ return { outcome: "expected_error", reason: "exit_nonzero" };
120
+ if (!mcp && MCP_RE.test(text))
121
+ return { outcome: "real_error", reason: "mcp_transport" };
122
+ return { outcome: "real_error", reason: "unknown" };
123
+ }
124
+ export const TOOL_OUTCOME_FLUSH_INTERVAL_MS = 60_000;
125
+ export const TOOL_OUTCOME_TIMEOUT_MS = 2_000;
126
+ const MAX_TRACKED_STARTS = 512;
127
+ export function createToolOutcomeBatcher(opts) {
128
+ const env = opts.env ?? process.env;
129
+ const now = opts.now ?? Date.now;
130
+ const enabled = (opts.enabled ?? true) && !crashReportsSuppressed(env);
131
+ const logSink = opts.logSink ??
132
+ ((e) => logEvent({ source: "telemetry", level: "warn", event: e.event, fields: e.fields, sessionId: env.YAGNI_SESSION_ID }));
133
+ const starts = new Map();
134
+ const buffer = new Map();
135
+ let timer;
136
+ let closed = false;
137
+ let droppedBatches = 0;
138
+ const key = (tool, outcome, reason) => `${tool}|${outcome}|${reason}`;
139
+ const record = (tool, cls, durationMs) => {
140
+ const k = key(tool, cls.outcome, cls.reason);
141
+ const existing = buffer.get(k);
142
+ if (existing) {
143
+ existing.count += 1;
144
+ existing.durationMsSum += durationMs;
145
+ return;
146
+ }
147
+ buffer.set(k, { tool, outcome: cls.outcome, reason: cls.reason, count: 1, durationMsSum: durationMs });
148
+ };
149
+ const drop = (samples, fields) => {
150
+ droppedBatches += 1;
151
+ try {
152
+ logSink({ event: "tool_outcomes_post_failed", fields: { ...fields, samples: samples.length, droppedBatches } });
153
+ }
154
+ catch {
155
+ // the trail is best-effort
156
+ }
157
+ };
158
+ const flush = async () => {
159
+ if (!enabled || buffer.size === 0)
160
+ return;
161
+ const samples = [...buffer.values()];
162
+ buffer.clear();
163
+ try {
164
+ // The token getter is fail-soft too: a throwing provider must not
165
+ // reject the timer's `void flush()` or surface at shutdown.
166
+ const token = opts.getToken();
167
+ if (!token) {
168
+ drop(samples, { kind: "no_token" });
169
+ return;
170
+ }
171
+ const fetchImpl = opts.fetchImpl ?? fetch;
172
+ const controller = new AbortController();
173
+ const t = setTimeout(() => controller.abort(), opts.timeoutMs ?? TOOL_OUTCOME_TIMEOUT_MS);
174
+ t.unref?.();
175
+ try {
176
+ const res = await fetchImpl(`${opts.baseUrl.replace(/\/$/, "")}/api/yagni-code/tool-outcomes`, {
177
+ method: "POST",
178
+ headers: {
179
+ "content-type": "application/json",
180
+ authorization: `Bearer ${token}`,
181
+ ...(opts.headers ?? {}),
182
+ },
183
+ body: JSON.stringify({
184
+ client: isDesktopSurface() ? "desktop" : "cli",
185
+ clientVersion: env.YAGNI_CODE_VERSION?.trim() || "unknown",
186
+ samples,
187
+ }),
188
+ signal: controller.signal,
189
+ });
190
+ if (!res.ok)
191
+ drop(samples, { kind: "http", status: res.status });
192
+ }
193
+ finally {
194
+ clearTimeout(t);
195
+ }
196
+ }
197
+ catch (err) {
198
+ drop(samples, { kind: "network", error: err instanceof Error ? err.name : "unknown" });
199
+ }
200
+ };
201
+ if (enabled) {
202
+ timer = setInterval(() => { void flush(); }, opts.flushIntervalMs ?? TOOL_OUTCOME_FLUSH_INTERVAL_MS);
203
+ timer.unref?.();
204
+ }
205
+ return {
206
+ toolStart(toolCallId, toolName) {
207
+ if (!enabled || closed)
208
+ return;
209
+ if (starts.size >= MAX_TRACKED_STARTS)
210
+ starts.clear();
211
+ starts.set(toolCallId, { family: toolFamilyOf(toolName), startedAt: now() });
212
+ },
213
+ toolEnd(toolCallId, outcome) {
214
+ if (!enabled || closed)
215
+ return;
216
+ const slot = starts.get(toolCallId);
217
+ starts.delete(toolCallId);
218
+ const family = slot?.family ?? "other";
219
+ const durationMs = slot ? Math.max(0, now() - slot.startedAt) : 0;
220
+ try {
221
+ record(family, classifyToolOutcome({ toolName: family, isError: outcome.isError, result: outcome.result }), durationMs);
222
+ }
223
+ catch {
224
+ // classification must never break a tool result
225
+ }
226
+ },
227
+ flush,
228
+ async close() {
229
+ if (closed)
230
+ return;
231
+ closed = true;
232
+ if (timer)
233
+ clearInterval(timer);
234
+ await flush();
235
+ },
236
+ pending: () => [...buffer.values()],
237
+ dropped: () => droppedBatches,
238
+ };
239
+ }
240
+ //# sourceMappingURL=toolOutcomes.js.map
@@ -33,6 +33,17 @@ export declare function parsePRReference(input: string): number | null;
33
33
  * with a clear message (surfaced by the caller).
34
34
  */
35
35
  export declare function validateWorktreeSlug(slug: string): void;
36
+ /**
37
+ * Whether a `-w` session exit can show the interactive Keep/Remove prompt.
38
+ *
39
+ * The prompt is an interactive-TUI-only affordance. It is suppressed for:
40
+ * - `--output-format json|stream-json` (machine-readable stdout must stay a
41
+ * single clean result object; a trailing readline prompt would corrupt it);
42
+ * - `-p`/`--print` one-shot runs (they exit, they never linger for a prompt);
43
+ * - non-TTY stdin (piped/CI runs must not hang on a question nobody answers).
44
+ * The default `text` format with a TTY is the only path that prompts.
45
+ */
46
+ export declare function canPromptWorktreeCleanup(outputFormat: string, remainingArgs: string[], isTTY: boolean): boolean;
36
47
  /**
37
48
  * Validate the `-w` name + argv before any side effect. Returns a user-facing
38
49
  * error message when the launch should be refused, or `undefined` to proceed.
@@ -73,6 +73,23 @@ export function validateWorktreeSlug(slug) {
73
73
  }
74
74
  }
75
75
  }
76
+ /**
77
+ * Whether a `-w` session exit can show the interactive Keep/Remove prompt.
78
+ *
79
+ * The prompt is an interactive-TUI-only affordance. It is suppressed for:
80
+ * - `--output-format json|stream-json` (machine-readable stdout must stay a
81
+ * single clean result object; a trailing readline prompt would corrupt it);
82
+ * - `-p`/`--print` one-shot runs (they exit, they never linger for a prompt);
83
+ * - non-TTY stdin (piped/CI runs must not hang on a question nobody answers).
84
+ * The default `text` format with a TTY is the only path that prompts.
85
+ */
86
+ export function canPromptWorktreeCleanup(outputFormat, remainingArgs, isTTY) {
87
+ if (outputFormat !== "text")
88
+ return false;
89
+ if (!isTTY)
90
+ return false;
91
+ return !remainingArgs.some((a) => a === "-p" || a === "--print" || a.startsWith("--print="));
92
+ }
76
93
  /**
77
94
  * Validate the `-w` name + argv before any side effect. Returns a user-facing
78
95
  * error message when the launch should be refused, or `undefined` to proceed.
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The arrow-key Keep/Remove list shown after an interactive `-w` session exits.
3
+ *
4
+ * Claude Code parity: a two-option list navigated with the up/down arrows and
5
+ * confirmed with Enter — not a y/N question. Keep is the default (highlighted
6
+ * first), and every escape hatch — Esc, q, Ctrl+C, EOF, a non-TTY stream —
7
+ * fails safe to Keep (resolves `undefined`, which callers treat as keep).
8
+ *
9
+ * This module owns the raw-TTY interaction only. The gating (which launches
10
+ * may prompt at all) lives in `canPromptWorktreeCleanup` (worktreeArgs.ts),
11
+ * and the git behavior lives in the extension's `removeSessionWorktree`.
12
+ */
13
+ export type WorktreeExitChoice = "keep" | "remove";
14
+ /** Minimal stream shape the prompt needs (satisfied by process.stdin/stderr). */
15
+ export interface ExitPromptStream {
16
+ write(chunk: string): unknown;
17
+ isTTY?: boolean;
18
+ }
19
+ /** The structural slice of a readline keypress event. */
20
+ export interface ExitPromptKey {
21
+ name?: string;
22
+ ctrl?: boolean;
23
+ }
24
+ export type ExitPromptAction = {
25
+ type: "toggle";
26
+ } | {
27
+ type: "confirm";
28
+ } | {
29
+ type: "abort";
30
+ } | {
31
+ type: "noop";
32
+ };
33
+ /**
34
+ * Map a keypress to its prompt action (pure, unit-tested). Arrows (plus
35
+ * k/j/tab) toggle between the two options; Enter or Space confirms; Esc, q,
36
+ * Ctrl+C, and Ctrl+D abort (fail-safe to Keep); anything else is ignored so
37
+ * stray keys can neither confirm nor abort by accident.
38
+ */
39
+ export declare function exitPromptAction(key: ExitPromptKey): ExitPromptAction;
40
+ /**
41
+ * Show the Keep/Remove list and resolve with the user's choice.
42
+ *
43
+ * Resolves `"keep"`/`"remove"` on Enter, or `undefined` when aborted
44
+ * (Esc/q/Ctrl+C/Ctrl+D/EOF) or when either stream is not a TTY — callers treat
45
+ * `undefined` exactly like `"keep"`: nothing is removed.
46
+ */
47
+ export declare function promptKeepOrRemoveWorktree(opts: {
48
+ input: NodeJS.ReadStream & {
49
+ isTTY?: boolean;
50
+ };
51
+ output: ExitPromptStream;
52
+ label: string;
53
+ }): Promise<WorktreeExitChoice | undefined>;
54
+ //# sourceMappingURL=worktreeExitPrompt.d.ts.map
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The arrow-key Keep/Remove list shown after an interactive `-w` session exits.
3
+ *
4
+ * Claude Code parity: a two-option list navigated with the up/down arrows and
5
+ * confirmed with Enter — not a y/N question. Keep is the default (highlighted
6
+ * first), and every escape hatch — Esc, q, Ctrl+C, EOF, a non-TTY stream —
7
+ * fails safe to Keep (resolves `undefined`, which callers treat as keep).
8
+ *
9
+ * This module owns the raw-TTY interaction only. The gating (which launches
10
+ * may prompt at all) lives in `canPromptWorktreeCleanup` (worktreeArgs.ts),
11
+ * and the git behavior lives in the extension's `removeSessionWorktree`.
12
+ */
13
+ import { emitKeypressEvents } from "node:readline";
14
+ const OPTION_KEEP = "Keep worktree";
15
+ const OPTION_REMOVE = "Remove worktree";
16
+ /**
17
+ * Map a keypress to its prompt action (pure, unit-tested). Arrows (plus
18
+ * k/j/tab) toggle between the two options; Enter or Space confirms; Esc, q,
19
+ * Ctrl+C, and Ctrl+D abort (fail-safe to Keep); anything else is ignored so
20
+ * stray keys can neither confirm nor abort by accident.
21
+ */
22
+ export function exitPromptAction(key) {
23
+ if (key.ctrl && (key.name === "c" || key.name === "d"))
24
+ return { type: "abort" };
25
+ switch (key.name) {
26
+ case "up":
27
+ case "down":
28
+ case "k":
29
+ case "j":
30
+ case "tab":
31
+ return { type: "toggle" };
32
+ case "return":
33
+ case "enter":
34
+ case "space":
35
+ return { type: "confirm" };
36
+ case "escape":
37
+ case "q":
38
+ return { type: "abort" };
39
+ default:
40
+ return { type: "noop" };
41
+ }
42
+ }
43
+ function renderOptions(output, index) {
44
+ const marker = (i) => (i === index ? "❯ " : " ");
45
+ output.write(`\r\x1b[2K${marker(0)}${OPTION_KEEP}\n`);
46
+ output.write(`\r\x1b[2K${marker(1)}${OPTION_REMOVE}\n`);
47
+ }
48
+ /** Erase the two option lines, leaving the cursor just below where they were. */
49
+ function eraseOptions(output) {
50
+ output.write("\x1b[2A\r\x1b[2K\x1b[1B\r\x1b[2K\x1b[1B");
51
+ }
52
+ /**
53
+ * Show the Keep/Remove list and resolve with the user's choice.
54
+ *
55
+ * Resolves `"keep"`/`"remove"` on Enter, or `undefined` when aborted
56
+ * (Esc/q/Ctrl+C/Ctrl+D/EOF) or when either stream is not a TTY — callers treat
57
+ * `undefined` exactly like `"keep"`: nothing is removed.
58
+ */
59
+ export async function promptKeepOrRemoveWorktree(opts) {
60
+ const { input, output, label } = opts;
61
+ // Fail safe when the terminal cannot render an interactive list.
62
+ if (input.isTTY !== true || output.isTTY !== true)
63
+ return undefined;
64
+ let index = 0; // Keep is the default
65
+ output.write(`\n[yagni] ${label}\n`);
66
+ renderOptions(output, index);
67
+ const canRawMode = typeof input.setRawMode === "function";
68
+ const wasRaw = canRawMode ? input.isRaw === true : false;
69
+ if (canRawMode)
70
+ input.setRawMode(true);
71
+ emitKeypressEvents(input);
72
+ input.resume();
73
+ try {
74
+ return await new Promise((resolve) => {
75
+ const cleanup = () => {
76
+ input.removeListener("keypress", onKeypress);
77
+ input.removeListener("end", onEnd);
78
+ };
79
+ const onKeypress = (_str, key) => {
80
+ const action = exitPromptAction(key ?? {});
81
+ if (action.type === "noop")
82
+ return;
83
+ if (action.type === "toggle") {
84
+ index = index === 0 ? 1 : 0;
85
+ eraseOptions(output);
86
+ renderOptions(output, index);
87
+ return;
88
+ }
89
+ cleanup();
90
+ eraseOptions(output);
91
+ resolve(action.type === "confirm" ? (index === 0 ? "keep" : "remove") : undefined);
92
+ };
93
+ // EOF (Ctrl+D is caught above as abort; a closed stdin arrives here).
94
+ const onEnd = () => {
95
+ cleanup();
96
+ resolve(undefined);
97
+ };
98
+ input.on("keypress", onKeypress);
99
+ input.on("end", onEnd);
100
+ });
101
+ }
102
+ finally {
103
+ if (canRawMode)
104
+ input.setRawMode(wasRaw);
105
+ input.pause();
106
+ }
107
+ }
108
+ //# sourceMappingURL=worktreeExitPrompt.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yagni-app/code-staging",
3
- "version": "1.1.0-staging.1328.1",
3
+ "version": "1.1.0-staging.1334.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)",
@@ -58,5 +58,5 @@
58
58
  "turndown": "^7.2.4",
59
59
  "typebox": "^1.3.15"
60
60
  },
61
- "yagniSourceSha": "34b64c98d42aa85f408a4f85de7ef103722038f7"
61
+ "yagniSourceSha": "b7b91c0bf94f197fc16643cb9a5c5f580e896001"
62
62
  }