@yagni-app/code-staging 1.1.0-staging.1329.1 → 1.1.0-staging.1335.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 +52 -0
- package/dist/cli.js +101 -8
- package/dist/extension/index.js +1 -0
- package/dist/extension/pipeline/sessionWorktree.d.ts +39 -0
- package/dist/extension/pipeline/sessionWorktree.js +79 -1
- package/dist/extension/sandbox/bash.d.ts +20 -1
- package/dist/extension/sandbox/bash.js +110 -7
- package/dist/extension/sandbox/session.d.ts +5 -1
- package/dist/extension/sandbox/session.js +28 -4
- package/dist/extension/telemetry/attrs.d.ts +1 -0
- package/dist/extension/telemetry/attrs.js +1 -0
- package/dist/extension/telemetry/register.d.ts +2 -0
- package/dist/extension/telemetry/register.js +2 -0
- package/dist/extension/telemetry/tracker.d.ts +4 -0
- package/dist/extension/telemetry/tracker.js +7 -1
- package/dist/worktreeArgs.d.ts +11 -0
- package/dist/worktreeArgs.js +17 -0
- package/dist/worktreeExitPrompt.d.ts +54 -0
- package/dist/worktreeExitPrompt.js +108 -0
- package/package.json +2 -2
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.
|
|
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
|
-
//
|
|
490
|
-
//
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
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
|
"",
|
package/dist/extension/index.js
CHANGED
|
@@ -541,6 +541,7 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
541
541
|
cwd: process.cwd(),
|
|
542
542
|
env,
|
|
543
543
|
hasUI: (ctx) => ctx.hasUI,
|
|
544
|
+
onShellResolutionRetry: (outcome) => telemetry.sandboxShellResolutionRetry(outcome),
|
|
544
545
|
// Anchors project-protected paths (.yagni-code + its config.json in
|
|
545
546
|
// denyWrite) and project-sourced permission rules to the repo the
|
|
546
547
|
// session runs in — same root the gate uses for its rule anchoring.
|
|
@@ -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
|
|
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
|
|
@@ -61,8 +61,27 @@ export declare function makeSandboxSpawnHook(manager: YagniSandboxManager): Bash
|
|
|
61
61
|
* Wrap a raw command for OS-sandboxed execution. Returns the original string
|
|
62
62
|
* when the manager is not initialized (fail-open to plain execution — the
|
|
63
63
|
* permission gate still ran; the sandbox simply is not active).
|
|
64
|
+
*
|
|
65
|
+
* Transient shell-resolution failures (isShellResolutionFailure — srt's 1s
|
|
66
|
+
* `which` spawn timing out under load) are retried once after a short
|
|
67
|
+
* backoff; a second failure surfaces the original error. Any other error
|
|
68
|
+
* propagates immediately, no retry.
|
|
64
69
|
*/
|
|
65
|
-
export
|
|
70
|
+
export interface PreWrapRetryOpts {
|
|
71
|
+
/** Abort the backoff sleep (the tool call's signal). */
|
|
72
|
+
signal?: AbortSignal;
|
|
73
|
+
/** Backoff before the single retry. Tests pass a tiny value to stay fast. */
|
|
74
|
+
backoffMs?: number;
|
|
75
|
+
/** Instrumentation: fired once, with the retry's terminal outcome. */
|
|
76
|
+
onRetry?: (outcome: "recovered" | "exhausted") => void;
|
|
77
|
+
}
|
|
78
|
+
export declare function preWrappedCommand(manager: YagniSandboxManager, command: string, binShell?: string, opts?: PreWrapRetryOpts): Promise<string>;
|
|
79
|
+
export declare function _setShellResolutionRetryBackoffForTest(ms: number | null): void;
|
|
80
|
+
/** srt's transient shell-resolution failure: `Shell '<name>' not found in
|
|
81
|
+
* PATH`, thrown from wrapCommandWithSandbox when which.js's 1s
|
|
82
|
+
* spawnSync('which') times out under load. Self-healing after ~15–30s;
|
|
83
|
+
* preWrappedCommand retries it once. */
|
|
84
|
+
export declare function isShellResolutionFailure(err: unknown): boolean;
|
|
66
85
|
/**
|
|
67
86
|
* Detect "Operation not permitted" style sandbox denials in bash output so
|
|
68
87
|
* callers (tool_result) can annotate and the model can react. Returns the
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
* lessons (process-group kill, stdio release).
|
|
22
22
|
*/
|
|
23
23
|
import { existsSync } from "node:fs";
|
|
24
|
+
import { logEvent } from "../errorSink.js";
|
|
25
|
+
import { scrubSecrets } from "../pipeline/scrubSecrets.js";
|
|
24
26
|
function stripLeadingSafeEnvVars(command) {
|
|
25
27
|
// Best-effort normalization for excludedCommands matching (not a security
|
|
26
28
|
// boundary — the permission gate is). Strips leading VAR=val pairs whose
|
|
@@ -112,15 +114,116 @@ export function makeSandboxSpawnHook(manager) {
|
|
|
112
114
|
env: { ...ctx.env, ...extraEnv },
|
|
113
115
|
});
|
|
114
116
|
}
|
|
115
|
-
|
|
116
|
-
* Wrap a raw command for OS-sandboxed execution. Returns the original string
|
|
117
|
-
* when the manager is not initialized (fail-open to plain execution — the
|
|
118
|
-
* permission gate still ran; the sandbox simply is not active).
|
|
119
|
-
*/
|
|
120
|
-
export async function preWrappedCommand(manager, command, binShell) {
|
|
117
|
+
export async function preWrappedCommand(manager, command, binShell, opts = {}) {
|
|
121
118
|
if (!manager.initialized)
|
|
122
119
|
return command;
|
|
123
|
-
|
|
120
|
+
try {
|
|
121
|
+
return await manager.wrapWithSandbox(command, binShell);
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (!isShellResolutionFailure(err))
|
|
125
|
+
throw err;
|
|
126
|
+
}
|
|
127
|
+
// The failed attempt may have left per-command state behind (the wrap can
|
|
128
|
+
// start helpers before it throws); clear it so the retry starts clean and
|
|
129
|
+
// the exec path's cleanupAfterCommand never sees double state. Best-effort:
|
|
130
|
+
// a cleanup throw must never swallow the original error or kill the retry.
|
|
131
|
+
try {
|
|
132
|
+
manager.cleanupAfterCommand();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
// Best-effort cleanup failed — the retry still runs (the exec path
|
|
136
|
+
// cleans up again); a fully silent swallow would hide a leaked-helper
|
|
137
|
+
// state, so the failure gets a visible trail. The message rides the
|
|
138
|
+
// line (the error class alone is always the literal "Error" for plain
|
|
139
|
+
// throws) and is run through scrubSecrets at WRITE time so the line
|
|
140
|
+
// is credential-scrubbed before any reader. That is pattern-scrub
|
|
141
|
+
// only, not a general redactor — readSessionTrail adds the same
|
|
142
|
+
// pattern pass at read time; the durable errors-*.jsonl stays
|
|
143
|
+
// unredacted by the sink's documented design (local file, never
|
|
144
|
+
// uploaded raw). Same posture as sandbox_persist_failed.
|
|
145
|
+
logEvent({
|
|
146
|
+
source: "sandbox",
|
|
147
|
+
level: "warn",
|
|
148
|
+
event: "shell_retry_cleanup_failed",
|
|
149
|
+
fields: {
|
|
150
|
+
error: scrubSecrets(err instanceof Error ? `${err.constructor.name}: ${err.message}` : String(err)),
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
await sleep(effectiveBackoffMs(opts.backoffMs), opts.signal);
|
|
155
|
+
try {
|
|
156
|
+
const wrapped = await manager.wrapWithSandbox(command, binShell);
|
|
157
|
+
opts.onRetry?.("recovered");
|
|
158
|
+
return wrapped;
|
|
159
|
+
}
|
|
160
|
+
catch (err) {
|
|
161
|
+
// `exhausted` is the shell-resolution health signal — report it only
|
|
162
|
+
// when the second failure is the SAME transient; an unrelated retry
|
|
163
|
+
// error propagates as-is without polluting the metric.
|
|
164
|
+
if (isShellResolutionFailure(err))
|
|
165
|
+
opts.onRetry?.("exhausted");
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Default backoff before the single shell-resolution retry. The observed
|
|
170
|
+
* upstream transient self-heals in ~15–30s; this is a bounded bridge, not
|
|
171
|
+
* a guarantee — a still-failing second attempt surfaces the original error. */
|
|
172
|
+
const SHELL_RESOLUTION_RETRY_BACKOFF_MS = 2000;
|
|
173
|
+
/** Test seam for the backoff: session-layer tests (composition, user_bash)
|
|
174
|
+
* drive the retry without a backoffMs opt, so they shrink the module-level
|
|
175
|
+
* default instead of sleeping a real 2s. Module-scoped (never a globalThis
|
|
176
|
+
* key — a leaked value there would silently shrink the production backoff);
|
|
177
|
+
* restore in the same describe that sets it. */
|
|
178
|
+
let shellRetryBackoffMsForTest = null;
|
|
179
|
+
export function _setShellResolutionRetryBackoffForTest(ms) {
|
|
180
|
+
shellRetryBackoffMsForTest = ms;
|
|
181
|
+
}
|
|
182
|
+
function effectiveBackoffMs(override) {
|
|
183
|
+
return override ?? shellRetryBackoffMsForTest ?? SHELL_RESOLUTION_RETRY_BACKOFF_MS;
|
|
184
|
+
}
|
|
185
|
+
function sleep(ms, signal) {
|
|
186
|
+
// The abort rejection carries the caller's own abort reason when the
|
|
187
|
+
// signal has a real one — a context-free "aborted" would replace the
|
|
188
|
+
// meaningful error the caller (and the model) should see. Node's DEFAULT
|
|
189
|
+
// reason is a generic DOMException ("This operation was aborted") with no
|
|
190
|
+
// retry context, so it is replaced with a named message too.
|
|
191
|
+
const abortError = () => {
|
|
192
|
+
const reason = signal?.reason;
|
|
193
|
+
if (reason instanceof Error && reason.name !== "AbortError")
|
|
194
|
+
return reason;
|
|
195
|
+
// A non-Error reason is spec-legal (controller.abort("user cancelled"))
|
|
196
|
+
// — stringify it rather than silently dropping it for the generic name.
|
|
197
|
+
// The DEFAULT reason (AbortError DOMException) stays on the named
|
|
198
|
+
// message: stringifying it would read "aborted: This operation was
|
|
199
|
+
// aborted".
|
|
200
|
+
if (reason !== undefined && reason !== null && !(reason instanceof Error)) {
|
|
201
|
+
return new Error(`sandbox shell-resolution retry aborted: ${String(reason)}`);
|
|
202
|
+
}
|
|
203
|
+
return new Error("sandbox shell-resolution retry aborted");
|
|
204
|
+
};
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
if (signal?.aborted) {
|
|
207
|
+
reject(abortError());
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
const t = setTimeout(() => {
|
|
211
|
+
signal?.removeEventListener("abort", onAbort);
|
|
212
|
+
resolve();
|
|
213
|
+
}, ms);
|
|
214
|
+
const onAbort = () => {
|
|
215
|
+
clearTimeout(t);
|
|
216
|
+
reject(abortError());
|
|
217
|
+
};
|
|
218
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
/** srt's transient shell-resolution failure: `Shell '<name>' not found in
|
|
222
|
+
* PATH`, thrown from wrapCommandWithSandbox when which.js's 1s
|
|
223
|
+
* spawnSync('which') times out under load. Self-healing after ~15–30s;
|
|
224
|
+
* preWrappedCommand retries it once. */
|
|
225
|
+
export function isShellResolutionFailure(err) {
|
|
226
|
+
return err instanceof Error && /^Shell '[^']*' not found in PATH$/.test(err.message);
|
|
124
227
|
}
|
|
125
228
|
/**
|
|
126
229
|
* Detect "Operation not permitted" style sandbox denials in bash output so
|
|
@@ -28,7 +28,7 @@ import type { PermissionRule } from "../permissionRules/loadConfig.js";
|
|
|
28
28
|
* here would be overwritten and the sandbox would silently never reach
|
|
29
29
|
* model-driven tool calls.
|
|
30
30
|
*/
|
|
31
|
-
export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string): (def: ToolDefinition) => ToolDefinition;
|
|
31
|
+
export declare function makeBashComposition(manager: YagniSandboxManager, settings: () => SandboxSettings, cwd: string, onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void): (def: ToolDefinition) => ToolDefinition;
|
|
32
32
|
export interface SandboxSessionHandle {
|
|
33
33
|
manager: YagniSandboxManager;
|
|
34
34
|
settings: () => SandboxSettings;
|
|
@@ -76,6 +76,10 @@ export interface RegisterSandboxOptions {
|
|
|
76
76
|
* registration (the duplicate-name 400 + silent-unwrap lesson from M4).
|
|
77
77
|
*/
|
|
78
78
|
registerOwnBash?: boolean;
|
|
79
|
+
/** Shell-resolution retry instrumentation (the OTel counter — index.ts
|
|
80
|
+
* wires the telemetry handle). Absent (eval mode, tests) ⇒ only the local
|
|
81
|
+
* sink line fires. */
|
|
82
|
+
onShellResolutionRetry?: (outcome: "recovered" | "exhausted") => void;
|
|
79
83
|
}
|
|
80
84
|
/**
|
|
81
85
|
* Register the sandbox surfaces on the ExtensionAPI. Returns the session
|
|
@@ -39,7 +39,7 @@ import { effectiveRules } from "../permissionRules/loadConfig.js";
|
|
|
39
39
|
* here would be overwritten and the sandbox would silently never reach
|
|
40
40
|
* model-driven tool calls.
|
|
41
41
|
*/
|
|
42
|
-
export function makeBashComposition(manager, settings, cwd) {
|
|
42
|
+
export function makeBashComposition(manager, settings, cwd, onShellResolutionRetry) {
|
|
43
43
|
return (def) => {
|
|
44
44
|
if (!settings().enabled)
|
|
45
45
|
return def;
|
|
@@ -115,9 +115,13 @@ export function makeBashComposition(manager, settings, cwd) {
|
|
|
115
115
|
if (!useSandbox) {
|
|
116
116
|
return def.execute(id, params, signal, onUpdate, ctx);
|
|
117
117
|
}
|
|
118
|
-
const execParams = { ...input, command: await preWrappedCommand(manager, input.command, binShell) };
|
|
119
118
|
let result;
|
|
120
119
|
try {
|
|
120
|
+
const command = await preWrappedCommand(manager, input.command, binShell, {
|
|
121
|
+
signal: signal ?? undefined,
|
|
122
|
+
onRetry: onShellResolutionRetry,
|
|
123
|
+
});
|
|
124
|
+
const execParams = { ...input, command };
|
|
121
125
|
result = await sandboxBash.execute(id, execParams, signal, onUpdate, ctx);
|
|
122
126
|
}
|
|
123
127
|
catch (err) {
|
|
@@ -309,7 +313,11 @@ export function registerSandbox(pi, opts) {
|
|
|
309
313
|
// registration when condensed is inactive (eval mode, classic rows,
|
|
310
314
|
// desktop). The composition is identity when the sandbox is disabled —
|
|
311
315
|
// default-off sessions stay byte-identical.
|
|
312
|
-
const
|
|
316
|
+
const onShellResolutionRetry = (outcome) => {
|
|
317
|
+
logShellResolutionRetry(outcome);
|
|
318
|
+
opts.onShellResolutionRetry?.(outcome);
|
|
319
|
+
};
|
|
320
|
+
const composeBash = makeBashComposition(manager, () => currentSettings, opts.cwd, onShellResolutionRetry);
|
|
313
321
|
const registerOwnBash = () => {
|
|
314
322
|
if (!currentSettings.enabled)
|
|
315
323
|
return;
|
|
@@ -345,7 +353,10 @@ export function registerSandbox(pi, opts) {
|
|
|
345
353
|
exec: async (command, cwd, { onData, signal, timeout, env }) => {
|
|
346
354
|
if (!existsSync(cwd))
|
|
347
355
|
throw new Error(`Working directory does not exist: ${cwd}`);
|
|
348
|
-
const wrapped = await manager
|
|
356
|
+
const wrapped = await preWrappedCommand(manager, command, shell, {
|
|
357
|
+
signal,
|
|
358
|
+
onRetry: onShellResolutionRetry,
|
|
359
|
+
});
|
|
349
360
|
const child = spawn(shell, [...args, wrapped], {
|
|
350
361
|
cwd,
|
|
351
362
|
env: env ?? process.env,
|
|
@@ -781,6 +792,19 @@ export function registerSandbox(pi, opts) {
|
|
|
781
792
|
function isPlainRecord(v) {
|
|
782
793
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
783
794
|
}
|
|
795
|
+
/** Sink line for the transient shell-resolution retry: outcome only (a
|
|
796
|
+
* closed enum, no command or shell content — scrub-safe, so it rides the
|
|
797
|
+
* default-on tier and /feedback). `recovered` = the retry healed a
|
|
798
|
+
* transient srt `which` timeout; `exhausted` = the failure surfaced to the
|
|
799
|
+
* model (warn, the break-glass signal). */
|
|
800
|
+
function logShellResolutionRetry(outcome) {
|
|
801
|
+
logEvent({
|
|
802
|
+
source: "sandbox",
|
|
803
|
+
level: outcome === "recovered" ? "info" : "warn",
|
|
804
|
+
event: "shell_resolution_retry",
|
|
805
|
+
fields: { outcome },
|
|
806
|
+
});
|
|
807
|
+
}
|
|
784
808
|
/** Sink line when the network-posture classifier fires — the impact
|
|
785
809
|
* signal (paired with tool_execute_decision's useSandbox it makes the
|
|
786
810
|
* escape-rate before/after readable from the trail). Closed enum class,
|
|
@@ -85,6 +85,7 @@ export declare const METRIC_COST_USAGE = "yagni_code.cost.usage";
|
|
|
85
85
|
export declare const METRIC_TOKEN_USAGE = "yagni_code.token.usage";
|
|
86
86
|
export declare const METRIC_CODE_EDIT_DECISION = "yagni_code.code_edit_tool.decision";
|
|
87
87
|
export declare const METRIC_ACTIVE_TIME = "yagni_code.active_time.total";
|
|
88
|
+
export declare const METRIC_SANDBOX_SHELL_RETRY = "yagni_code.sandbox.shell_resolution_retry.count";
|
|
88
89
|
export declare const EVENT_USER_PROMPT = "user_prompt";
|
|
89
90
|
export declare const EVENT_ASSISTANT_RESPONSE = "assistant_response";
|
|
90
91
|
export declare const EVENT_TOOL_RESULT = "tool_result";
|
|
@@ -89,6 +89,7 @@ export const METRIC_COST_USAGE = `${PREFIX}.cost.usage`;
|
|
|
89
89
|
export const METRIC_TOKEN_USAGE = `${PREFIX}.token.usage`;
|
|
90
90
|
export const METRIC_CODE_EDIT_DECISION = `${PREFIX}.code_edit_tool.decision`;
|
|
91
91
|
export const METRIC_ACTIVE_TIME = `${PREFIX}.active_time.total`;
|
|
92
|
+
export const METRIC_SANDBOX_SHELL_RETRY = `${PREFIX}.sandbox.shell_resolution_retry.count`;
|
|
92
93
|
// ── Event names (Claude Code's log events, prefixed in the body) ────────────
|
|
93
94
|
export const EVENT_USER_PROMPT = "user_prompt";
|
|
94
95
|
export const EVENT_ASSISTANT_RESPONSE = "assistant_response";
|
|
@@ -34,6 +34,8 @@ export interface TelemetryHandle {
|
|
|
34
34
|
* to the `includeAccountId` gate).
|
|
35
35
|
*/
|
|
36
36
|
setUserEmail(email: string | undefined): void;
|
|
37
|
+
/** The sandbox's shell-resolution retry calls this with its outcome. */
|
|
38
|
+
sandboxShellResolutionRetry(outcome: "recovered" | "exhausted"): void;
|
|
37
39
|
/** Test/introspection seam: the live tracker once the SDK is up. */
|
|
38
40
|
readonly tracker: SessionTelemetry | null;
|
|
39
41
|
}
|
|
@@ -28,6 +28,7 @@ const NOOP_HANDLE = (config) => ({
|
|
|
28
28
|
toolDecision: () => { },
|
|
29
29
|
permissionModeChanged: () => { },
|
|
30
30
|
setUserEmail: () => { },
|
|
31
|
+
sandboxShellResolutionRetry: () => { },
|
|
31
32
|
tracker: null,
|
|
32
33
|
});
|
|
33
34
|
export function registerTelemetry(pi, deps = {}) {
|
|
@@ -188,6 +189,7 @@ export function registerTelemetry(pi, deps = {}) {
|
|
|
188
189
|
},
|
|
189
190
|
toolDecision: guard("tool_decision", (input) => tracker?.toolDecision(input)),
|
|
190
191
|
permissionModeChanged: guard("permission_mode_changed", (from, to) => tracker?.permissionModeChanged(from, to)),
|
|
192
|
+
sandboxShellResolutionRetry: guard("sandbox_shell_resolution_retry", (outcome) => tracker?.sandboxShellResolutionRetry(outcome)),
|
|
191
193
|
// Precedence, lowest to highest: this call (the /context boot fetch) <
|
|
192
194
|
// the launcher's YAGNI_USER_EMAIL, which resolveTelemetryConfig already
|
|
193
195
|
// placed on `identity`. So an identity that is set is never overwritten,
|
|
@@ -110,6 +110,10 @@ export declare class SessionTelemetry {
|
|
|
110
110
|
}): void;
|
|
111
111
|
/** Lines added/removed by an edit or write, from pi's tool_result details. */
|
|
112
112
|
linesOfCode(added: number, removed: number): void;
|
|
113
|
+
/** Transient sandbox shell-resolution failure retried by the wrap seam —
|
|
114
|
+
* `recovered` (retry healed it) vs `exhausted` (error surfaced to the
|
|
115
|
+
* model). The post-deploy health signal for the retry feature. */
|
|
116
|
+
sandboxShellResolutionRetry(outcome: "recovered" | "exhausted"): void;
|
|
113
117
|
/** A successful bash command: count commits and PR creations. */
|
|
114
118
|
bashSucceeded(command: string | undefined): void;
|
|
115
119
|
permissionModeChanged(fromMode: string, toMode: string): void;
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
import { randomUUID } from "node:crypto";
|
|
21
21
|
import { context as otelContext, SpanStatusCode, trace, } from "@opentelemetry/api";
|
|
22
22
|
import { SeverityNumber } from "@opentelemetry/api-logs";
|
|
23
|
-
import { ATTR_APP_ENTRYPOINT, ATTR_APP_VERSION, ATTR_ERROR_TYPE, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_READ_TOKENS, ATTR_GEN_AI_CACHE_READ_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_WRITE_TOKENS, ATTR_DD_LLMOBS_METADATA, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_COST_ESTIMATED_TOTAL, ATTR_GEN_AI_FINISH_REASONS, ATTR_GEN_AI_INPUT_TOKENS, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_OUTPUT_TOKENS, ATTR_GEN_AI_TOTAL_TOKENS, ATTR_GEN_AI_PROVIDER_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE, ATTR_HTTP_STATUS_CODE, ATTR_ORGANIZATION_ID, ATTR_SESSION_ID, ATTR_TERMINAL_TYPE, ATTR_USER_EMAIL, EVENT_API_ERROR, EVENT_API_REQUEST, EVENT_ASSISTANT_RESPONSE, EVENT_PERMISSION_MODE_CHANGED, EVENT_TOOL_DECISION, EVENT_TOOL_RESULT, EVENT_USER_PROMPT, GEN_AI_PROVIDER, languageFromPath, METRIC_ACTIVE_TIME, METRIC_CODE_EDIT_DECISION, METRIC_COMMIT_COUNT, METRIC_COST_USAGE, METRIC_LINES_OF_CODE, METRIC_PULL_REQUEST_COUNT, METRIC_SESSION_COUNT, METRIC_TOKEN_USAGE, PREFIX, SPAN_INTERACTION, SPAN_LLM_REQUEST, SPAN_TOOL, SPAN_TURN, } from "./attrs.js";
|
|
23
|
+
import { ATTR_APP_ENTRYPOINT, ATTR_APP_VERSION, ATTR_ERROR_TYPE, ATTR_GEN_AI_AGENT_NAME, ATTR_GEN_AI_CACHE_CREATION_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_READ_TOKENS, ATTR_GEN_AI_CACHE_READ_TOKENS_LEGACY, ATTR_GEN_AI_CACHE_WRITE_TOKENS, ATTR_DD_LLMOBS_METADATA, ATTR_GEN_AI_CONVERSATION_ID, ATTR_GEN_AI_COST_ESTIMATED_TOTAL, ATTR_GEN_AI_FINISH_REASONS, ATTR_GEN_AI_INPUT_TOKENS, ATTR_GEN_AI_OPERATION_NAME, ATTR_GEN_AI_OUTPUT_TOKENS, ATTR_GEN_AI_TOTAL_TOKENS, ATTR_GEN_AI_PROVIDER_NAME, ATTR_GEN_AI_REQUEST_MODEL, ATTR_GEN_AI_RESPONSE_ID, ATTR_GEN_AI_RESPONSE_MODEL, ATTR_GEN_AI_SYSTEM, ATTR_GEN_AI_TOOL_CALL_ID, ATTR_GEN_AI_TOOL_NAME, ATTR_GEN_AI_TOOL_TYPE, ATTR_HTTP_STATUS_CODE, ATTR_ORGANIZATION_ID, ATTR_SESSION_ID, ATTR_TERMINAL_TYPE, ATTR_USER_EMAIL, EVENT_API_ERROR, EVENT_API_REQUEST, EVENT_ASSISTANT_RESPONSE, EVENT_PERMISSION_MODE_CHANGED, EVENT_TOOL_DECISION, EVENT_TOOL_RESULT, EVENT_USER_PROMPT, GEN_AI_PROVIDER, languageFromPath, METRIC_ACTIVE_TIME, METRIC_CODE_EDIT_DECISION, METRIC_COMMIT_COUNT, METRIC_COST_USAGE, METRIC_LINES_OF_CODE, METRIC_PULL_REQUEST_COUNT, METRIC_SANDBOX_SHELL_RETRY, METRIC_SESSION_COUNT, METRIC_TOKEN_USAGE, PREFIX, SPAN_INTERACTION, SPAN_LLM_REQUEST, SPAN_TOOL, SPAN_TURN, } from "./attrs.js";
|
|
24
24
|
/** Idle cutoff for user active time: gaps longer than this are not "active". */
|
|
25
25
|
export const USER_ACTIVE_IDLE_CUTOFF_MS = 5 * 60 * 1000;
|
|
26
26
|
const EDIT_TOOLS = new Set(["edit", "write", "multi_edit", "notebook_edit"]);
|
|
@@ -463,6 +463,12 @@ export class SessionTelemetry {
|
|
|
463
463
|
this.add(METRIC_LINES_OF_CODE, "1", "Count of lines of code modified", added, { type: "added" });
|
|
464
464
|
this.add(METRIC_LINES_OF_CODE, "1", "Count of lines of code modified", removed, { type: "removed" });
|
|
465
465
|
}
|
|
466
|
+
/** Transient sandbox shell-resolution failure retried by the wrap seam —
|
|
467
|
+
* `recovered` (retry healed it) vs `exhausted` (error surfaced to the
|
|
468
|
+
* model). The post-deploy health signal for the retry feature. */
|
|
469
|
+
sandboxShellResolutionRetry(outcome) {
|
|
470
|
+
this.add(METRIC_SANDBOX_SHELL_RETRY, "1", "Sandbox shell-resolution transient failures, by retry outcome", 1, { outcome });
|
|
471
|
+
}
|
|
466
472
|
/** A successful bash command: count commits and PR creations. */
|
|
467
473
|
bashSucceeded(command) {
|
|
468
474
|
if (typeof command !== "string")
|
package/dist/worktreeArgs.d.ts
CHANGED
|
@@ -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.
|
package/dist/worktreeArgs.js
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.1.0-staging.1335.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": "
|
|
61
|
+
"yagniSourceSha": "fe89db90856f43da8d645abeb16d448c83bb8e33"
|
|
62
62
|
}
|