@yagni-app/code-staging 0.1.0-staging.997.1 → 0.2.0-staging.1025.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 +58 -9
- package/dist/claudeCompat.d.ts +36 -5
- package/dist/claudeCompat.js +85 -23
- package/dist/claudePlugins.d.ts +109 -0
- package/dist/claudePlugins.js +336 -0
- package/dist/cli.js +14 -4
- package/dist/crashReport.d.ts +135 -0
- package/dist/crashReport.js +291 -0
- package/dist/doctor.d.ts +21 -0
- package/dist/doctor.js +52 -0
- package/dist/extension/askAdvisorTool.js +7 -1
- package/dist/extension/bless.js +16 -3
- package/dist/extension/boostCommand.d.ts +144 -0
- package/dist/extension/boostCommand.js +263 -0
- package/dist/extension/branding.d.ts +31 -0
- package/dist/extension/branding.js +37 -0
- package/dist/extension/chipEditor.js +7 -3
- package/dist/extension/claudeRules.d.ts +54 -0
- package/dist/extension/claudeRules.js +180 -0
- package/dist/extension/config.d.ts +61 -0
- package/dist/extension/config.js +86 -0
- package/dist/extension/costHud.d.ts +128 -15
- package/dist/extension/costHud.js +189 -19
- package/dist/extension/crashReport.d.ts +89 -0
- package/dist/extension/crashReport.js +241 -0
- package/dist/extension/index.d.ts +43 -4
- package/dist/extension/index.js +241 -32
- package/dist/extension/initPass.d.ts +65 -47
- package/dist/extension/initPass.js +145 -145
- package/dist/extension/mcpTools.d.ts +57 -0
- package/dist/extension/mcpTools.js +132 -0
- package/dist/extension/pipeline/eval.d.ts +42 -5
- package/dist/extension/pipeline/eval.js +44 -0
- package/dist/extension/pipeline/goCommand.d.ts +18 -0
- package/dist/extension/pipeline/goCommand.js +139 -26
- package/dist/extension/pipeline/goCompareCommand.d.ts +18 -8
- package/dist/extension/pipeline/goCompareCommand.js +42 -23
- package/dist/extension/pipeline/orchestrator.js +9 -0
- package/dist/extension/pipeline/runCostTable.d.ts +37 -0
- package/dist/extension/pipeline/runCostTable.js +165 -0
- package/dist/extension/pipeline/runState.d.ts +19 -0
- package/dist/extension/pipeline/runState.js +11 -0
- package/dist/extension/pipeline/runner.d.ts +19 -0
- package/dist/extension/pipeline/runner.js +13 -1
- package/dist/extension/pipeline/scrubSecrets.js +2 -2
- package/dist/extension/pipeline/stages.d.ts +3 -1
- package/dist/extension/pipeline/stages.js +3 -1
- package/dist/extension/pipeline/types.d.ts +7 -4
- package/dist/extension/pipeline/verify.js +6 -1
- package/dist/extension/pipeline/worktree.js +3 -1
- package/dist/extension/provider.d.ts +7 -1
- package/dist/extension/provider.js +8 -1
- package/dist/extension/recall.js +5 -2
- package/dist/extension/rerouteNotice.d.ts +42 -0
- package/dist/extension/rerouteNotice.js +67 -0
- package/dist/extension/sessionRuns.d.ts +45 -0
- package/dist/extension/sessionRuns.js +77 -0
- package/dist/extension/subagents.d.ts +17 -7
- package/dist/extension/subagents.js +52 -7
- package/dist/launch.d.ts +17 -3
- package/dist/launch.js +22 -9
- package/dist/login.d.ts +7 -0
- package/dist/login.js +3 -1
- package/package.json +2 -2
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crash reporting for the YAGNI Code launcher (and, via deep import, the
|
|
3
|
+
* desktop driver): a sanitized, fire-and-forget POST to the customer's own
|
|
4
|
+
* YAGNI backend at /api/yagni-code/crash. Deliberately no third-party
|
|
5
|
+
* telemetry SDK — crash reports are the ONLY thing this CLI phones home, they
|
|
6
|
+
* contain no code and no prompts, and `YAGNI_DISABLE_CRASH_REPORTS=1` turns
|
|
7
|
+
* them off entirely (same switch family as YAGNI_DISABLE_UPDATE_CHECK).
|
|
8
|
+
*
|
|
9
|
+
* Everything here is fail-soft: reporting never throws, never retries, never
|
|
10
|
+
* blocks longer than the short timeout, and never changes how the process
|
|
11
|
+
* would otherwise crash.
|
|
12
|
+
*
|
|
13
|
+
* The sanitizer core (scrub patterns + env-value redaction + path collapse +
|
|
14
|
+
* caps) is duplicated in `pi-extension-yagni/src/crashReport.ts` (the
|
|
15
|
+
* extension is bundled standalone and cannot import across packages) and
|
|
16
|
+
* re-run server-side in `backend/src/yagniCode/crashReports.ts`. Keep the
|
|
17
|
+
* three in sync. Spec:
|
|
18
|
+
* docs/superpowers/specs/2026-08-08-crash-reporting-design.md
|
|
19
|
+
*/
|
|
20
|
+
import { credentialsFromProfile, readActiveProfile } from "./profiles.js";
|
|
21
|
+
export const CRASH_REPORT_DISABLE_ENV = "YAGNI_DISABLE_CRASH_REPORTS";
|
|
22
|
+
export const CRASH_REPORT_TIMEOUT_MS = 1_500;
|
|
23
|
+
/** Hard ceiling the process-exit path waits for a report before exiting. */
|
|
24
|
+
export const CRASH_REPORT_EXIT_RACE_MS = 1_600;
|
|
25
|
+
export const MAX_CRASH_MESSAGE = 512;
|
|
26
|
+
export const MAX_CRASH_ERROR_CLASS = 128;
|
|
27
|
+
export const MAX_CRASH_STACK = 8_000;
|
|
28
|
+
export const MAX_CRASH_STACK_FRAMES = 40;
|
|
29
|
+
/** If the serialized payload would exceed this, the stack is dropped. */
|
|
30
|
+
export const MAX_CRASH_PAYLOAD_BYTES = 16_384;
|
|
31
|
+
/** Same truthiness rule as updateChecksDisabled: set and not "" / "0". */
|
|
32
|
+
export function crashReportsDisabled(env = process.env) {
|
|
33
|
+
const value = env[CRASH_REPORT_DISABLE_ENV];
|
|
34
|
+
return value !== undefined && value !== "" && value !== "0";
|
|
35
|
+
}
|
|
36
|
+
// Mirrors scrubSecrets (backend yagniCode/scrubSecrets.ts and
|
|
37
|
+
// pi-extension-yagni pipeline/scrubSecrets.ts) — keep in sync.
|
|
38
|
+
const SECRET_PATTERNS = [
|
|
39
|
+
[/\b([a-z][a-z0-9+.\-]*:\/\/[^\s:@/]+):[^\s:@/]+@/gi, "$1:[REDACTED]@"],
|
|
40
|
+
[/\b(sk-[A-Za-z0-9]{16,}|sk_(?:live|test)_[A-Za-z0-9]{16,}|rk_(?:live|test)_[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z_\-]{20,})\b/g, "[REDACTED]"],
|
|
41
|
+
[/\b([A-Za-z0-9_]*(?:secret|password|passwd|api[_-]?key|token|private[_-]?key|access[_-]?key)[A-Za-z0-9_]*)\b(\s*[:=]\s*)("[^"]+"|'[^']+'|`[^`]+`|[^\s"']+)/gi, "$1$2[REDACTED]"],
|
|
42
|
+
[/\b[A-Za-z0-9+/]{40,}={0,2}\b/g, "[REDACTED]"],
|
|
43
|
+
];
|
|
44
|
+
const HOME_DIR_RE = /(?:\/(?:Users|home)\/|[A-Za-z]:\\Users\\)[^\s/\\]+/g;
|
|
45
|
+
/**
|
|
46
|
+
* Absolute or ~-rooted path tokens (colon excluded so `file.ts:10:5` keeps its
|
|
47
|
+
* location). The lookbehind stops a mid-path slash from matching, so an
|
|
48
|
+
* already-relative path (`src/pipeline/run.ts` after the repo-root strip)
|
|
49
|
+
* survives intact instead of being re-collapsed at its inner slashes. Spaces
|
|
50
|
+
* are deliberately ALLOWED inside the token: real directories contain spaces
|
|
51
|
+
* ("~/Client Confidential Project/src/x.ts"), and a space-stopping class
|
|
52
|
+
* would leak every segment after the first space verbatim. The cost is that
|
|
53
|
+
* prose following a path can be folded into the kept basename — over-redacts
|
|
54
|
+
* rather than under-redacts.
|
|
55
|
+
*/
|
|
56
|
+
const PATH_TOKEN_RE = /(?<![\w.~/\\])(?:~\/|\/|[A-Za-z]:\\)[^\n():'",]+/g;
|
|
57
|
+
/** Values too short to redact safely ("1", "true", "utf-8" would shred text). */
|
|
58
|
+
const MIN_ENV_VALUE_LENGTH = 8;
|
|
59
|
+
function isPathLikeValue(value) {
|
|
60
|
+
// Filesystem-path values (HOME, PWD, PATH, TMPDIR…) are handled — and more
|
|
61
|
+
// readably — by the path rules; replacing them here would break ~-collapse.
|
|
62
|
+
return value.startsWith("/") || value.startsWith("~") || /^[A-Za-z]:\\/.test(value);
|
|
63
|
+
}
|
|
64
|
+
function collapsePathToken(token) {
|
|
65
|
+
const nm = token.lastIndexOf("node_modules/");
|
|
66
|
+
if (nm >= 0)
|
|
67
|
+
return token.slice(nm);
|
|
68
|
+
const cut = Math.max(token.lastIndexOf("/"), token.lastIndexOf("\\"));
|
|
69
|
+
const base = cut >= 0 ? token.slice(cut + 1) : token;
|
|
70
|
+
return base.length > 0 ? base : token;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Sanitize one piece of crash text. Order matters:
|
|
74
|
+
* 1. env-value redaction (non-path values, length >= 8) → `[ENV:NAME]`
|
|
75
|
+
* 2. secret patterns (keys, connection strings, base64 blobs)
|
|
76
|
+
* 3. home-dir collapse to `~`
|
|
77
|
+
* 4. repo-root strip (repo files stay readable as relative paths)
|
|
78
|
+
* 5. every remaining absolute/`~` path → basename (node_modules kept from
|
|
79
|
+
* `node_modules/` on, so dependency frames stay diagnosable)
|
|
80
|
+
* Over-redacts rather than under-redacts; pure; never throws.
|
|
81
|
+
*/
|
|
82
|
+
export function sanitizeCrashText(text, opts = {}) {
|
|
83
|
+
let out = text;
|
|
84
|
+
const env = opts.env ?? process.env;
|
|
85
|
+
const entries = Object.entries(env)
|
|
86
|
+
.filter((entry) => typeof entry[1] === "string" &&
|
|
87
|
+
entry[1].length >= MIN_ENV_VALUE_LENGTH &&
|
|
88
|
+
!isPathLikeValue(entry[1]))
|
|
89
|
+
// Longest first so a value containing another value cannot leave a tail.
|
|
90
|
+
.sort((a, b) => b[1].length - a[1].length);
|
|
91
|
+
for (const [name, value] of entries) {
|
|
92
|
+
if (out.includes(value))
|
|
93
|
+
out = out.split(value).join(`[ENV:${name}]`);
|
|
94
|
+
}
|
|
95
|
+
for (const [re, repl] of SECRET_PATTERNS)
|
|
96
|
+
out = out.replace(re, repl);
|
|
97
|
+
out = out.replace(HOME_DIR_RE, "~");
|
|
98
|
+
if (opts.repoRoot) {
|
|
99
|
+
const root = opts.repoRoot.replace(HOME_DIR_RE, "~").replace(/\/+$/, "");
|
|
100
|
+
if (root.length > 1)
|
|
101
|
+
out = out.split(`${root}/`).join("");
|
|
102
|
+
}
|
|
103
|
+
out = out.replace(PATH_TOKEN_RE, collapsePathToken);
|
|
104
|
+
return out;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Reduce an arbitrary thrown value to sanitized error facts. Reads ONLY
|
|
108
|
+
* `name`, `message`, and `stack` — custom fields (response bodies, request
|
|
109
|
+
* payloads…) are never touched.
|
|
110
|
+
*/
|
|
111
|
+
export function sanitizeCrashError(err, opts = {}) {
|
|
112
|
+
let errorClass = "Error";
|
|
113
|
+
let message;
|
|
114
|
+
let stack;
|
|
115
|
+
if (err instanceof Error) {
|
|
116
|
+
errorClass = err.name || "Error";
|
|
117
|
+
message = err.message;
|
|
118
|
+
stack = typeof err.stack === "string" ? err.stack : undefined;
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
try {
|
|
122
|
+
message = String(err);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
message = "unstringifiable thrown value";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
const cappedStack = stack !== undefined
|
|
129
|
+
? sanitizeCrashText(stack, opts)
|
|
130
|
+
.split("\n")
|
|
131
|
+
.slice(0, MAX_CRASH_STACK_FRAMES)
|
|
132
|
+
.join("\n")
|
|
133
|
+
.slice(0, MAX_CRASH_STACK)
|
|
134
|
+
: undefined;
|
|
135
|
+
return {
|
|
136
|
+
errorClass: sanitizeCrashText(errorClass, opts).slice(0, MAX_CRASH_ERROR_CLASS),
|
|
137
|
+
message: sanitizeCrashText(message, opts).slice(0, MAX_CRASH_MESSAGE),
|
|
138
|
+
...(cappedStack !== undefined ? { stack: cappedStack } : {}),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Sanitize + POST one crash report from pre-extracted fields. Resolves on
|
|
143
|
+
* every outcome — timeout, network error, non-2xx, disabled — and never
|
|
144
|
+
* throws; there is nobody upstream who could act on a failure, and a
|
|
145
|
+
* reporting bug must never re-crash the process.
|
|
146
|
+
*/
|
|
147
|
+
export async function sendCrashReport(input) {
|
|
148
|
+
try {
|
|
149
|
+
const env = input.env ?? process.env;
|
|
150
|
+
if (crashReportsDisabled(env))
|
|
151
|
+
return;
|
|
152
|
+
const opts = { env, repoRoot: input.repoRoot };
|
|
153
|
+
const stack = input.stack !== undefined
|
|
154
|
+
? sanitizeCrashText(input.stack, opts)
|
|
155
|
+
.split("\n")
|
|
156
|
+
.slice(0, MAX_CRASH_STACK_FRAMES)
|
|
157
|
+
.join("\n")
|
|
158
|
+
.slice(0, MAX_CRASH_STACK)
|
|
159
|
+
: undefined;
|
|
160
|
+
const payload = {
|
|
161
|
+
client: input.client,
|
|
162
|
+
clientVersion: input.clientVersion,
|
|
163
|
+
platform: `${process.platform} ${process.arch}`,
|
|
164
|
+
errorClass: sanitizeCrashText(input.errorClass, opts).slice(0, MAX_CRASH_ERROR_CLASS),
|
|
165
|
+
message: sanitizeCrashText(input.message, opts).slice(0, MAX_CRASH_MESSAGE),
|
|
166
|
+
...(stack !== undefined ? { stack } : {}),
|
|
167
|
+
...(input.context !== undefined ? { context: input.context } : {}),
|
|
168
|
+
timestamp: new Date().toISOString(),
|
|
169
|
+
};
|
|
170
|
+
if (JSON.stringify(payload).length > MAX_CRASH_PAYLOAD_BYTES) {
|
|
171
|
+
delete payload.stack;
|
|
172
|
+
}
|
|
173
|
+
const fetchImpl = input.fetchImpl ?? fetch;
|
|
174
|
+
const controller = new AbortController();
|
|
175
|
+
const timer = setTimeout(() => controller.abort(), input.timeoutMs ?? CRASH_REPORT_TIMEOUT_MS);
|
|
176
|
+
timer.unref?.();
|
|
177
|
+
try {
|
|
178
|
+
await fetchImpl(`${input.baseUrl.replace(/\/$/, "")}/api/yagni-code/crash`, {
|
|
179
|
+
method: "POST",
|
|
180
|
+
headers: {
|
|
181
|
+
"content-type": "application/json",
|
|
182
|
+
authorization: `Bearer ${input.token}`,
|
|
183
|
+
},
|
|
184
|
+
body: JSON.stringify(payload),
|
|
185
|
+
signal: controller.signal,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// fail-soft by contract
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Fire one crash report from a live thrown value. Same never-throws contract
|
|
198
|
+
* as {@link sendCrashReport}, which does the sending.
|
|
199
|
+
*/
|
|
200
|
+
export async function reportCrash(input) {
|
|
201
|
+
try {
|
|
202
|
+
const env = input.env ?? process.env;
|
|
203
|
+
const sanitized = sanitizeCrashError(input.error, { env, repoRoot: input.repoRoot });
|
|
204
|
+
await sendCrashReport({
|
|
205
|
+
client: input.client,
|
|
206
|
+
clientVersion: input.clientVersion,
|
|
207
|
+
errorClass: sanitized.errorClass,
|
|
208
|
+
message: sanitized.message,
|
|
209
|
+
stack: sanitized.stack,
|
|
210
|
+
context: input.context,
|
|
211
|
+
baseUrl: input.baseUrl,
|
|
212
|
+
token: input.token,
|
|
213
|
+
env,
|
|
214
|
+
repoRoot: input.repoRoot,
|
|
215
|
+
fetchImpl: input.fetchImpl,
|
|
216
|
+
timeoutMs: input.timeoutMs,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
// fail-soft by contract
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
async function resolveProfileTarget() {
|
|
224
|
+
try {
|
|
225
|
+
const profile = await readActiveProfile();
|
|
226
|
+
const creds = credentialsFromProfile(profile);
|
|
227
|
+
if (!creds?.token)
|
|
228
|
+
return null;
|
|
229
|
+
return { baseUrl: creds.baseUrl, token: creds.token };
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* The handler behind uncaughtException/unhandledRejection: report (bounded by
|
|
237
|
+
* `raceMs`), then preserve today's crash behavior — print the error to stderr
|
|
238
|
+
* and exit 1. Exposed for tests; `installProcessCrashHandlers` registers it.
|
|
239
|
+
*/
|
|
240
|
+
export function makeProcessCrashHandler(context, opts) {
|
|
241
|
+
const exit = opts.exit ?? ((code) => process.exit(code));
|
|
242
|
+
const writeStderr = opts.writeStderr ?? ((text) => process.stderr.write(text));
|
|
243
|
+
return (err) => {
|
|
244
|
+
void (async () => {
|
|
245
|
+
try {
|
|
246
|
+
const resolveTarget = opts.resolveTarget ?? resolveProfileTarget;
|
|
247
|
+
const target = await resolveTarget();
|
|
248
|
+
if (target) {
|
|
249
|
+
// The race timer is deliberately ref'd: it guarantees the exit path
|
|
250
|
+
// even if the fetch never settles.
|
|
251
|
+
await Promise.race([
|
|
252
|
+
reportCrash({
|
|
253
|
+
client: opts.client,
|
|
254
|
+
clientVersion: opts.clientVersion,
|
|
255
|
+
error: err,
|
|
256
|
+
context,
|
|
257
|
+
baseUrl: target.baseUrl,
|
|
258
|
+
token: target.token,
|
|
259
|
+
env: opts.env,
|
|
260
|
+
repoRoot: opts.repoRoot,
|
|
261
|
+
fetchImpl: opts.fetchImpl,
|
|
262
|
+
}),
|
|
263
|
+
new Promise((resolve) => setTimeout(resolve, opts.raceMs ?? CRASH_REPORT_EXIT_RACE_MS)),
|
|
264
|
+
]);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// reporting must never re-crash the crash path
|
|
269
|
+
}
|
|
270
|
+
try {
|
|
271
|
+
const text = err instanceof Error ? (err.stack ?? err.message) : String(err);
|
|
272
|
+
writeStderr(`${text}\n`);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
// even stderr can fail (EPIPE); still exit
|
|
276
|
+
}
|
|
277
|
+
exit(1);
|
|
278
|
+
})();
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Install process-level crash reporting. Call once, before main() runs, from
|
|
283
|
+
* a process YAGNI owns (the launcher, the desktop driver) — never from inside
|
|
284
|
+
* pi's process, where adding these listeners would change crash semantics.
|
|
285
|
+
*/
|
|
286
|
+
export function installProcessCrashHandlers(opts) {
|
|
287
|
+
const proc = opts.proc ?? process;
|
|
288
|
+
proc.on("uncaughtException", makeProcessCrashHandler("uncaught-exception", opts));
|
|
289
|
+
proc.on("unhandledRejection", makeProcessCrashHandler("unhandled-rejection", opts));
|
|
290
|
+
}
|
|
291
|
+
//# sourceMappingURL=crashReport.js.map
|
package/dist/doctor.d.ts
CHANGED
|
@@ -60,6 +60,13 @@ export declare function checkCliUpdate(probe: {
|
|
|
60
60
|
latest: string | null;
|
|
61
61
|
}): CheckResult;
|
|
62
62
|
export declare function checkGh(onPath: boolean): CheckResult;
|
|
63
|
+
/** What the Windows bash probe found (pi needs a bash — Git Bash — on win32). */
|
|
64
|
+
export interface BashProbe {
|
|
65
|
+
found: boolean;
|
|
66
|
+
/** The resolved bash path, when found. */
|
|
67
|
+
where?: string;
|
|
68
|
+
}
|
|
69
|
+
export declare function checkBash(probe: BashProbe): CheckResult;
|
|
63
70
|
export interface DoctorReport {
|
|
64
71
|
checks: CheckResult[];
|
|
65
72
|
exitCode: number;
|
|
@@ -75,12 +82,26 @@ export interface DoctorDeps {
|
|
|
75
82
|
probeBackend?: (baseUrl: string, token: string) => Promise<BackendProbe>;
|
|
76
83
|
probeStateDir?: () => StateDirProbe;
|
|
77
84
|
ghOnPath?: () => boolean;
|
|
85
|
+
/** Platform seam for the win32-only bash check (defaults to process.platform). */
|
|
86
|
+
platform?: NodeJS.Platform;
|
|
87
|
+
/** Windows bash probe; only ever called when the platform is win32. */
|
|
88
|
+
probeBash?: () => BashProbe;
|
|
78
89
|
currentVersion?: string;
|
|
79
90
|
probeLatestVersion?: () => Promise<string | null>;
|
|
80
91
|
log?: (msg: string) => void;
|
|
81
92
|
}
|
|
82
93
|
/** Whether a `gh` executable is resolvable on PATH (no subprocess spawn). */
|
|
83
94
|
export declare function ghOnPathDefault(env?: NodeJS.ProcessEnv): boolean;
|
|
95
|
+
/**
|
|
96
|
+
* Locate the bash pi will actually use on Windows. The order and locations
|
|
97
|
+
* MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
|
|
98
|
+
* `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
|
|
99
|
+
* then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
|
|
100
|
+
* per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
|
|
101
|
+
* to pi, and a doctor that reported it green would bless a machine where the
|
|
102
|
+
* first bash tool call throws. Pure function of env, like the gh probe.
|
|
103
|
+
*/
|
|
104
|
+
export declare function bashOnWindowsDefault(env?: NodeJS.ProcessEnv): BashProbe;
|
|
84
105
|
/**
|
|
85
106
|
* Gather every check result against the (injectable) probes. Pure ordering; each
|
|
86
107
|
* individual check is a pure function of its probe.
|
package/dist/doctor.js
CHANGED
|
@@ -198,6 +198,24 @@ export function checkGh(onPath) {
|
|
|
198
198
|
required: false,
|
|
199
199
|
};
|
|
200
200
|
}
|
|
201
|
+
export function checkBash(probe) {
|
|
202
|
+
if (!probe.found) {
|
|
203
|
+
return {
|
|
204
|
+
name: "bash",
|
|
205
|
+
status: "fail",
|
|
206
|
+
detail: "no bash found (pi runs its shell commands through bash)",
|
|
207
|
+
hint: "Install Git for Windows — pi needs its bash: https://gitforwindows.org "
|
|
208
|
+
+ "(a per-user install must also put bash.exe on PATH)",
|
|
209
|
+
required: true,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return {
|
|
213
|
+
name: "bash",
|
|
214
|
+
status: "ok",
|
|
215
|
+
detail: probe.where ? `found (${probe.where})` : "found",
|
|
216
|
+
required: true,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
201
219
|
function toOctal(mode) {
|
|
202
220
|
return `0${(mode & 0o777).toString(8).padStart(3, "0")}`;
|
|
203
221
|
}
|
|
@@ -296,6 +314,32 @@ export function ghOnPathDefault(env = process.env) {
|
|
|
296
314
|
}
|
|
297
315
|
return false;
|
|
298
316
|
}
|
|
317
|
+
/**
|
|
318
|
+
* Locate the bash pi will actually use on Windows. The order and locations
|
|
319
|
+
* MIRROR pi 0.83's own shell resolution (dist/utils/shell.js) exactly:
|
|
320
|
+
* `%ProgramFiles%\Git\bin\bash.exe`, then `%ProgramFiles(x86)%\Git\bin\bash.exe`,
|
|
321
|
+
* then `bash.exe` on PATH (`where bash.exe`). Deliberately NOTHING wider — a
|
|
322
|
+
* per-user Git install in `%LOCALAPPDATA%` that is not on PATH is invisible
|
|
323
|
+
* to pi, and a doctor that reported it green would bless a machine where the
|
|
324
|
+
* first bash tool call throws. Pure function of env, like the gh probe.
|
|
325
|
+
*/
|
|
326
|
+
export function bashOnWindowsDefault(env = process.env) {
|
|
327
|
+
for (const root of [env.ProgramFiles, env["ProgramFiles(x86)"]]) {
|
|
328
|
+
if (!root)
|
|
329
|
+
continue;
|
|
330
|
+
const candidate = join(root, "Git", "bin", "bash.exe");
|
|
331
|
+
if (existsSync(candidate))
|
|
332
|
+
return { found: true, where: candidate };
|
|
333
|
+
}
|
|
334
|
+
for (const dir of (env.PATH ?? "").split(delimiter)) {
|
|
335
|
+
if (!dir)
|
|
336
|
+
continue;
|
|
337
|
+
const candidate = join(dir, "bash.exe");
|
|
338
|
+
if (existsSync(candidate))
|
|
339
|
+
return { found: true, where: candidate };
|
|
340
|
+
}
|
|
341
|
+
return { found: false };
|
|
342
|
+
}
|
|
299
343
|
/**
|
|
300
344
|
* Gather every check result against the (injectable) probes. Pure ordering; each
|
|
301
345
|
* individual check is a pure function of its probe.
|
|
@@ -308,10 +352,18 @@ export async function gatherChecks(deps = {}) {
|
|
|
308
352
|
const probeBackend = deps.probeBackend ?? defaultProbeBackend;
|
|
309
353
|
const probeStateDir = deps.probeStateDir ?? defaultProbeStateDir;
|
|
310
354
|
const ghOnPath = deps.ghOnPath ?? (() => ghOnPathDefault());
|
|
355
|
+
const platform = deps.platform ?? process.platform;
|
|
356
|
+
const probeBash = deps.probeBash ?? (() => bashOnWindowsDefault());
|
|
311
357
|
const probeLatestVersion = deps.probeLatestVersion ?? (() => fetchLatestVersion());
|
|
312
358
|
const checks = [];
|
|
313
359
|
checks.push(checkPiEngine(probePiEngine()));
|
|
314
360
|
checks.push(checkExtension(probeExtension()));
|
|
361
|
+
// win32 only, and skipped means NOT SHOWN: on macOS/Linux there is nothing
|
|
362
|
+
// to say. pi shells out through bash, so a Windows machine without Git Bash
|
|
363
|
+
// cannot launch at all — a required red, like a missing engine.
|
|
364
|
+
if (platform === "win32") {
|
|
365
|
+
checks.push(checkBash(probeBash()));
|
|
366
|
+
}
|
|
315
367
|
checks.push(checkCliUpdate({
|
|
316
368
|
current: deps.currentVersion ?? currentCliVersion(),
|
|
317
369
|
latest: await probeLatestVersion(),
|
|
@@ -113,7 +113,13 @@ export function makeAskAdvisorTool(opts) {
|
|
|
113
113
|
content: [{ type: "text", text: "Consulting the advisor…" }],
|
|
114
114
|
details: { consults: opts.state.read().consults, cost: 0 },
|
|
115
115
|
});
|
|
116
|
-
const result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, {
|
|
116
|
+
const result = await runStage(advisorStage(), { ticket: buildConsultBrief(params) }, {
|
|
117
|
+
cwd: ctx?.cwd ?? process.cwd(),
|
|
118
|
+
...(signal ? { signal } : {}),
|
|
119
|
+
// YAG-471: attribute the consult's completions to the advisor, not
|
|
120
|
+
// the "plan" stage id advisorStage() borrows (see its docblock).
|
|
121
|
+
callerLabel: "advisor",
|
|
122
|
+
});
|
|
117
123
|
const cost = result.usage?.cost ?? 0;
|
|
118
124
|
const state = opts.state.record(cost);
|
|
119
125
|
if (result.exitCode !== 0 && !result.finalOutput.trim()) {
|
package/dist/extension/bless.js
CHANGED
|
@@ -17,6 +17,14 @@
|
|
|
17
17
|
* one (and vice versa). Everything is pure except the in-memory rule list.
|
|
18
18
|
*/
|
|
19
19
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
20
|
+
/**
|
|
21
|
+
* Windows-only separator normalization so prefixes compare and display with
|
|
22
|
+
* `/` on every platform: pi's tools emit forward-slash paths even on Windows,
|
|
23
|
+
* and a rule keyed `C:\repo\src\api` would silently never match a call for
|
|
24
|
+
* `C:/repo/src/api/a.ts`. On POSIX this is the identity (a `\` there is a
|
|
25
|
+
* legal filename character, not a separator).
|
|
26
|
+
*/
|
|
27
|
+
const norm = sep === "\\" ? (p) => p.split("\\").join("/") : (p) => p;
|
|
20
28
|
/** The file path a call targets, or null for path-less tools (bash). */
|
|
21
29
|
export function blessPath(params) {
|
|
22
30
|
const p = params.path;
|
|
@@ -25,7 +33,12 @@ export function blessPath(params) {
|
|
|
25
33
|
/** Build a fresh, empty session bless store rooted at `cwd`. */
|
|
26
34
|
export function makeBlessStore(cwd) {
|
|
27
35
|
const rules = [];
|
|
28
|
-
|
|
36
|
+
// Always THROUGH resolve, even for absolute inputs: on Windows a bare
|
|
37
|
+
// "/repo/…" is drive-relative and resolve() drive-qualifies it, so a rule
|
|
38
|
+
// minted from a relative path and a call carrying an absolute one land on
|
|
39
|
+
// the same canonical form. (For an already-absolute POSIX path this is just
|
|
40
|
+
// normalization.)
|
|
41
|
+
const abs = (p) => norm(resolve(cwd, p));
|
|
29
42
|
/** Absolute directory prefix a bless of this call would cover, or null. */
|
|
30
43
|
function prefixFor(params) {
|
|
31
44
|
const p = blessPath(params);
|
|
@@ -41,7 +54,7 @@ export function makeBlessStore(cwd) {
|
|
|
41
54
|
const prefix = prefixFor(params);
|
|
42
55
|
if (prefix === null)
|
|
43
56
|
return null;
|
|
44
|
-
const rel = relative(cwd, prefix);
|
|
57
|
+
const rel = norm(relative(cwd, prefix));
|
|
45
58
|
// Inside the tree → the relative dir (or "." for the repo root); outside →
|
|
46
59
|
// the absolute path so the user sees exactly what they are blessing.
|
|
47
60
|
if (rel === "")
|
|
@@ -64,7 +77,7 @@ export function makeBlessStore(cwd) {
|
|
|
64
77
|
if (p === null)
|
|
65
78
|
return false; // path-less (bash) never auto-approves
|
|
66
79
|
const target = abs(p);
|
|
67
|
-
return rules.some((r) => r.tool === tool && (target === r.prefix || target.startsWith(r.prefix
|
|
80
|
+
return rules.some((r) => r.tool === tool && (target === r.prefix || target.startsWith(`${r.prefix}/`)));
|
|
68
81
|
},
|
|
69
82
|
rules() {
|
|
70
83
|
return rules.slice();
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/boost` — the sanctioned session-scoped escalation to Peak (spec §7).
|
|
3
|
+
*
|
|
4
|
+
* The session default is the `balanced` tier. `/boost` flips the
|
|
5
|
+
* DRIVER session's live model to `peak` until `/boost off` or the process
|
|
6
|
+
* exits; `peak` stays directly pickable through pi's own model picker too,
|
|
7
|
+
* this just adds a one-word lever plus an attribution flag.
|
|
8
|
+
*
|
|
9
|
+
* Two halves, same split as `advisor.ts` / `permission.ts`:
|
|
10
|
+
*
|
|
11
|
+
* - `boostOn` / `boostOff` are PURE. They own every transition and the exact
|
|
12
|
+
* notice copy; no pi, no I/O, no env. That is what needs exhaustive tests.
|
|
13
|
+
* - `registerBoostCommand` is the wiring: it applies the pure result's
|
|
14
|
+
* `targetModelId` through pi's real model-switch API, toggles
|
|
15
|
+
* `process.env.YAGNI_BOOST`, and paints the notice + a status-bar chip.
|
|
16
|
+
*
|
|
17
|
+
* The model-switch API (verified against pi 0.83.0's typings, not guessed):
|
|
18
|
+
* `ctx.modelRegistry.find(provider, modelId)` resolves a tier id to a
|
|
19
|
+
* `Model`, and `pi.setModel(model)` (the top-level `ExtensionAPI` method, NOT
|
|
20
|
+
* `ctx.setModel` — `ExtensionCommandContext` does not expose a setter) applies
|
|
21
|
+
* it to the live session, resolving `false` when no API key is configured.
|
|
22
|
+
* The `yagni` provider's catalog entries key `id` on the tier id itself (see
|
|
23
|
+
* `provider.ts`), so `peak` is the pi model id for the peak tier.
|
|
24
|
+
*
|
|
25
|
+
* State lives in the registration closure — module/session-scoped, mirroring
|
|
26
|
+
* `costHud.ts`'s accumulator — because a session's process exit is the only
|
|
27
|
+
* "end" there is; nothing needs to persist across it. `registerBoostCommand`
|
|
28
|
+
* returns a small `{ isBoosted() }` handle onto that same closure so other
|
|
29
|
+
* registrations (currently just `/cost`, see below) can read the live flag
|
|
30
|
+
* without a second source of truth.
|
|
31
|
+
*
|
|
32
|
+
* Picker-drift guard (spec review item 3): pi's OWN model picker (Ctrl+P,
|
|
33
|
+
* `/model`) can change the live model independently of `/boost` in either
|
|
34
|
+
* direction, so `state.active` can go stale relative to `ctx.model.id`. Both
|
|
35
|
+
* halves read the live model and reconcile rather than trusting `state.active`
|
|
36
|
+
* blindly:
|
|
37
|
+
*
|
|
38
|
+
* - `boostOn` while already active but the live model has drifted OFF peak
|
|
39
|
+
* (the user cycled models mid-boost without `/boost off`): re-applies
|
|
40
|
+
* peak instead of a silent "Already boosted." that would leave the
|
|
41
|
+
* driver quietly running a cheaper tier under an attribution header that
|
|
42
|
+
* claims otherwise. The remembered `priorModelId` is left untouched — the
|
|
43
|
+
* tier to restore is still whatever was active before the ORIGINAL boost,
|
|
44
|
+
* not the tier the user happened to drift to.
|
|
45
|
+
* - `boostOff` while active but the live model is no longer peak (same
|
|
46
|
+
* drift, encountered from the other command): the user already left
|
|
47
|
+
* boost manually, so forcing a switch back to the remembered prior tier
|
|
48
|
+
* would clobber a choice they just made on purpose. Instead this clears
|
|
49
|
+
* `/boost`'s own bookkeeping (state, env, chip) without touching the
|
|
50
|
+
* model, and names where they actually landed.
|
|
51
|
+
*
|
|
52
|
+
* KNOWN asymmetry, documented rather than worked around: `YAGNI_BOOST=1`
|
|
53
|
+
* while boosted makes every CHILD process spawned during the boost (a /go
|
|
54
|
+
* run, a subagent, an advisor consult) inherit it, and those children's
|
|
55
|
+
* completions carry `x-yagni-boost` because their provider is registered
|
|
56
|
+
* fresh per child. The DRIVER's own completions do not gain that header
|
|
57
|
+
* retroactively — `buildYagniProvider`'s headers are baked into the
|
|
58
|
+
* `registerProvider` call once, at session start, from the env snapshot at
|
|
59
|
+
* that moment (see `provider.ts` / `attributionHeaders`). Re-registering the
|
|
60
|
+
* provider mid-session to pick up a new header is explicitly NOT the fix
|
|
61
|
+
* here: it would race the in-flight request the switch itself triggers and
|
|
62
|
+
* has no test coverage as a live-swap path.
|
|
63
|
+
*
|
|
64
|
+
* This is exactly why `/cost` cannot rely on server rows alone: the server's
|
|
65
|
+
* per-tier "Boosted (tier) spend" subtotal (`formatServerCostLines`, Task 7)
|
|
66
|
+
* only ever sees rows carrying `x-yagni-boost` — i.e. `/go` children, never
|
|
67
|
+
* the driver's own turns. A session that only chats while boosted would
|
|
68
|
+
* otherwise show no boost line at all. `registerCostCommand`'s `isBoosted`
|
|
69
|
+
* dep (threaded from THIS module's return handle, the same way
|
|
70
|
+
* `advisorSubtotal` is threaded from `advisor.ts`) closes that gap: `/cost`
|
|
71
|
+
* appends a fixed "Boost is on. Driver turns bill at the peak tier." line
|
|
72
|
+
* whenever the live toggle is on, on BOTH the server-authoritative and the
|
|
73
|
+
* local-fallback branch, independent of what server rows happen to show.
|
|
74
|
+
*/
|
|
75
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
76
|
+
/** Session-scoped boost state, held in the registration closure. */
|
|
77
|
+
export interface BoostState {
|
|
78
|
+
active: boolean;
|
|
79
|
+
/** The tier to restore on `/boost off`. Set only while `active`. */
|
|
80
|
+
priorModelId?: string;
|
|
81
|
+
}
|
|
82
|
+
/** The pure outcome of a `/boost` transition. */
|
|
83
|
+
export interface BoostResult {
|
|
84
|
+
state: BoostState;
|
|
85
|
+
/** Exact notice text to surface to the user. */
|
|
86
|
+
notice: string;
|
|
87
|
+
/** The tier id to switch the session to, or undefined for a pure no-op. */
|
|
88
|
+
targetModelId?: string;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Turn boost on. PURE.
|
|
92
|
+
*
|
|
93
|
+
* - Already active AND the live model is still `peak`: genuine no-op,
|
|
94
|
+
* "Already boosted." (no switch — nothing about the prior tier changes).
|
|
95
|
+
* - Already active but the live model has drifted off `peak` (picker-drift
|
|
96
|
+
* guard 3b, see module docblock): re-applies `peak`, keeping the ORIGINAL
|
|
97
|
+
* `priorModelId` rather than adopting the drifted-to tier.
|
|
98
|
+
* - Not active, current model IS already `peak`: activates anyway (so
|
|
99
|
+
* attribution and `/boost off` behave correctly) with a distinct notice,
|
|
100
|
+
* remembering `peak` itself as the tier to restore.
|
|
101
|
+
* - Not active, otherwise: remembers the current tier, targets `peak`.
|
|
102
|
+
*/
|
|
103
|
+
export declare function boostOn(state: BoostState, currentModelId: string | undefined): BoostResult;
|
|
104
|
+
/**
|
|
105
|
+
* Turn boost off. PURE.
|
|
106
|
+
*
|
|
107
|
+
* - Not active: no-op, "Boost is not on."
|
|
108
|
+
* - Active but the live model is no longer `peak` (picker-drift guard 3a,
|
|
109
|
+
* see module docblock): the user already left boost manually. Clears
|
|
110
|
+
* `/boost`'s own bookkeeping WITHOUT a switch — forcing one back to the
|
|
111
|
+
* remembered prior tier would clobber a choice just made on purpose — and
|
|
112
|
+
* names the model they actually landed on.
|
|
113
|
+
* - Active and still on `peak`: restores the remembered prior tier (falling
|
|
114
|
+
* back to the session default when none was recorded, which should not
|
|
115
|
+
* normally happen). When the prior tier is itself `peak` (the
|
|
116
|
+
* already-on-Peak activation path), the resulting switch is a no-op in
|
|
117
|
+
* effect — still applied, harmlessly.
|
|
118
|
+
*/
|
|
119
|
+
export declare function boostOff(state: BoostState, currentModelId: string | undefined): BoostResult;
|
|
120
|
+
/** Injectable seams for `registerBoostCommand`. */
|
|
121
|
+
export interface RegisterBoostCommandDeps {
|
|
122
|
+
/** Environment `YAGNI_BOOST` is toggled on. Defaults to `process.env`. */
|
|
123
|
+
env?: NodeJS.ProcessEnv;
|
|
124
|
+
}
|
|
125
|
+
/** What `registerBoostCommand` hands back so other registrations (`/cost`) can read the live flag. */
|
|
126
|
+
export interface BoostCommandHandle {
|
|
127
|
+
/** Whether the session is currently boosted, read fresh off the same closure `/boost` mutates. */
|
|
128
|
+
isBoosted(): boolean;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Register the `/boost` command.
|
|
132
|
+
*
|
|
133
|
+
* `/boost` (no args) turns boost on; `/boost off` turns it off; any other
|
|
134
|
+
* argument shows a usage notice and changes nothing. Every transition that
|
|
135
|
+
* requires a model switch runs it through pi's real API and is guarded end to
|
|
136
|
+
* end: state and `YAGNI_BOOST` are only ever committed together, AFTER the
|
|
137
|
+
* switch succeeds (or is deliberately skipped — the picker-drift guards), so
|
|
138
|
+
* a half-applied boost (env set but model unchanged, or vice versa) can never
|
|
139
|
+
* happen. A failed ON reports "Boost failed."; a failed OFF reports a
|
|
140
|
+
* DISTINCT message, because the failure mode is materially worse — the
|
|
141
|
+
* driver is left still running (and billing) on Peak, not merely unchanged.
|
|
142
|
+
*/
|
|
143
|
+
export declare function registerBoostCommand(pi: ExtensionAPI, deps?: RegisterBoostCommandDeps): BoostCommandHandle;
|
|
144
|
+
//# sourceMappingURL=boostCommand.d.ts.map
|