@yagni-app/code 0.3.5 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +42 -0
  2. package/dist/cli.js +231 -6
  3. package/dist/crashReport.d.ts +8 -0
  4. package/dist/crashReport.js +13 -1
  5. package/dist/doctor.d.ts +7 -0
  6. package/dist/doctor.js +33 -0
  7. package/dist/extension/askAdvisorTool.d.ts +7 -0
  8. package/dist/extension/askAdvisorTool.js +13 -3
  9. package/dist/extension/askUserQuestionTool.d.ts +54 -0
  10. package/dist/extension/askUserQuestionTool.js +621 -0
  11. package/dist/extension/askYagniTool.js +2 -0
  12. package/dist/extension/branding.d.ts +15 -0
  13. package/dist/extension/branding.js +76 -0
  14. package/dist/extension/chipEditor.d.ts +22 -1
  15. package/dist/extension/chipEditor.js +58 -5
  16. package/dist/extension/cmux/state.js +9 -16
  17. package/dist/extension/condensedTools.d.ts +93 -0
  18. package/dist/extension/condensedTools.js +392 -0
  19. package/dist/extension/crashReport.js +12 -0
  20. package/dist/extension/decisionCapture.js +3 -0
  21. package/dist/extension/decisions.js +4 -0
  22. package/dist/extension/diagnostics.d.ts +31 -0
  23. package/dist/extension/diagnostics.js +53 -55
  24. package/dist/extension/diffStat.d.ts +62 -0
  25. package/dist/extension/diffStat.js +158 -0
  26. package/dist/extension/errorSink.d.ts +64 -0
  27. package/dist/extension/errorSink.js +180 -0
  28. package/dist/extension/feedbackCommand.d.ts +38 -0
  29. package/dist/extension/feedbackCommand.js +151 -0
  30. package/dist/extension/footer.d.ts +2 -0
  31. package/dist/extension/footer.js +21 -8
  32. package/dist/extension/hooks.js +12 -12
  33. package/dist/extension/index.d.ts +7 -0
  34. package/dist/extension/index.js +161 -42
  35. package/dist/extension/mineBeat.js +13 -0
  36. package/dist/extension/permission/execPolicy.js +47 -0
  37. package/dist/extension/pipeline/goCommand.js +2 -0
  38. package/dist/extension/pipeline/invocation.d.ts +7 -0
  39. package/dist/extension/pipeline/invocation.js +7 -0
  40. package/dist/extension/pipeline/personas.js +4 -4
  41. package/dist/extension/pipeline/runner.d.ts +1 -0
  42. package/dist/extension/pipeline/runner.js +24 -3
  43. package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
  44. package/dist/extension/pipeline/sessionWorktree.js +225 -0
  45. package/dist/extension/scratchpad.d.ts +66 -0
  46. package/dist/extension/scratchpad.js +93 -0
  47. package/dist/extension/silentTurnReminder.js +18 -14
  48. package/dist/extension/subagents.d.ts +10 -0
  49. package/dist/extension/subagents.js +18 -4
  50. package/dist/extension/todos.d.ts +1 -0
  51. package/dist/extension/todos.js +15 -0
  52. package/dist/extension/toolRuns.d.ts +92 -0
  53. package/dist/extension/toolRuns.js +201 -0
  54. package/dist/extension/turnLog.js +17 -46
  55. package/dist/extension/webFetch.d.ts +85 -0
  56. package/dist/extension/webFetch.js +192 -0
  57. package/dist/extension/webFetchTool.d.ts +34 -0
  58. package/dist/extension/webFetchTool.js +106 -0
  59. package/dist/extension/workingLine.d.ts +49 -0
  60. package/dist/extension/workingLine.js +116 -0
  61. package/dist/feedback.d.ts +77 -0
  62. package/dist/feedback.js +500 -0
  63. package/dist/goHeadless.d.ts +3 -0
  64. package/dist/goHeadless.js +13 -0
  65. package/dist/launch.d.ts +8 -0
  66. package/dist/launch.js +6 -0
  67. package/dist/otel.d.ts +150 -0
  68. package/dist/otel.js +291 -0
  69. package/dist/outputFormat.d.ts +83 -0
  70. package/dist/outputFormat.js +207 -0
  71. package/dist/paths.d.ts +10 -0
  72. package/dist/paths.js +13 -0
  73. package/dist/worktreeArgs.d.ts +43 -0
  74. package/dist/worktreeArgs.js +96 -0
  75. package/package.json +4 -2
@@ -56,7 +56,7 @@ Numbered, small, actionable steps — each names the file/function to touch.
56
56
  ## Risks
57
57
  What to watch for, including any decision the worker will be forced to make.
58
58
 
59
- Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
59
+ Complete the task fully — do not gold-plate, but do not leave it half-done. Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
60
60
 
61
61
  Budget discipline: you have a hard output budget, and a plan that gets cut off mid-thought is worth less than a short plan that ships. Explore only until you can name the files and the steps — do not read broadly for completeness, and do not re-verify what you have already established. Aim for 5-10 short steps; the worker fills small gaps from the ticket. When in doubt, write the plan NOW.
