@yagni-app/code 1.0.0 → 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.
- package/README.md +42 -0
- package/dist/cli.js +231 -6
- package/dist/crashReport.d.ts +8 -0
- package/dist/crashReport.js +13 -1
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +33 -0
- package/dist/extension/askAdvisorTool.d.ts +7 -0
- package/dist/extension/askAdvisorTool.js +11 -3
- package/dist/extension/askYagniTool.js +2 -0
- package/dist/extension/branding.d.ts +15 -0
- package/dist/extension/branding.js +76 -0
- package/dist/extension/chipEditor.d.ts +22 -1
- package/dist/extension/chipEditor.js +58 -5
- package/dist/extension/condensedTools.d.ts +93 -0
- package/dist/extension/condensedTools.js +392 -0
- package/dist/extension/diffStat.d.ts +62 -0
- package/dist/extension/diffStat.js +158 -0
- package/dist/extension/footer.d.ts +2 -0
- package/dist/extension/footer.js +21 -8
- package/dist/extension/index.d.ts +6 -0
- package/dist/extension/index.js +70 -2
- package/dist/extension/permission/execPolicy.js +47 -0
- package/dist/extension/pipeline/invocation.d.ts +7 -0
- package/dist/extension/pipeline/invocation.js +7 -0
- package/dist/extension/pipeline/personas.js +4 -4
- package/dist/extension/pipeline/runner.d.ts +1 -0
- package/dist/extension/pipeline/runner.js +15 -3
- package/dist/extension/pipeline/sessionWorktree.d.ts +64 -0
- package/dist/extension/pipeline/sessionWorktree.js +225 -0
- package/dist/extension/scratchpad.d.ts +66 -0
- package/dist/extension/scratchpad.js +93 -0
- package/dist/extension/subagents.d.ts +10 -0
- package/dist/extension/subagents.js +18 -4
- package/dist/extension/todos.d.ts +1 -0
- package/dist/extension/todos.js +15 -0
- package/dist/extension/toolRuns.d.ts +92 -0
- package/dist/extension/toolRuns.js +201 -0
- package/dist/extension/webFetchTool.js +2 -0
- package/dist/extension/workingLine.d.ts +49 -0
- package/dist/extension/workingLine.js +116 -0
- package/dist/feedback.d.ts +77 -0
- package/dist/feedback.js +500 -0
- package/dist/goHeadless.d.ts +3 -0
- package/dist/goHeadless.js +13 -0
- package/dist/launch.d.ts +8 -0
- package/dist/launch.js +6 -0
- package/dist/otel.d.ts +150 -0
- package/dist/otel.js +291 -0
- package/dist/outputFormat.d.ts +83 -0
- package/dist/outputFormat.js +207 -0
- package/dist/paths.d.ts +10 -0
- package/dist/paths.js +13 -0
- package/dist/worktreeArgs.d.ts +43 -0
- package/dist/worktreeArgs.js +96 -0
- package/package.json +3 -2
|
@@ -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
|
|
@@ -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;
|
|
@@ -74,7 +74,7 @@ You are grounded in how THIS company works: call ask_yagni before inferring a co
|
|
|
74
74
|
|
|
75
75
|
Never fabricate file paths, contents, or findings. If you cannot find something, say so.
|
|
76
76
|
|
|
77
|
-
Your final message is your report back to the driving agent, which has NOT seen what you read or did
|
|
77
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done. Your final message is your report back to the driving agent, which has NOT seen what you read or did: make it a concise report of what was done and the key findings, since the caller relays it to the user and it only needs the essentials. Cover what you did, what you found, exact file paths and key excerpts, and anything the driver must know before continuing.`;
|
|
78
78
|
const GENERAL_AGENT = {
|
|
79
79
|
name: GENERAL_AGENT_NAME,
|
|
80
80
|
description: "General-purpose agent for research, multi-file changes, and self-contained tasks.",
|
|
@@ -94,6 +94,8 @@ one you actually read with a tool. If you cannot find something, say "not
|
|
|
94
94
|
found" — a plausible-sounding invention is worse than no answer because the
|
|
95
95
|
driving agent trusts your report.
|
|
96
96
|
|
|
97
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done.
|
|
98
|
+
|
|
97
99
|
Your final message is your report back to the driving agent, which has NOT
|
|
98
100
|
seen what you read. Make it compressed and complete: exact file paths, the
|
|
99
101
|
key excerpts, and a one-paragraph map of how the pieces relate. Say what you
|
|
@@ -122,6 +124,8 @@ convention, an ownership rule, or anything organization-specific.
|
|
|
122
124
|
Never fabricate file paths or results. Report what you actually did and what
|
|
123
125
|
you actually found.
|
|
124
126
|
|
|
127
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done.
|
|
128
|
+
|
|
125
129
|
Your final message is your report back to the driving agent, which has NOT
|
|
126
130
|
seen what you did. List every file you touched, what changed in each, the
|
|
127
131
|
commands you ran with their outcomes, and anything you deliberately left
|
|
@@ -154,6 +158,8 @@ a convention, an ownership rule, or anything organization-specific.
|
|
|
154
158
|
Never fabricate file paths or findings. If you could not verify something,
|
|
155
159
|
say exactly what you tried and why you could not.
|
|
156
160
|
|
|
161
|
+
Complete the task fully — do not gold-plate, but do not leave it half-done.
|
|
162
|
+
|
|
157
163
|
Your final message is your verdict back to the driving agent, which has NOT
|
|
158
164
|
seen what you read. Format:
|
|
159
165
|
## Verdict
|
|
@@ -344,6 +350,8 @@ export function makeSubagentTool(deps = {}) {
|
|
|
344
350
|
"(list them with /agents); omit `agent` for the general-purpose one.",
|
|
345
351
|
promptSnippet: "subagent: delegate a self-contained task (or parallel tasks) to a fresh-context agent; returns its report.",
|
|
346
352
|
parameters,
|
|
353
|
+
// Self-framed: the condensed transcript look has no tinted tool boxes.
|
|
354
|
+
renderShell: "self",
|
|
347
355
|
renderCall: renderSubagentCall,
|
|
348
356
|
renderResult: renderSubagentResult,
|
|
349
357
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
@@ -391,9 +399,12 @@ export function makeSubagentTool(deps = {}) {
|
|
|
391
399
|
},
|
|
392
400
|
});
|
|
393
401
|
const working = formatWorkingMessage(progresses, now);
|
|
394
|
-
if (
|
|
402
|
+
if (working !== lastWorking) {
|
|
395
403
|
lastWorking = working;
|
|
396
|
-
|
|
404
|
+
if (deps.workingLine)
|
|
405
|
+
deps.workingLine.setActivity(working);
|
|
406
|
+
else
|
|
407
|
+
ui?.setWorkingMessage?.(working);
|
|
397
408
|
}
|
|
398
409
|
};
|
|
399
410
|
emit();
|
|
@@ -423,7 +434,10 @@ export function makeSubagentTool(deps = {}) {
|
|
|
423
434
|
}
|
|
424
435
|
finally {
|
|
425
436
|
// Restore the default "Working…" text whether we resolved or threw.
|
|
426
|
-
|
|
437
|
+
if (deps.workingLine)
|
|
438
|
+
deps.workingLine.setActivity(undefined);
|
|
439
|
+
else
|
|
440
|
+
ui?.setWorkingMessage?.();
|
|
427
441
|
}
|
|
428
442
|
const allFailed = outcomes.every((o) => o.result.exitCode !== 0);
|
|
429
443
|
const sections = outcomes.map((o) => {
|
|
@@ -105,6 +105,7 @@ export declare function makeTodoTool(get: () => TodoItem[], set: (todos: TodoIte
|
|
|
105
105
|
label: string;
|
|
106
106
|
description: string;
|
|
107
107
|
promptSnippet: string;
|
|
108
|
+
promptGuidelines: string[];
|
|
108
109
|
parameters: Type.TObject<{
|
|
109
110
|
todos: Type.TArray<Type.TObject<{
|
|
110
111
|
text: Type.TString;
|
package/dist/extension/todos.js
CHANGED
|
@@ -204,6 +204,21 @@ export function makeTodoTool(get, set) {
|
|
|
204
204
|
"in_progress at a time, mark items completed the moment they are done, and add newly " +
|
|
205
205
|
"discovered steps as pending. Use it for any task with three or more steps, updating as you go.",
|
|
206
206
|
promptSnippet: "todo_write: keep a user-visible checklist for multi-step work (full-list replacement).",
|
|
207
|
+
promptGuidelines: [
|
|
208
|
+
"Use todo_write proactively when a task needs 3 or more distinct steps, requires careful " +
|
|
209
|
+
"planning, or the user gives you a list of things (numbered or comma-separated).",
|
|
210
|
+
"Capture new instructions as todos the moment you receive them, and mark a step in_progress " +
|
|
211
|
+
"BEFORE you start working on it.",
|
|
212
|
+
"When in doubt, use it — a visible checklist answers \"is it stuck?\" without the user having " +
|
|
213
|
+
"to interrupt.",
|
|
214
|
+
"Skip it when there is only one straightforward task, the work is trivial, or the request is " +
|
|
215
|
+
"purely conversational or informational — in those cases just do the task directly.",
|
|
216
|
+
"Pass the FULL list every call; it replaces the previous one. Keep exactly ONE item in_progress " +
|
|
217
|
+
"at a time.",
|
|
218
|
+
"Mark a step completed the moment it is done (do not batch completions), and add newly " +
|
|
219
|
+
"discovered steps as pending. Only mark a step completed when it is fully done — if tests " +
|
|
220
|
+
"fail or work is partial, leave it in_progress and add a new step for the blocker.",
|
|
221
|
+
],
|
|
207
222
|
parameters,
|
|
208
223
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
209
224
|
const normalized = normalizeTodos(params.todos);
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run model for the condensed tool transcript (Claude Code-style).
|
|
3
|
+
*
|
|
4
|
+
* A "run" is a maximal stretch of consecutive QUIET tool rows (reads, searches,
|
|
5
|
+
* listings, successful shell commands, scratchpad edits) uninterrupted by an
|
|
6
|
+
* assistant/user message or a visible row (errors, project writes/edits, image
|
|
7
|
+
* reads). Collapsed, every row in a completed run renders zero lines except the
|
|
8
|
+
* run's tail, which paints one summary line: "Read 2 files, ran 2 shell
|
|
9
|
+
* commands". Expanded (ctrl+o) bypasses this model entirely.
|
|
10
|
+
*
|
|
11
|
+
* The tracker is built purely from render calls (idempotent upserts keyed by
|
|
12
|
+
* toolCallId, in first-render order, which matches display order both live and
|
|
13
|
+
* on session replay). Message boundaries arrive via {@link ToolRunTracker.markBreak}
|
|
14
|
+
* from live `message_start` events only — after a resume, runs that were
|
|
15
|
+
* separated by prose may merge into one summary. That is a deliberate trade:
|
|
16
|
+
* render-derived state is the only state that survives replay.
|
|
17
|
+
*
|
|
18
|
+
* Everything here is PURE (no pi imports) so tests run against plain objects;
|
|
19
|
+
* the pi wiring lives in condensedTools.ts.
|
|
20
|
+
*/
|
|
21
|
+
/** Aggregation category of one tool row. */
|
|
22
|
+
export type RowKind = "read" | "shell" | "search" | "list" | "write" | "edit";
|
|
23
|
+
/** Facts about one tool row; `quiet` is derived, never stored. */
|
|
24
|
+
export interface ToolRow {
|
|
25
|
+
id: string;
|
|
26
|
+
kind: RowKind;
|
|
27
|
+
/** The row's target path lives under the session scratchpad dir. */
|
|
28
|
+
scratchpad: boolean;
|
|
29
|
+
/** The tool call errored (visible regardless of kind). */
|
|
30
|
+
error: boolean;
|
|
31
|
+
/** The result carries inline images (a read screenshot must stay visible). */
|
|
32
|
+
images: boolean;
|
|
33
|
+
/** The result is final (not partial/streaming). */
|
|
34
|
+
final: boolean;
|
|
35
|
+
/** Added lines (scratchpad edits surface as "+N" in the summary). */
|
|
36
|
+
added: number;
|
|
37
|
+
/** An assistant/user message landed between the previous row and this one. */
|
|
38
|
+
breakBefore: boolean;
|
|
39
|
+
/** Repaint hook for this row's component (captured from the render context). */
|
|
40
|
+
invalidate?: () => void;
|
|
41
|
+
/** A deferred repaint is already queued for this row. */
|
|
42
|
+
invalidatePending?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export type ToolRowPatch = Partial<Pick<ToolRow, "kind" | "scratchpad" | "error" | "images" | "final" | "added" | "invalidate">>;
|
|
45
|
+
/** Aggregation kind for a built-in tool name (undefined for non-built-ins). */
|
|
46
|
+
export declare function kindForTool(toolName: string): RowKind | undefined;
|
|
47
|
+
/**
|
|
48
|
+
* Whether a row folds into a run summary. Reads, searches, listings, and
|
|
49
|
+
* successful shell commands always do; writes/edits only when they target the
|
|
50
|
+
* scratchpad (project mutations must stay visible). Errors and image-bearing
|
|
51
|
+
* results are always visible.
|
|
52
|
+
*/
|
|
53
|
+
export declare function isQuiet(row: Pick<ToolRow, "kind" | "scratchpad" | "error" | "images">): boolean;
|
|
54
|
+
/**
|
|
55
|
+
* One summary line for a completed run, phrases in first-occurrence order:
|
|
56
|
+
* "Read 2 files, ran 2 shell commands, made 1 scratchpad edit +20".
|
|
57
|
+
*/
|
|
58
|
+
export declare function summarizeRun(rows: readonly Pick<ToolRow, "kind" | "scratchpad" | "added">[]): string;
|
|
59
|
+
/**
|
|
60
|
+
* Ordered row registry. Upserts are idempotent and diff-aware: only a change
|
|
61
|
+
* invalidates the affected run's tail (the one component whose output depends
|
|
62
|
+
* on neighbors), so repaints converge instead of looping.
|
|
63
|
+
*/
|
|
64
|
+
export declare class ToolRunTracker {
|
|
65
|
+
private rows;
|
|
66
|
+
private indexById;
|
|
67
|
+
private breakPending;
|
|
68
|
+
/** Record an assistant/user message boundary; the next new row starts a fresh run. */
|
|
69
|
+
markBreak(): void;
|
|
70
|
+
get(id: string): ToolRow | undefined;
|
|
71
|
+
upsert(id: string, patch: ToolRowPatch): ToolRow;
|
|
72
|
+
/**
|
|
73
|
+
* The one summary line for `id`, present only when `id` is the tail of a
|
|
74
|
+
* fully-final quiet run. Every other member of the run gets undefined.
|
|
75
|
+
*/
|
|
76
|
+
summaryFor(id: string): string | undefined;
|
|
77
|
+
/** The contiguous quiet run containing `row` (just `[row]` when visible). */
|
|
78
|
+
private runOf;
|
|
79
|
+
private invalidateTailOf;
|
|
80
|
+
/**
|
|
81
|
+
* Defer a row's repaint to a microtask, deduped per row. Upserts run INSIDE
|
|
82
|
+
* pi's synchronous `updateDisplay` pass (renderers call them), and the
|
|
83
|
+
* component's `invalidate()` re-enters `updateDisplay` immediately — a
|
|
84
|
+
* synchronous call from a renderer would rebuild the container while the
|
|
85
|
+
* outer frame is still appending to it, stacking duplicate children.
|
|
86
|
+
* Deferring means every repaint runs as its own clean top-level pass; it
|
|
87
|
+
* still cannot loop, because the diff-aware upsert only schedules on an
|
|
88
|
+
* actual change.
|
|
89
|
+
*/
|
|
90
|
+
private scheduleInvalidate;
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=toolRuns.d.ts.map
|