62
62
 
@@ -65,7 +65,7 @@ const WORKER_BODY = `You are a worker with full capabilities, operating in an is
65
65
 
66
66
  You are grounded. Call ask_yagni before guessing about anything organization- or codebase-specific. Treat a confirmed answer as settled; when an answer is an unverified assumption or an inference and your change leans on it, say so in your Notes so the reviewer knows what to check. Critically: for ANY product-intent call you are forced to make that the plan did not settle — a behavior choice, a tradeoff, an interpretation of intent — call record_decision so the company's decision corpus captures it and the next agent inherits the call instead of re-litigating it. When ask_yagni reports no recorded position, follow its instruction and record the assumption you proceed on.
67
67
 
68
- You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
68
+ Complete the task fully — do not gold-plate, but do not leave it half-done. You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code, calling record_decision for any intent you infer. Ending your turn with no write/edit is a failure.
69
69
 
70
70
  Output:
71
71
  ## Completed
@@ -242,14 +242,14 @@ Numbered, small, actionable steps — each names the file/function to touch.
242
242
  ## Risks
243
243
  What to watch for, including any decision the worker will be forced to make.
244
244
 
245
- Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
245
+ Complete the task fully — do not gold-plate, but do not leave it half-done. Finish the job in ONE turn: do not end your turn on an interstitial like "now let me check X". Your FINAL message MUST be the complete plan in the format above (## Goal / ## Plan / ## Files to modify or create / ## Risks). Keep exploring with your tools until you can write the whole plan, then write it as your last message.
246
246
 
247
247
  Budget discipline: you have a hard output budget, and a plan that gets cut off mid-thought is worth less than a short plan that ships. Explore only until you can name the files and the steps — do not read broadly for completeness, and do not re-verify what you have already established. Aim for 5-10 short steps; the worker fills small gaps from the ticket. When in doubt, write the plan NOW.
248
248
 
249
249
  Keep it concrete; the worker executes it verbatim.`;
250
250
  const WORKER_BLIND = `You are a worker with full capabilities, operating in an isolated context to implement a plan. Work autonomously and use the tools as needed.
251
251
 
252
- You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code. Ending your turn with no write/edit is a failure.
252
+ Complete the task fully — do not gold-plate, but do not leave it half-done. You MUST make the change. If the plan is missing, partial, or appears cut off, do not stop at exploring: implement the ticket directly from the ticket text and the code. Ending your turn with no write/edit is a failure.
253
253
 
254
254
  Output:
255
255
  ## Completed
@@ -32,6 +32,7 @@ export interface RunStageDeps {
32
32
  resolveChild?: () => {
33
33
  piCli: string;
34
34
  extensionPath: string;
35
+ otelExtensionPath?: string;
35
36
  };
36
37
  /** Optional environment for spawned child stages. Defaults to Node's inherited env. */
37
38
  env?: NodeJS.ProcessEnv;
@@ -25,6 +25,7 @@ import * as path from "node:path";
25
25
  import { fileURLToPath } from "node:url";
26
26
  import { trackChild } from "./childRegistry.js";
27
27
  import { finalOutputFrom, foldEvent, newEventAccumulator } from "./events.js";
28
+ import { logEvent } from "../errorSink.js";
28
29
  import { buildStageInvocation, groundedChildArgv } from "./invocation.js";
29
30
  import { personaBody } from "./personas.js";
30
31
  import { clampTier, resolveTierCap } from "./tierCap.js";
@@ -60,11 +61,19 @@ async function defaultWritePrompt(body) {
60
61
  /**
61
62
  * Default child resolution: the parent process IS pi (so `process.argv[1]` is
62
63
  * pi's cli), and our compiled extension entry sits one dir up from this module.
64
+ *
65
+ * `otelExtensionPath` comes from `YAGNI_OTEL_EXTENSION_PATH`, set by the
66
+ * launcher only when an OTLP endpoint is configured (see the CLI's otel.ts) —
67
+ * children then load pi-otel so their LLM spend is traced. Guarded with
68
+ * existsSync so a stale env value degrades to an untraced child, never a
69
+ * child that fails to boot.
63
70
  */
64
71
  function defaultResolveChild() {
65
72
  const piCli = process.argv[1] ?? "pi";
66
73
  const extensionPath = fileURLToPath(new URL("../index.js", import.meta.url));
67
- return { piCli, extensionPath };
74
+ const otelPath = process.env.YAGNI_OTEL_EXTENSION_PATH;
75
+ const otelExtensionPath = otelPath && fs.existsSync(otelPath) ? otelPath : undefined;
76
+ return { piCli, extensionPath, ...(otelExtensionPath ? { otelExtensionPath } : {}) };
68
77
  }
69
78
  const EMPTY_USAGE = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, turns: 0 };
70
79
  /** Truncate to PER_TASK_OUTPUT_CAP bytes, never exceeding the cap. */
@@ -115,8 +124,12 @@ export async function runStage(stage, ctx, deps) {
115
124
  lens: ctx.lens,
116
125
  ...(tierCap ? { tierCap } : {}),
117
126
  });
118
- const { piCli, extensionPath } = resolveChild();
119
- const argv = groundedChildArgv(passthrough, { piCli, extensionPath });
127
+ const { piCli, extensionPath, otelExtensionPath } = resolveChild();
128
+ const argv = groundedChildArgv(passthrough, {
129
+ piCli,
130
+ extensionPath,
131
+ ...(otelExtensionPath ? { otelExtensionPath } : {}),
132
+ });
120
133
  // Fold events into a fixed-size accumulator as they stream — never retain
121
134
  // the full array (that OOMs on a verbose stage; YAG-317 follow-up).
122
135
  const acc = newEventAccumulator();
@@ -311,6 +324,14 @@ export async function runStage(stage, ctx, deps) {
311
324
  final_output: finalOut,
312
325
  }, null, 2));
313
326
  dbgLog("stage_end");
327
+ if (overlongLinesDropped > 0) {
328
+ logEvent({
329
+ source: "pipeline",
330
+ level: "debug",
331
+ event: "overlong_lines_dropped",
332
+ fields: { stage: stage.id, dropped: overlongLinesDropped },
333
+ });
334
+ }
314
335
  }
315
336
  catch { /* ignore */ }
316
337
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `-w / --worktree` session-worktree plumbing.
3
+ *
4
+ * The launcher (`yagni-code-cli`) reaches this module by file path (the same
5
+ * seam as `headlessGo.ts` → `runHeadlessGo`) so it can create/resume a named
6
+ * YAGNI worktree BEFORE spawning pi into it. The launcher owns the process
7
+ * lifecycle (spawn + `cwd` + exit summary); this module owns the git behavior.
8
+ *
9
+ * Design invariants (see the YAG-594 plan):
10
+ * - **Add-only creation.** Every git op is `worktree add`, `fetch`, `show-ref`,
11
+ * `symbolic-ref`, or `rev-parse`. Nothing deletes, force-resets, or
12
+ * `branch -D`s — the worktree is DURABLE by default and never auto-removed.
13
+ * - **Get-or-resume.** An existing worktree dir is resumed, never recreated.
14
+ * - **Lazy fetch.** Base `origin/<default>` is read from the local ref when
15
+ * present; `git fetch` only runs when that ref is absent, and always with
16
+ * credential prompts disabled.
17
+ * - **Validate before any side effect.** The slug is checked (again, defense
18
+ * in depth against the launcher) before the first git subprocess.
19
+ * - **Canonical root.** `-w` invoked from inside an existing worktree lands in
20
+ * the main repo, never nested.
21
+ *
22
+ * Convention reused from `/wt-new`: branch `agent/<slug>`, dir `.worktrees/<slug>`
23
+ * (both gitignored in-repo). PR refs (`#N`, GitHub PR URLs) map to `pr-<N>` and
24
+ * base on `FETCH_HEAD`.
25
+ */
26
+ export interface SessionWorktreeResult {
27
+ /** Absolute destination (under `<mainRepo>/.worktrees/<slug>`). */
28
+ worktreePath: string;
29
+ /** The `agent/<slug>` branch. */
30
+ branch: string;
31
+ /** True when the worktree already existed (resumed, not created). */
32
+ existed: boolean;
33
+ }
34
+ export type SessionGit = (argv: string[], cwd: string, env?: NodeJS.ProcessEnv) => Promise<string>;
35
+ export interface CreateOrResumeDeps {
36
+ /** Repo the user ran `yagni -w` from (any path inside it works for git). */
37
+ repoCwd: string;
38
+ /** Injectable git seam (defaults to a real `git` exec). */
39
+ gitImpl?: SessionGit;
40
+ /** Injectable fs seam for existence checks (defaults to node:fs). */
41
+ pathExists?: (p: string) => boolean;
42
+ /** Injectable randomness (defaults to Math.random). */
43
+ random?: () => number;
44
+ }
45
+ /** Turn arbitrary name text into a git-ref-safe slug. Empty input → "worktree". */
46
+ export declare function slugify(name: string): string;
47
+ /**
48
+ * Validate a worktree slug before any side effect. Mirrors Claude's guard:
49
+ * length cap, per-segment allowlist, `.`/`..` rejection. Throws synchronously.
50
+ */
51
+ export declare function validateWorktreeSlug(slug: string): void;
52
+ /**
53
+ * Parse a PR reference: `#N` or a GitHub-style PR URL. Returns the number or null.
54
+ */
55
+ export declare function parsePRReference(input: string): number | null;
56
+ /**
57
+ * Create or resume the session worktree for `name`.
58
+ *
59
+ * Throws with a user-surfaced message on any failure; the caller (launcher)
60
+ * catches and prints it to stderr + the diagnostic sink. Never leaves a partial
61
+ * branch/worktree: validation happens first, and `git worktree add` is atomic.
62
+ */
63
+ export declare function createOrResume(name: string | undefined, deps: CreateOrResumeDeps): Promise<SessionWorktreeResult>;
64
+ //# sourceMappingURL=sessionWorktree.d.ts.map
@@ -0,0 +1,225 @@
1
+ /**
2
+ * `-w / --worktree` session-worktree plumbing.
3
+ *
4
+ * The launcher (`yagni-code-cli`) reaches this module by file path (the same
5
+ * seam as `headlessGo.ts` → `runHeadlessGo`) so it can create/resume a named
6
+ * YAGNI worktree BEFORE spawning pi into it. The launcher owns the process
7
+ * lifecycle (spawn + `cwd` + exit summary); this module owns the git behavior.
8
+ *
9
+ * Design invariants (see the YAG-594 plan):
10
+ * - **Add-only creation.** Every git op is `worktree add`, `fetch`, `show-ref`,
11
+ * `symbolic-ref`, or `rev-parse`. Nothing deletes, force-resets, or
12
+ * `branch -D`s — the worktree is DURABLE by default and never auto-removed.
13
+ * - **Get-or-resume.** An existing worktree dir is resumed, never recreated.
14
+ * - **Lazy fetch.** Base `origin/<default>` is read from the local ref when
15
+ * present; `git fetch` only runs when that ref is absent, and always with
16
+ * credential prompts disabled.
17
+ * - **Validate before any side effect.** The slug is checked (again, defense
18
+ * in depth against the launcher) before the first git subprocess.
19
+ * - **Canonical root.** `-w` invoked from inside an existing worktree lands in
20
+ * the main repo, never nested.
21
+ *
22
+ * Convention reused from `/wt-new`: branch `agent/<slug>`, dir `.worktrees/<slug>`
23
+ * (both gitignored in-repo). PR refs (`#N`, GitHub PR URLs) map to `pr-<N>` and
24
+ * base on `FETCH_HEAD`.
25
+ */
26
+ import { execFile } from "node:child_process";
27
+ import { existsSync, mkdirSync } from "node:fs";
28
+ import { basename, dirname, isAbsolute, join } from "node:path";
29
+ import { bootstrapWorktree } from "./worktree.js";
30
+ /** Cap on the slug half (keeps refs & dirs readable). */
31
+ const SLUG_MAX = 40;
32
+ /** Maximum slug characters, mirrored from Claude's guard. */
33
+ const MAX_SLUG_LENGTH = 64;
34
+ /** Allowlist per `/`-separated segment (mirrors Claude's `validateWorktreeSlug`). */
35
+ const VALID_SLUG_SEGMENT = /^[a-zA-Z0-9._-]+$/;
36
+ /** Env that prevents git/ssh from prompting for credentials (which would hang). */
37
+ const GIT_NO_PROMPT_ENV = {
38
+ GIT_TERMINAL_PROMPT: "0",
39
+ GIT_ASKPASS: "",
40
+ };
41
+ /** Turn arbitrary name text into a git-ref-safe slug. Empty input → "worktree". */
42
+ export function slugify(name) {
43
+ const slug = name
44
+ .toLowerCase()
45
+ .replace(/[^a-z0-9._-]+/g, "-")
46
+ .replace(/^-+|-+$/g, "")
47
+ .slice(0, SLUG_MAX)
48
+ .replace(/-+$/, "");
49
+ return slug || "worktree";
50
+ }
51
+ /**
52
+ * Validate a worktree slug before any side effect. Mirrors Claude's guard:
53
+ * length cap, per-segment allowlist, `.`/`..` rejection. Throws synchronously.
54
+ */
55
+ export function validateWorktreeSlug(slug) {
56
+ if (slug.length > MAX_SLUG_LENGTH) {
57
+ throw new Error(`Invalid worktree name: must be ${MAX_SLUG_LENGTH} characters or fewer (got ${slug.length})`);
58
+ }
59
+ for (const segment of slug.split("/")) {
60
+ if (segment === "." || segment === "..") {
61
+ throw new Error(`Invalid worktree name "${slug}": must not contain "." or ".." path segments`);
62
+ }
63
+ if (!VALID_SLUG_SEGMENT.test(segment)) {
64
+ throw new Error(`Invalid worktree name "${slug}": each "/"-separated segment must be non-empty and contain only letters, digits, dots, underscores, and dashes`);
65
+ }
66
+ }
67
+ }
68
+ /**
69
+ * Parse a PR reference: `#N` or a GitHub-style PR URL. Returns the number or null.
70
+ */
71
+ export function parsePRReference(input) {
72
+ const urlMatch = input.match(/^https?:\/\/[^/]+\/[^/]+\/[^/]+\/pull\/(\d+)\/?(?:[?#].*)?$/i);
73
+ if (urlMatch?.[1])
74
+ return parseInt(urlMatch[1], 10);
75
+ const hashMatch = input.match(/^#(\d+)$/);
76
+ if (hashMatch?.[1])
77
+ return parseInt(hashMatch[1], 10);
78
+ return null;
79
+ }
80
+ function randomSlug(random = Math.random) {
81
+ const adjectives = ["swift", "bright", "calm", "keen", "bold", "quiet", "warm", "true"];
82
+ const nouns = ["fox", "owl", "elm", "oak", "ray", "fern", "pine", "brook"];
83
+ const adj = adjectives[Math.floor(random() * adjectives.length)];
84
+ const noun = nouns[Math.floor(random() * nouns.length)];
85
+ const suffix = Math.floor(random() * 0x10000).toString(36).padStart(4, "0");
86
+ return `${adj}-${noun}-${suffix}`;
87
+ }
88
+ function defaultGit(argv, cwd, env) {
89
+ return new Promise((resolve, reject) => {
90
+ execFile("git", argv, { cwd, env, maxBuffer: 32 * 1024 * 1024 }, (err, stdout, stderr) => {
91
+ if (err) {
92
+ reject(new Error(stderr.toString().trim() || err.message));
93
+ return;
94
+ }
95
+ resolve(stdout.toString().trim());
96
+ });
97
+ });
98
+ }
99
+ /** The main repo root, resolved through an existing linked worktree via commondir. */
100
+ async function resolveMainRepo(gitImpl, repoCwd) {
101
+ let topLevel;
102
+ try {
103
+ topLevel = await gitImpl(["rev-parse", "--show-toplevel"], repoCwd);
104
+ }
105
+ catch (err) {
106
+ throw new Error(`Cannot create a worktree: not inside a git repository. ` +
107
+ `${err instanceof Error ? err.message : String(err)}`);
108
+ }
109
+ const common = await gitImpl(["rev-parse", "--git-common-dir"], repoCwd);
110
+ const abs = isAbsolute(common) ? common : join(topLevel, common);
111
+ // A linked worktree's commondir points at the shared `.git`; the main repo root
112
+ // is its parent. A main checkout resolves to its own toplevel.
113
+ return basename(abs) === ".git" ? dirname(abs) : topLevel;
114
+ }
115
+ /** Resolve the default branch: origin/HEAD symref, else main, else master. */
116
+ async function resolveDefaultBranch(gitImpl, repoCwd) {
117
+ try {
118
+ const symref = await gitImpl(["symbolic-ref", "refs/remotes/origin/HEAD"], repoCwd);
119
+ const name = symref.replace(/^refs\/remotes\//, "");
120
+ if (name)
121
+ return name;
122
+ }
123
+ catch {
124
+ /* no origin/HEAD symref */
125
+ }
126
+ for (const candidate of ["main", "master"]) {
127
+ try {
128
+ await gitImpl(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${candidate}`], repoCwd);
129
+ return candidate;
130
+ }
131
+ catch {
132
+ /* keep looking */
133
+ }
134
+ }
135
+ return "main";
136
+ }
137
+ /** True when a local branch `refs/heads/<branch>` exists. */
138
+ async function branchExists(gitImpl, repoCwd, branch) {
139
+ try {
140
+ await gitImpl(["show-ref", "--verify", "--quiet", `refs/heads/${branch}`], repoCwd);
141
+ return true;
142
+ }
143
+ catch {
144
+ return false;
145
+ }
146
+ }
147
+ /**
148
+ * Create or resume the session worktree for `name`.
149
+ *
150
+ * Throws with a user-surfaced message on any failure; the caller (launcher)
151
+ * catches and prints it to stderr + the diagnostic sink. Never leaves a partial
152
+ * branch/worktree: validation happens first, and `git worktree add` is atomic.
153
+ */
154
+ export async function createOrResume(name, deps) {
155
+ const gitImpl = deps.gitImpl ?? defaultGit;
156
+ const pathExists = deps.pathExists ?? ((p) => existsSync(p));
157
+ const random = deps.random ?? Math.random;
158
+ const repoCwd = deps.repoCwd;
159
+ const prNumber = name !== undefined ? parsePRReference(name) : null;
160
+ const slug = prNumber !== null
161
+ ? `pr-${prNumber}`
162
+ : slugify(name ?? randomSlug(random));
163
+ validateWorktreeSlug(slug);
164
+ const repoRoot = await resolveMainRepo(gitImpl, repoCwd);
165
+ const branch = `agent/${slug}`;
166
+ const worktreePath = join(repoRoot, ".worktrees", slug);
167
+ // Get-or-resume: an existing dir is resumed, never recreated/fetched/overwritten.
168
+ if (pathExists(worktreePath)) {
169
+ return { worktreePath, branch, existed: true };
170
+ }
171
+ // Collision: the branch already exists locally (dirty leftover from a prior
172
+ // crash) — refuse rather than force-reset.
173
+ if (await branchExists(gitImpl, repoCwd, branch)) {
174
+ throw new Error(`Branch ${branch} already exists. Pick a different name with \`-w <other>\`, or clean it up first.`);
175
+ }
176
+ mkdirSync(dirname(worktreePath), { recursive: true, mode: 0o700 });
177
+ // Resolve base. PR path fetches the PR head into FETCH_HEAD; default path uses
178
+ // the local origin/<default> ref when present, else fetches, else falls back
179
+ // to HEAD (a repo with no remote or no commits still works).
180
+ let base;
181
+ const fetchEnv = { ...process.env, ...GIT_NO_PROMPT_ENV };
182
+ if (prNumber !== null) {
183
+ try {
184
+ await gitImpl(["fetch", "origin", `pull/${prNumber}/head`], repoCwd, fetchEnv);
185
+ }
186
+ catch (err) {
187
+ throw new Error(`Failed to fetch PR #${prNumber}: ${err instanceof Error ? err.message : String(err)}. ` +
188
+ `The PR may not exist or this repo may not have a remote named "origin".`);
189
+ }
190
+ base = "FETCH_HEAD";
191
+ }
192
+ else {
193
+ const defaultBranch = await resolveDefaultBranch(gitImpl, repoCwd);
194
+ let originRef = null;
195
+ try {
196
+ await gitImpl(["show-ref", "--verify", "--quiet", `refs/remotes/origin/${defaultBranch}`], repoCwd);
197
+ originRef = `origin/${defaultBranch}`;
198
+ }
199
+ catch {
200
+ try {
201
+ await gitImpl(["fetch", "origin", defaultBranch], repoCwd, fetchEnv);
202
+ originRef = `origin/${defaultBranch}`;
203
+ }
204
+ catch {
205
+ originRef = "HEAD"; // no remote / no commits: degrade to local HEAD
206
+ }
207
+ }
208
+ base = originRef;
209
+ }
210
+ try {
211
+ await gitImpl(["worktree", "add", "-b", branch, worktreePath, base], repoCwd);
212
+ }
213
+ catch (err) {
214
+ throw new Error(`Failed to create worktree: ${err instanceof Error ? err.message : String(err)}`);
215
+ }
216
+ // Best-effort: install deps + env so a fresh worktree can actually run.
217
+ try {
218
+ await bootstrapWorktree(worktreePath);
219
+ }
220
+ catch {
221
+ /* bootstrap is best-effort; a failed install must not fail the launch */
222
+ }
223
+ return { worktreePath, branch, existed: false };
224
+ }
225
+ //# sourceMappingURL=sessionWorktree.js.map
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Session scratchpad — a permission-free directory the agent writes its working
3
+ * state to, so neither it nor the user has to reconstruct intermediate results.
4
+ *
5
+ * Ports Claude Code's scratchpad with one deliberate simplification: Claude
6
+ * resolves the tmp root and normalizes against path traversal because its
7
+ * scratchpad is an allow-listed path in a permission classifier. We have no
8
+ * such classifier (auto mode already passes write/edit unprompted, and plan
9
+ * mode's write gate holds scratchpad like every other write), so the path is
10
+ * built plainly and the only caller-facing contract is "the dir exists or the
11
+ * section is omitted."
12
+ *
13
+ * Path mirrors Claude Code's shape under our own owner namespace so the two
14
+ * never collide: <tmp>/yagni-{uid}/<sanitized-cwd>/<sessionId>/scratchpad/.
15
+ * - tmp root: YAGNI_CODE_TMPDIR, else os.tmpdir()
16
+ * - uid: process.getuid() ?? 0 (multi-user isolation; tmpdir() is already
17
+ * per-user on Windows)
18
+ * - sanitized-cwd: non-alphanumerics → "-", length-capped (cosmetic grouping
19
+ * only — sessionId is the real uniqueness key)
20
+ * - sessionId: env.YAGNI_SESSION_ID, minted by the launcher as a UUID; when
21
+ * absent (a bare pi run) no scratchpad is configured at all.
22
+ *
23
+ * PURE path/section builders are separated from the one impure mkdir so tests
24
+ * drive the former directly and the latter through an injectable fs seam.
25
+ */
26
+ /** Env override for the scratchpad tmp root (mirrors CLAUDE_CODE_TMPDIR). */
27
+ export declare const SCRATCHPAD_TMPDIR_ENV = "YAGNI_CODE_TMPDIR";
28
+ /**
29
+ * PURE: sanitize an absolute cwd into a filename-safe segment. Mirrors Claude
30
+ * Code's sanitizePath but without the hash suffix — the cwd segment is cosmetic
31
+ * grouping, not a permission identity, so an identical prefix under two long
32
+ * cwds is disambiguated by the sessionId one level deeper.
33
+ */
34
+ export declare function sanitizeCwdSegment(cwd: string): string;
35
+ /**
36
+ * PURE: the per-user scratchpad owner dir name. uid isolates multi-user systems
37
+ * the way Claude Code's "claude-{uid}" does, under our own prefix.
38
+ */
39
+ export declare function scratchpadOwnerDir(uid: number): string;
40
+ /**
41
+ * PURE: the session scratchpad directory path. Returns null when there is no
42
+ * sessionId — a scratchpad is meaningless without a per-session key, and the
43
+ * prompt section is gated on a non-null result.
44
+ */
45
+ export declare function scratchpadDir(opts?: {
46
+ sessionId?: string;
47
+ cwd?: string;
48
+ uid?: number;
49
+ tmp?: string;
50
+ }): string | null;
51
+ /**
52
+ * IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
53
+ * the path on success and null on failure — a null result means "no scratchpad
54
+ * this session", which the caller turns into an omitted prompt section.
55
+ */
56
+ export declare function ensureScratchpadDir(path: string, mkdir?: (p: string, o: {
57
+ mode: number;
58
+ recursive: boolean;
59
+ }) => void): string | null;
60
+ /**
61
+ * PURE: the prompt section naming the scratchpad. Gated by the caller on the
62
+ * dir existing; when present, it tells the agent where to put intermediate
63
+ * files instead of /tmp or the user's project.
64
+ */
65
+ export declare function scratchpadSection(dir: string): string;
66
+ //# sourceMappingURL=scratchpad.d.ts.map
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Session scratchpad — a permission-free directory the agent writes its working
3
+ * state to, so neither it nor the user has to reconstruct intermediate results.
4
+ *
5
+ * Ports Claude Code's scratchpad with one deliberate simplification: Claude
6
+ * resolves the tmp root and normalizes against path traversal because its
7
+ * scratchpad is an allow-listed path in a permission classifier. We have no
8
+ * such classifier (auto mode already passes write/edit unprompted, and plan
9
+ * mode's write gate holds scratchpad like every other write), so the path is
10
+ * built plainly and the only caller-facing contract is "the dir exists or the
11
+ * section is omitted."
12
+ *
13
+ * Path mirrors Claude Code's shape under our own owner namespace so the two
14
+ * never collide: <tmp>/yagni-{uid}/<sanitized-cwd>/<sessionId>/scratchpad/.
15
+ * - tmp root: YAGNI_CODE_TMPDIR, else os.tmpdir()
16
+ * - uid: process.getuid() ?? 0 (multi-user isolation; tmpdir() is already
17
+ * per-user on Windows)
18
+ * - sanitized-cwd: non-alphanumerics → "-", length-capped (cosmetic grouping
19
+ * only — sessionId is the real uniqueness key)
20
+ * - sessionId: env.YAGNI_SESSION_ID, minted by the launcher as a UUID; when
21
+ * absent (a bare pi run) no scratchpad is configured at all.
22
+ *
23
+ * PURE path/section builders are separated from the one impure mkdir so tests
24
+ * drive the former directly and the latter through an injectable fs seam.
25
+ */
26
+ import { mkdirSync } from "node:fs";
27
+ import { tmpdir } from "node:os";
28
+ import { join } from "node:path";
29
+ /** Env override for the scratchpad tmp root (mirrors CLAUDE_CODE_TMPDIR). */
30
+ export const SCRATCHPAD_TMPDIR_ENV = "YAGNI_CODE_TMPDIR";
31
+ /** Longest sanitized-cwd segment we keep; the sessionId carries uniqueness. */
32
+ const MAX_SANITIZED_CWD = 64;
33
+ /**
34
+ * PURE: sanitize an absolute cwd into a filename-safe segment. Mirrors Claude
35
+ * Code's sanitizePath but without the hash suffix — the cwd segment is cosmetic
36
+ * grouping, not a permission identity, so an identical prefix under two long
37
+ * cwds is disambiguated by the sessionId one level deeper.
38
+ */
39
+ export function sanitizeCwdSegment(cwd) {
40
+ const sanitized = cwd.replace(/[^a-zA-Z0-9]/g, "-").replace(/^-+|-+$/g, "");
41
+ return sanitized.slice(0, MAX_SANITIZED_CWD) || "root";
42
+ }
43
+ /**
44
+ * PURE: the per-user scratchpad owner dir name. uid isolates multi-user systems
45
+ * the way Claude Code's "claude-{uid}" does, under our own prefix.
46
+ */
47
+ export function scratchpadOwnerDir(uid) {
48
+ return `yagni-${uid}`;
49
+ }
50
+ /**
51
+ * PURE: the session scratchpad directory path. Returns null when there is no
52
+ * sessionId — a scratchpad is meaningless without a per-session key, and the
53
+ * prompt section is gated on a non-null result.
54
+ */
55
+ export function scratchpadDir(opts = {}) {
56
+ const sessionId = opts.sessionId?.trim();
57
+ if (!sessionId)
58
+ return null;
59
+ const tmp = opts.tmp ?? tmpdir();
60
+ const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() ?? 0 : 0);
61
+ const cwd = sanitizeCwdSegment(opts.cwd ?? ".");
62
+ return join(tmp, scratchpadOwnerDir(uid), cwd, sessionId, "scratchpad");
63
+ }
64
+ /**
65
+ * IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
66
+ * the path on success and null on failure — a null result means "no scratchpad
67
+ * this session", which the caller turns into an omitted prompt section.
68
+ */
69
+ export function ensureScratchpadDir(path, mkdir = mkdirSync) {
70
+ try {
71
+ mkdir(path, { recursive: true, mode: 0o700 });
72
+ return path;
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ }
78
+ /**
79
+ * PURE: the prompt section naming the scratchpad. Gated by the caller on the
80
+ * dir existing; when present, it tells the agent where to put intermediate
81
+ * files instead of /tmp or the user's project.
82
+ */
83
+ export function scratchpadSection(dir) {
84
+ return ("# Scratchpad directory\n\n" +
85
+ `Use this session scratchpad directory for files that do not belong in the user's project:\n` +
86
+ `${dir}\n\n` +
87
+ "- Store intermediate results or data during multi-step tasks.\n" +
88
+ "- Write temporary scripts or configuration files.\n" +
89
+ "- Save outputs that don't belong in the user's project.\n" +
90
+ "- Anything that would otherwise go to /tmp.\n\n" +
91
+ "The directory is session-specific and isolated from the user's project.");
92
+ }
93
+ //# sourceMappingURL=scratchpad.js.map
@@ -177,23 +177,23 @@ export function registerSilentTurnReminder(pi, deps = {}) {
177
177
  // Never break input handling.
178
178
  }
179
179
  });
180
- // The nudge itself rides an existing tool result. Never appended to a
181
- // user-facing tool's own result (a file_ticket/todo_write already IS
182
- // speaking), and fail-soft: a reminder must never break a tool call.
183
- pi.on("tool_result", async (event) => {
180
+ // The nudge rides the `context` event, which pi fires before each provider
181
+ // request (transformContext) on a throwaway copy of the message array. That
182
+ // makes it reach the model as the most recent message — and, unlike the old
183
+ // `tool_result` seam, it is never rendered to the user and never persisted
184
+ // to the session file. Appending (not replacing) preserves prior `context`
185
+ // handlers (e.g. gate.ts's mode-context filter), and fail-soft means a
186
+ // reminder must never break a turn.
187
+ pi.on("context", async (event) => {
184
188
  try {
185
- if (event.isError)
186
- return;
187
- if (event.toolName === "todo_write" || event.toolName === "file_ticket" || event.toolName === "update_ticket_status") {
188
- return;
189
- }
189
+ const messages = Array.isArray(event.messages) ? event.messages : [];
190
190
  if (!shouldRemind({
191
191
  msSinceSpoke: now() - lastSpokeAt,
192
192
  turnsSinceSpoke,
193
193
  msSinceLastNudge: lastNudgeAt === null ? null : now() - lastNudgeAt,
194
194
  intervalMs,
195
195
  })) {
196
- return;
196
+ return undefined;
197
197
  }
198
198
  nudgeCount += 1;
199
199
  lastNudgeAt = now();
@@ -203,14 +203,18 @@ export function registerSilentTurnReminder(pi, deps = {}) {
203
203
  secondsSinceSpoke: Math.round((now() - lastSpokeAt) / 1000),
204
204
  });
205
205
  return {
206
- content: [
207
- ...event.content,
208
- { type: "text", text: `\n\n${formatSilentReminder()}` },
206
+ messages: [
207
+ ...messages,
208
+ {
209
+ role: "user",
210
+ content: [{ type: "text", text: formatSilentReminder() }],
211
+ timestamp: now(),
212
+ },
209
213
  ],
210
214
  };
211
215
  }
212
216
  catch {
213
- return;
217
+ return undefined;
214
218
  }
215
219
  });
216
220
  }
@@ -19,6 +19,7 @@
19
19
  */
20
20
  import { type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
21
21
  import { Type } from "typebox";
22
+ import type { WorkingLineHandle } from "./workingLine.js";
22
23
  import { runStage } from "./pipeline/runner.js";
23
24
  import { type ModelTier, type PipelineStage } from "./pipeline/types.js";
24
25
  import { renderSubagentCall, renderSubagentResult } from "./subagentRender.js";
@@ -96,6 +97,12 @@ export interface MakeSubagentToolDeps {
96
97
  homeDir?: string;
97
98
  /** Live ultra-mode probe (/ultra): widens the per-call fan-out ceiling. */
98
99
  isUltra?: () => boolean;
100
+ /**
101
+ * The session working-line manager (workingLine.ts). When present, live
102
+ * progress goes through it (so the elapsed/token suffix survives); absent,
103
+ * the tool falls back to setting ui.setWorkingMessage directly.
104
+ */
105
+ workingLine?: WorkingLineHandle;
99
106
  }
100
107
  export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
101
108
  name: string;
@@ -110,6 +117,7 @@ export declare function makeSubagentTool(deps?: MakeSubagentToolDeps): {
110
117
  agent: Type.TOptional<Type.TString>;
111
118
  }>>>;
112
119
  }>;
120
+ renderShell: "self";
113
121
  renderCall: typeof renderSubagentCall;
114
122
  renderResult: typeof renderSubagentResult;
115
123
  execute(_toolCallId: string, params: SubagentParams, signal?: AbortSignal, onUpdate?: (update: {
@@ -141,6 +149,8 @@ export interface RegisterSubagentsDeps {
141
149
  homeDir?: string;
142
150
  /** Live ultra-mode probe (/ultra): widens the per-call fan-out ceiling. */
143
151
  isUltra?: () => boolean;
152
+ /** Session working-line manager; see MakeSubagentToolDeps.workingLine. */
153
+ workingLine?: WorkingLineHandle;
144
154
  }
145
155
  /** Wire the subagent tool and the /agents listing command. */
146
156
  export declare function registerSubagents(pi: ExtensionAPI, deps?: RegisterSubagentsDeps): void;