@yagni-app/code 0.2.1 → 0.3.0

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 (77) hide show
  1. package/dist/cli.d.ts +30 -0
  2. package/dist/cli.js +135 -3
  3. package/dist/doctor.d.ts +1 -1
  4. package/dist/doctor.js +1 -1
  5. package/dist/extension/advisor.d.ts +4 -4
  6. package/dist/extension/advisor.js +6 -7
  7. package/dist/extension/approvedPrefixes.d.ts +92 -0
  8. package/dist/extension/approvedPrefixes.js +252 -0
  9. package/dist/extension/askAdvisorTool.d.ts +2 -2
  10. package/dist/extension/askAdvisorTool.js +5 -5
  11. package/dist/extension/askYagniTool.js +49 -0
  12. package/dist/extension/branding.d.ts +24 -3
  13. package/dist/extension/branding.js +71 -10
  14. package/dist/extension/chipEditor.d.ts +30 -9
  15. package/dist/extension/chipEditor.js +173 -59
  16. package/dist/extension/claudeRules.d.ts +0 -2
  17. package/dist/extension/claudeRules.js +0 -8
  18. package/dist/extension/cmux/dispatcher.d.ts +25 -0
  19. package/dist/extension/cmux/dispatcher.js +266 -0
  20. package/dist/extension/cmux/hooks.d.ts +12 -0
  21. package/dist/extension/cmux/hooks.js +192 -0
  22. package/dist/extension/cmux/index.d.ts +3 -0
  23. package/dist/extension/cmux/index.js +155 -0
  24. package/dist/extension/cmux/naming.d.ts +5 -0
  25. package/dist/extension/cmux/naming.js +23 -0
  26. package/dist/extension/cmux/state.d.ts +33 -0
  27. package/dist/extension/cmux/state.js +142 -0
  28. package/dist/extension/config.d.ts +32 -1
  29. package/dist/extension/config.js +36 -4
  30. package/dist/extension/costHud.d.ts +16 -22
  31. package/dist/extension/costHud.js +8 -47
  32. package/dist/extension/crashReport.js +1 -3
  33. package/dist/extension/execPolicy.d.ts +119 -0
  34. package/dist/extension/execPolicy.js +805 -0
  35. package/dist/extension/footer.d.ts +111 -0
  36. package/dist/extension/footer.js +294 -0
  37. package/dist/extension/guardian.d.ts +129 -0
  38. package/dist/extension/guardian.js +213 -0
  39. package/dist/extension/index.d.ts +15 -4
  40. package/dist/extension/index.js +250 -24
  41. package/dist/extension/permission.d.ts +123 -10
  42. package/dist/extension/permission.js +586 -40
  43. package/dist/extension/pipeline/childRegistry.d.ts +41 -0
  44. package/dist/extension/pipeline/childRegistry.js +118 -0
  45. package/dist/extension/pipeline/finish.js +5 -1
  46. package/dist/extension/pipeline/goCommand.d.ts +1 -1
  47. package/dist/extension/pipeline/goCommand.js +35 -6
  48. package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
  49. package/dist/extension/pipeline/goStatusCommands.js +61 -1
  50. package/dist/extension/pipeline/personas.js +25 -0
  51. package/dist/extension/pipeline/runRegistry.d.ts +14 -0
  52. package/dist/extension/pipeline/runRegistry.js +35 -0
  53. package/dist/extension/pipeline/runner.js +4 -0
  54. package/dist/extension/pipeline/verify.d.ts +4 -0
  55. package/dist/extension/pipeline/verify.js +48 -26
  56. package/dist/extension/redact.d.ts +20 -0
  57. package/dist/extension/redact.js +64 -0
  58. package/dist/extension/rerouteNotice.d.ts +3 -4
  59. package/dist/extension/rerouteNotice.js +20 -11
  60. package/dist/extension/subagentRender.d.ts +129 -0
  61. package/dist/extension/subagentRender.js +441 -0
  62. package/dist/extension/subagents.d.ts +4 -7
  63. package/dist/extension/subagents.js +103 -33
  64. package/dist/extension/ticketTools.d.ts +37 -0
  65. package/dist/extension/ticketTools.js +117 -0
  66. package/dist/extension/tokenProvider.js +46 -5
  67. package/dist/launch.d.ts +7 -0
  68. package/dist/launch.js +24 -12
  69. package/dist/padding.d.ts +22 -0
  70. package/dist/padding.js +25 -0
  71. package/dist/promptEnrichment.d.ts +40 -0
  72. package/dist/promptEnrichment.js +85 -0
  73. package/dist/signalForward.d.ts +60 -0
  74. package/dist/signalForward.js +130 -0
  75. package/package.json +5 -5
  76. package/dist/extension/boostCommand.d.ts +0 -144
  77. package/dist/extension/boostCommand.js +0 -263
@@ -17,7 +17,7 @@
17
17
  * literal text is the source of truth, so undo/cursor/history and the
18
18
  * chip→model mapping all share one string and can't desync.
19
19
  *
20
- * Validated against pi 0.83.0 (spike, 5/5): the subclass owns `onPasteImage`
20
+ * Validated against pi 0.84.1 (spike, 5/5): the subclass owns `onPasteImage`
21
21
  * because `setCustomEditorComponent` only fills it when unset, and jiti aliases
22
22
  * the extension's pi imports to the same module instance pi uses.
23
23
  */
@@ -29,6 +29,7 @@ import { tmpdir } from "node:os";
29
29
  import { join, basename, isAbsolute } from "node:path";
30
30
  import { randomUUID } from "node:crypto";
31
31
  import { logImagePaste } from "./diagnostics.js";
32
+ import { cancelActiveRuns } from "./pipeline/runRegistry.js";
32
33
  /** Matches the `[Image #N]` chip token in the editor text. */
33
34
  const CHIP_RE = /\[Image #(\d+)\]/g;
34
35
  /** Inverse-video wrap so the chip token reads as a chip, not plain text. */
@@ -64,58 +65,98 @@ const IMAGE_MAGIC = [
64
65
  [[0x47, 0x49, 0x46, 0x38], "image/gif"], // GIF8
65
66
  [[0x52, 0x49, 0x46, 0x46], "image/webp"], // RIFF (webp container)
66
67
  ];
68
+ /** A text paste this long is prose, not a dragged file list — bail early. */
69
+ const MAX_PASTED_PATHS = 20;
67
70
  /**
68
- * Recognize a pasted temp-image path written by a Ghostty-based terminal
69
- * (cmux, Ghostty). Those terminals name the file `clipboard-<ts>-<id>.<ext>`
70
- * in the system temp dir. We match strictly — clipboard- prefix, image
71
- * extension, real file, and genuine image magic bytes so a path the user
72
- * typed by hand (e.g. /Users/me/photo.png) is never silently converted.
73
- * Returns the decoded image, or null when the pasted text is not one such path.
71
+ * Split a pasted payload into path tokens the way terminals produce them:
72
+ * Finder/desktop drag-drop inserts absolute paths with backslash-escaped
73
+ * spaces (POSIX) or quote-wrapped paths (Windows Terminal), multiple files
74
+ * separated by whitespace. Returns [] when the payload can't be a path list
75
+ * (e.g. an unbalanced quote from ordinary prose like "don't").
74
76
  */
75
- export function readPastedImagePath(pastedText) {
76
- const trimmed = pastedText.trim();
77
- if (trimmed.includes("\n") || trimmed.includes(" "))
78
- return null; // one token only
79
- // Validate the RAW input first: an attacker could smuggle the `clipboard-`
80
- // prefix through backslash escapes (e.g. `\c\l\i\p\b\o\a\r\d-`), so we
81
- // must confirm the un-escaped form is a well-formed tmp image path before any
82
- // unescaping or path resolution. cmux tmp paths contain no escapable chars
83
- // (no spaces/metacharacters), so a legitimate paste has NO backslashes at all.
84
- // On Windows the backslash IS the path separator (and cmd/PowerShell do no
85
- // backslash-escaping), so the escape-smuggling rejection applies only where
86
- // a backslash could be an escape: POSIX shells.
77
+ function splitPathTokens(text) {
87
78
  const isWindows = process.platform === "win32";
88
- if (!isWindows && trimmed.includes("\\"))
89
- return null; // escapes => not a plain cmux tmp path
90
- const path = trimmed;
91
- if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
92
- return null;
93
- const name = basename(path);
94
- if (!name.startsWith("clipboard-"))
95
- return null;
96
- const ext = name.split(".").pop()?.toLowerCase() ?? "";
97
- if (!(ext in IMAGE_EXT_MIME))
98
- return null;
99
- if (!existsSync(path))
100
- return null;
101
- let bytes;
102
- try {
103
- bytes = readFileSync(path);
104
- }
105
- catch {
106
- return null;
79
+ const tokens = [];
80
+ let cur = "";
81
+ let quote = null;
82
+ for (let i = 0; i < text.length; i++) {
83
+ const ch = text[i];
84
+ if (quote) {
85
+ if (ch === quote)
86
+ quote = null;
87
+ else
88
+ cur += ch;
89
+ continue;
90
+ }
91
+ if (ch === '"' || ch === "'") {
92
+ quote = ch;
93
+ continue;
94
+ }
95
+ // POSIX shells escape spaces/metacharacters with a backslash; on Windows
96
+ // the backslash IS the path separator, so no unescaping there.
97
+ if (!isWindows && ch === "\\" && i + 1 < text.length) {
98
+ cur += text[++i];
99
+ continue;
100
+ }
101
+ if (ch === " " || ch === "\t" || ch === "\n" || ch === "\r") {
102
+ if (cur)
103
+ tokens.push(cur);
104
+ cur = "";
105
+ continue;
106
+ }
107
+ cur += ch;
107
108
  }
108
- if (bytes.length === 0)
109
+ if (quote !== null)
110
+ return [];
111
+ if (cur)
112
+ tokens.push(cur);
113
+ return tokens;
114
+ }
115
+ /**
116
+ * Recognize a pasted payload that is entirely image-file paths and decode
117
+ * them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
118
+ * (which writes the clipboard image to a `clipboard-*.png` temp file and
119
+ * pastes its path) — Claude Code parity: dragging a screenshot in becomes
120
+ * `[Image #N]`, not a literal path.
121
+ *
122
+ * Guardrails against converting something the user meant as text: the WHOLE
123
+ * paste must be path tokens, every token must be an absolute path to a real
124
+ * file with an image extension AND genuine image magic bytes, and the
125
+ * conversion is visible — each image becomes a chip in the prompt, so nothing
126
+ * is ever attached silently. Returns null when the paste is not such a list
127
+ * (caller passes it through as ordinary text).
128
+ */
129
+ export function readPastedImagePaths(pastedText) {
130
+ const tokens = splitPathTokens(pastedText.trim());
131
+ if (tokens.length === 0 || tokens.length > MAX_PASTED_PATHS)
109
132
  return null;
110
- for (const [magic, mime] of IMAGE_MAGIC) {
111
- if (magic.every((b, i) => bytes[i] === b))
112
- return { bytes, mimeType: mime };
133
+ const isWindows = process.platform === "win32";
134
+ const images = [];
135
+ for (const path of tokens) {
136
+ if (isWindows ? !isAbsolute(path) : !path.startsWith("/"))
137
+ return null;
138
+ const ext = basename(path).split(".").pop()?.toLowerCase() ?? "";
139
+ if (!(ext in IMAGE_EXT_MIME))
140
+ return null;
141
+ if (!existsSync(path))
142
+ return null;
143
+ let bytes;
144
+ try {
145
+ bytes = readFileSync(path);
146
+ }
147
+ catch {
148
+ return null;
149
+ }
150
+ const magic = IMAGE_MAGIC.find(([m]) => m.every((b, i) => bytes[i] === b));
151
+ if (!magic)
152
+ return null;
153
+ images.push({ bytes, mimeType: magic[1] });
113
154
  }
114
- return null;
155
+ return images;
115
156
  }
116
157
  /**
117
158
  * Read an image from the system clipboard without forking pi. Best-effort and
118
- * cross-platform: native module if present, else osascript/pngpaste (macOS),
159
+ * cross-platform using ONLY tools the OS ships with: osascript (macOS),
119
160
  * wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
120
161
  * the clipboard holds no image (caller then pastes text instead).
121
162
  */
@@ -145,7 +186,45 @@ function runBase64(command, args, timeoutMs = 3000) {
145
186
  return out.length > 0 ? out : null;
146
187
  }
147
188
  function readMacClipboardImage() {
148
- // pngpaste writes the clipboard image to a PNG file (cleanest on macOS).
189
+ return readMacClipboardViaOsascript() ?? readMacClipboardViaPngpaste();
190
+ }
191
+ /**
192
+ * Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
193
+ * The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
194
+ * is the image. Returns null when the output holds no such dump.
195
+ */
196
+ export function parseOsascriptImageHex(out) {
197
+ const m = /«data \w{4}((?:[0-9A-Fa-f]{2})+)»/.exec(out);
198
+ if (!m)
199
+ return null;
200
+ const bytes = Buffer.from(m[1], "hex");
201
+ return bytes.length > 0 ? bytes : null;
202
+ }
203
+ function readMacClipboardViaOsascript() {
204
+ // AppleScript ships with macOS, so this route needs no install (it is how
205
+ // Claude Code reads clipboard images too). Screenshots and browser
206
+ // "Copy image" put a PNG flavor on the pasteboard; try JPEG second.
207
+ for (const [cls, mime] of [
208
+ ["PNGf", "image/png"],
209
+ ["JPEG", "image/jpeg"],
210
+ ]) {
211
+ const res = spawnSync("osascript", ["-e", `the clipboard as «class ${cls}»`], {
212
+ timeout: 5000,
213
+ maxBuffer: 256 * 1024 * 1024, // the hex dump doubles the image size
214
+ });
215
+ if (res.error || res.status !== 0)
216
+ continue;
217
+ const out = Buffer.isBuffer(res.stdout) ? res.stdout.toString("utf8") : String(res.stdout ?? "");
218
+ const bytes = parseOsascriptImageHex(out);
219
+ if (bytes)
220
+ return { bytes, mimeType: mime };
221
+ }
222
+ return null;
223
+ }
224
+ function readMacClipboardViaPngpaste() {
225
+ // Optional fallback for pasteboard flavors osascript can't coerce to
226
+ // PNG/JPEG (e.g. TIFF-only sources) — pngpaste converts anything to PNG.
227
+ // Nice when installed, never required.
149
228
  const dest = join(tmpdir(), `yagni-clip-${randomUUID()}.png`);
150
229
  try {
151
230
  const res = spawnSync("pngpaste", [dest], { timeout: 3000 });
@@ -207,6 +286,15 @@ export class ChipEditor extends CustomEditor {
207
286
  stashed = [];
208
287
  chipCounter = 0;
209
288
  readImage;
289
+ /**
290
+ * Fired when Ctrl+C lands on an EMPTY prompt. pi's own binding is
291
+ * clear-editor (Esc is the interrupt key), but with nothing typed that is a
292
+ * no-op, and every peer harness (Claude Code, Codex, Gemini, Aider) treats a
293
+ * single Ctrl+C as "stop what's happening". The hook lets the extension wire
294
+ * exactly that; the key still falls through to pi afterwards, so clear and
295
+ * the 500ms double-press exit window are untouched.
296
+ */
297
+ onEmptyCtrlC;
210
298
  constructor(tui, theme, keybindings, readImage = defaultClipboardImageReader, options) {
211
299
  super(tui, theme, keybindings, options);
212
300
  this.readImage = readImage;
@@ -222,27 +310,31 @@ export class ChipEditor extends CustomEditor {
222
310
  * Kitty-protocol terminals that genuinely deliver the Cmd modifier.
223
311
  */
224
312
  handleInput(data) {
225
- // Cmd+V on Ghostty-based terminals (cmux, Ghostty): the terminal writes the
226
- // clipboard image to a temp file and pastes its PATH as bracketed text, so
227
- // the payload arrives here as "\x1b[200~<path>\x1b[201~" (pi's Terminal
228
- // re-wraps it). handlePaste is private and consumed inside super.handleInput,
229
- // so we strip the markers here and convert a bare image path to a chip.
313
+ // Pasted image PATHS arrive as bracketed text ("\x1b[200~<paths>\x1b[201~",
314
+ // pi's Terminal re-wraps every paste): Finder/desktop drag-drop inserts the
315
+ // file's path, and Ghostty-based terminals (cmux, Ghostty) write a Cmd+V'd
316
+ // clipboard image to a temp file and paste ITS path. handlePaste is private
317
+ // and consumed inside super.handleInput, so we strip the markers here and
318
+ // convert an all-image-paths payload into chips.
230
319
  const unwrapped = unwrapBracketedPaste(data);
231
320
  if (unwrapped !== null) {
232
- const image = readPastedImagePath(unwrapped);
321
+ const images = readPastedImagePaths(unwrapped);
233
322
  logImagePaste({
234
323
  event: "paste_path",
235
- outcome: image ? "ok" : "passthrough",
236
- mimeType: image?.mimeType,
237
- bytes: image?.bytes.length,
238
- file: image ? unwrapped : undefined,
239
- detail: image ? undefined : `not a lone image path: ${unwrapped.slice(0, 60)}`,
324
+ outcome: images ? "ok" : "passthrough",
325
+ imageCount: images?.length,
326
+ bytes: images?.reduce((sum, i) => sum + i.bytes.length, 0),
327
+ detail: images ? undefined : `not an image path list: ${unwrapped.slice(0, 60)}`,
240
328
  });
241
- if (image) {
242
- this.dropChip(image.bytes, image.mimeType);
329
+ if (images) {
330
+ images.forEach((image, i) => {
331
+ if (i > 0)
332
+ this.insertTextAtCursor(" ");
333
+ this.dropChip(image.bytes, image.mimeType);
334
+ });
243
335
  return;
244
336
  }
245
- // Not a lone image path — re-wrap and let pi handle the paste normally.
337
+ // Not a list of image paths — re-wrap and let pi handle the paste normally.
246
338
  super.handleInput(data);
247
339
  return;
248
340
  }
@@ -251,6 +343,14 @@ export class ChipEditor extends CustomEditor {
251
343
  this.onPasteImage?.();
252
344
  return;
253
345
  }
346
+ // Ctrl+C on an empty prompt: fire the stop hook, then STILL hand the key to
347
+ // pi so its clear + double-press-exit tracking behave exactly as before.
348
+ if (matchesKey(data, "ctrl+c")) {
349
+ if (this.getText().length === 0)
350
+ this.onEmptyCtrlC?.();
351
+ super.handleInput(data);
352
+ return;
353
+ }
254
354
  super.handleInput(data);
255
355
  }
256
356
  async pasteChip() {
@@ -346,6 +446,20 @@ export function registerChipEditor(pi) {
346
446
  return;
347
447
  ctx.ui.setEditorComponent((tui, theme, keybindings) => {
348
448
  const editor = new ChipEditor(tui, theme, keybindings);
449
+ // Single Ctrl+C on an empty prompt = "stop what's happening" (peer-harness
450
+ // parity; pi's clear binding is a no-op with nothing typed): abort a
451
+ // streaming turn and cancel every in-flight /go run. pi still sees the
452
+ // key afterwards, so Ctrl+C twice within 500ms exits as always.
453
+ editor.onEmptyCtrlC = () => {
454
+ if (!ctx.isIdle())
455
+ ctx.abort();
456
+ const cancelled = cancelActiveRuns();
457
+ if (cancelled.length > 0) {
458
+ ctx.ui.notify(`Stopping ${cancelled.length} /go run${cancelled.length === 1 ? "" : "s"}: ${cancelled
459
+ .map((r) => `[${r.runId.slice(0, 8)}] ${r.ticket}`)
460
+ .join(", ")}. Work so far lands as WIP commits (see /go-status).`, "warning");
461
+ }
462
+ };
349
463
  liveChipEditors.add(new WeakRef(editor));
350
464
  return editor;
351
465
  });
@@ -49,6 +49,4 @@ export interface BuildSectionOptions {
49
49
  export declare function buildClaudeRulesSection(rules: ClaudeRule[], opts?: BuildSectionOptions): string | null;
50
50
  /** Env-driven entry: dirs from YAGNI_CLAUDE_RULES_DIRS, null when off/empty. */
51
51
  export declare function claudeRulesSection(env: NodeJS.ProcessEnv, opts?: CollectRulesOptions & BuildSectionOptions): string | null;
52
- /** Append `section` to a system prompt exactly once; no-op without a section. */
53
- export declare function appendClaudeRules(systemPrompt: string, section: string | null): string;
54
52
  //# sourceMappingURL=claudeRules.d.ts.map
@@ -169,12 +169,4 @@ export function claudeRulesSection(env, opts = {}) {
169
169
  return null;
170
170
  }
171
171
  }
172
- /** Append `section` to a system prompt exactly once; no-op without a section. */
173
- export function appendClaudeRules(systemPrompt, section) {
174
- if (!section)
175
- return systemPrompt;
176
- if (systemPrompt.includes(CLAUDE_RULES_HEADER))
177
- return systemPrompt;
178
- return `${systemPrompt}\n\n${section}`;
179
- }
180
172
  //# sourceMappingURL=claudeRules.js.map
@@ -0,0 +1,25 @@
1
+ import type { PiExtensionContextSnapshot } from "./state.js";
2
+ export interface CommandResult {
3
+ ok: boolean;
4
+ status: number | null;
5
+ stdout: string;
6
+ stderr: string;
7
+ error?: unknown;
8
+ surfaceUnavailable?: boolean;
9
+ }
10
+ export declare function normalizedLaunchArgv(): string[];
11
+ export declare function shouldPreserveEnvKey(key: string): boolean;
12
+ export declare function hookEnvironment(cwd: string, includeSocketPassword?: boolean): NodeJS.ProcessEnv;
13
+ export declare class CmuxDispatcher {
14
+ private static readonly surfaceUnavailableExitCode;
15
+ private controlQueues;
16
+ private unavailableSessions;
17
+ canDispatch(sessionId: string | null): boolean;
18
+ releaseSession(sessionId: string): void;
19
+ run(args: string[], cwd: string, input: string | undefined, context: PiExtensionContextSnapshot): Promise<CommandResult>;
20
+ private execute;
21
+ private spawnCmux;
22
+ private isSurfaceResolutionFailure;
23
+ private surfaceUnavailableResult;
24
+ }
25
+ //# sourceMappingURL=dispatcher.d.ts.map
@@ -0,0 +1,266 @@
1
+ import { spawn } from "node:child_process";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ function resolveExecutable(name) {
5
+ const pathEnv = process.env.PATH || "";
6
+ for (const dir of pathEnv.split(path.delimiter)) {
7
+ if (!dir)
8
+ continue;
9
+ const candidate = path.join(dir, name);
10
+ try {
11
+ fs.accessSync(candidate, fs.constants.X_OK);
12
+ if (fs.statSync(candidate).isFile())
13
+ return candidate;
14
+ }
15
+ catch { }
16
+ }
17
+ return name;
18
+ }
19
+ function looksLikePiExecutable(value) {
20
+ const base = path.basename(value).toLowerCase();
21
+ return base === "pi" || base === "pi-coding-agent";
22
+ }
23
+ function looksLikePiScript(value) {
24
+ const normalized = value.replaceAll("\\", "/").toLowerCase();
25
+ const base = path.basename(normalized);
26
+ return (normalized.includes("/@earendil-works/pi-coding-agent/") ||
27
+ normalized.includes("/@mariozechner/pi-coding-agent/") ||
28
+ normalized.includes("/packages/coding-agent/") ||
29
+ ((base === "cli.js" || base === "cli.ts") &&
30
+ (normalized.includes("pi-coding-agent") || normalized.includes("coding-agent"))));
31
+ }
32
+ export function normalizedLaunchArgv() {
33
+ const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : [];
34
+ if (raw.length === 0)
35
+ return [resolveExecutable("pi")];
36
+ if (looksLikePiExecutable(raw[0]))
37
+ return raw;
38
+ if (raw.length > 1 && looksLikePiScript(raw[1])) {
39
+ return [resolveExecutable("pi"), ...raw.slice(2)];
40
+ }
41
+ return [resolveExecutable("pi"), ...raw.slice(1)];
42
+ }
43
+ function base64NulSeparated(values) {
44
+ const bytes = [];
45
+ for (const value of values) {
46
+ bytes.push(Buffer.from(String(value), "utf8"));
47
+ bytes.push(Buffer.from([0]));
48
+ }
49
+ return Buffer.concat(bytes).toString("base64");
50
+ }
51
+ function secretLikeEnvKey(key) {
52
+ return /(TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|CREDENTIAL|AUTHORIZATION|COOKIE)/i.test(key);
53
+ }
54
+ function safePiEnvKey(key) {
55
+ return (key === "PI_CODING_AGENT_DIR" ||
56
+ key === "PI_CONFIG_DIR" ||
57
+ key === "PI_CODING_AGENT_SESSION_DIR" ||
58
+ (key.startsWith("PI_CODING_AGENT_") && !secretLikeEnvKey(key)));
59
+ }
60
+ function safeNodeEnvKey(key) {
61
+ return (key === "NODE_ENV" ||
62
+ key === "NODE_OPTIONS" ||
63
+ key === "NODE_PATH" ||
64
+ key === "NODE_NO_WARNINGS" ||
65
+ key === "NODE_EXTRA_CA_CERTS");
66
+ }
67
+ function safeCmuxEnvKey(key) {
68
+ if (key.startsWith("CMUX_TEST_PI_"))
69
+ return !secretLikeEnvKey(key);
70
+ if (key.startsWith("CMUX_AGENT_LAUNCH_"))
71
+ return !secretLikeEnvKey(key);
72
+ if (key === "CMUX_AGENT_HOOK_STATE_DIR")
73
+ return true;
74
+ if (key === "CMUX_PI_CMUX_BIN" || key === "CMUX_PI_HOOKS_DISABLED")
75
+ return true;
76
+ if (key === "CMUX_SURFACE_ID" || key === "CMUX_WORKSPACE_ID" || key === "CMUX_WINDOW_ID")
77
+ return true;
78
+ if (key === "CMUX_PANE_ID" || key === "CMUX_TAB_ID" || key === "CMUX_PANEL_ID")
79
+ return true;
80
+ if (key === "CMUX_SOCKET" || key === "CMUX_SOCKET_PATH")
81
+ return true;
82
+ if (key === "CMUX_BUNDLE_ID" || key === "CMUX_BUNDLED_CLI_PATH")
83
+ return true;
84
+ if (key === "CMUX_CLI_SENTRY_DISABLED" || key === "CMUX_DEBUG_LOG")
85
+ return true;
86
+ return false;
87
+ }
88
+ export function shouldPreserveEnvKey(key) {
89
+ if (safeCmuxEnvKey(key))
90
+ return true;
91
+ if (safePiEnvKey(key))
92
+ return true;
93
+ if (safeNodeEnvKey(key))
94
+ return true;
95
+ if (key === "PATH" || key === "HOME" || key === "PWD" || key === "SHELL")
96
+ return true;
97
+ if (key === "USER" || key === "LOGNAME" || key === "TMPDIR" || key === "TZ")
98
+ return true;
99
+ if (key === "LANG" || key.startsWith("LC_"))
100
+ return true;
101
+ if (key === "TERM" || key === "TERM_PROGRAM" || key === "TERM_PROGRAM_VERSION" || key === "COLORTERM")
102
+ return true;
103
+ if (key === "SSH_AUTH_SOCK")
104
+ return true;
105
+ if (key.startsWith("PI_") || key.startsWith("NODE_"))
106
+ return !secretLikeEnvKey(key);
107
+ return false;
108
+ }
109
+ export function hookEnvironment(cwd, includeSocketPassword = false) {
110
+ const env = {};
111
+ for (const [key, value] of Object.entries(process.env)) {
112
+ if (value === undefined)
113
+ continue;
114
+ if (shouldPreserveEnvKey(key))
115
+ env[key] = value;
116
+ }
117
+ if (includeSocketPassword) {
118
+ const socketPassword = process.env.CMUX_SOCKET_PASSWORD;
119
+ if (socketPassword)
120
+ env.CMUX_SOCKET_PASSWORD = socketPassword;
121
+ }
122
+ if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) {
123
+ const argv = normalizedLaunchArgv();
124
+ env.CMUX_AGENT_LAUNCH_KIND = "pi";
125
+ env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("pi");
126
+ env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv);
127
+ env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd();
128
+ }
129
+ return env;
130
+ }
131
+ function cmuxExecutable() {
132
+ return process.env.CMUX_PI_CMUX_BIN || "cmux";
133
+ }
134
+ export class CmuxDispatcher {
135
+ static surfaceUnavailableExitCode = 69;
136
+ controlQueues = new Map();
137
+ unavailableSessions = new Set();
138
+ canDispatch(sessionId) {
139
+ return !sessionId || !this.unavailableSessions.has(sessionId);
140
+ }
141
+ releaseSession(sessionId) {
142
+ this.unavailableSessions.delete(sessionId);
143
+ }
144
+ run(args, cwd, input, context) {
145
+ const sessionId = context.sessionId;
146
+ const previous = this.controlQueues.get(sessionId) || Promise.resolve();
147
+ const scheduled = previous.then(() => this.execute(args, cwd, input, context));
148
+ let tail;
149
+ tail = scheduled
150
+ .then(() => undefined, () => undefined)
151
+ .finally(() => {
152
+ if (this.controlQueues.get(sessionId) === tail)
153
+ this.controlQueues.delete(sessionId);
154
+ });
155
+ this.controlQueues.set(sessionId, tail);
156
+ return scheduled;
157
+ }
158
+ async execute(args, cwd, input, context) {
159
+ const sessionId = context.sessionId;
160
+ if (!this.canDispatch(sessionId)) {
161
+ return this.surfaceUnavailableResult();
162
+ }
163
+ const result = await this.spawnCmux(args, cwd, input);
164
+ if (this.isSurfaceResolutionFailure(result)) {
165
+ if (sessionId)
166
+ this.unavailableSessions.add(sessionId);
167
+ return { ...result, surfaceUnavailable: true };
168
+ }
169
+ return result;
170
+ }
171
+ spawnCmux(args, cwd, input) {
172
+ return new Promise((resolve) => {
173
+ let settled = false;
174
+ let stdout = "";
175
+ let stderr = "";
176
+ let inputError;
177
+ let timeout = null;
178
+ let terminateGrace = null;
179
+ let forceSettleTimeout = null;
180
+ let terminationError;
181
+ const appendOutput = (current, chunk) => {
182
+ const limit = 1024 * 1024;
183
+ if (current.length >= limit)
184
+ return current;
185
+ return current + String(chunk).slice(0, limit - current.length);
186
+ };
187
+ const settle = (result) => {
188
+ if (settled)
189
+ return;
190
+ settled = true;
191
+ if (timeout)
192
+ clearTimeout(timeout);
193
+ if (terminateGrace)
194
+ clearTimeout(terminateGrace);
195
+ if (forceSettleTimeout)
196
+ clearTimeout(forceSettleTimeout);
197
+ resolve(result);
198
+ };
199
+ const terminatedResult = () => ({
200
+ ok: false,
201
+ status: null,
202
+ stdout,
203
+ stderr,
204
+ error: terminationError,
205
+ });
206
+ try {
207
+ const child = spawn(cmuxExecutable(), args, {
208
+ env: hookEnvironment(cwd, true),
209
+ stdio: ["pipe", "pipe", "pipe"],
210
+ });
211
+ child.stdout.setEncoding("utf8");
212
+ child.stderr.setEncoding("utf8");
213
+ child.stdout.on("data", (chunk) => { stdout = appendOutput(stdout, chunk); });
214
+ child.stderr.on("data", (chunk) => { stderr = appendOutput(stderr, chunk); });
215
+ child.stdin.on("error", (error) => { inputError = error; });
216
+ const beginTermination = (error) => {
217
+ if (terminationError)
218
+ return;
219
+ terminationError = error;
220
+ child.stdin.destroy();
221
+ try {
222
+ child.kill("SIGTERM");
223
+ }
224
+ catch { }
225
+ terminateGrace = setTimeout(() => {
226
+ try {
227
+ child.kill("SIGKILL");
228
+ }
229
+ catch { }
230
+ forceSettleTimeout = setTimeout(() => {
231
+ child.stdout.destroy();
232
+ child.stderr.destroy();
233
+ child.unref();
234
+ settle(terminatedResult());
235
+ }, 250);
236
+ }, 250);
237
+ };
238
+ child.on("error", (error) => {
239
+ settle(terminationError ? terminatedResult() : { ok: false, status: null, stdout, stderr, error });
240
+ });
241
+ child.on("close", (code) => {
242
+ if (terminationError) {
243
+ settle(terminatedResult());
244
+ return;
245
+ }
246
+ const status = typeof code === "number" ? code : null;
247
+ settle({ ok: status === 0 && inputError === undefined, status, stdout, stderr, error: inputError });
248
+ });
249
+ timeout = setTimeout(() => {
250
+ beginTermination(new Error("cmux command timed out after 5000ms"));
251
+ }, 5000);
252
+ child.stdin.end(input);
253
+ }
254
+ catch (error) {
255
+ settle({ ok: false, status: null, stdout, stderr, error });
256
+ }
257
+ });
258
+ }
259
+ isSurfaceResolutionFailure(result) {
260
+ return !result.ok && result.status === CmuxDispatcher.surfaceUnavailableExitCode;
261
+ }
262
+ surfaceUnavailableResult() {
263
+ return { ok: false, status: null, stdout: "", stderr: "", surfaceUnavailable: true };
264
+ }
265
+ }
266
+ //# sourceMappingURL=dispatcher.js.map
@@ -0,0 +1,12 @@
1
+ import type { CmuxDispatcher, CommandResult } from "./dispatcher.js";
2
+ import type { PiExtensionContextSnapshot } from "./state.js";
3
+ type HookExtra = Record<string, unknown>;
4
+ export declare function eventName(subcommand: string): string;
5
+ export declare function surfaceTargetArgs(dispatcher: CmuxDispatcher, sessionId: string): string[] | null;
6
+ export declare function rememberSurfaceTarget(dispatcher: CmuxDispatcher, sessionId: string, result: CommandResult): void;
7
+ export declare function releaseSessionRuntime(dispatcher: CmuxDispatcher, sessionStates: Map<string, unknown>, sessionId: string): void;
8
+ export declare function sendHook(dispatcher: CmuxDispatcher, subcommand: string, context: PiExtensionContextSnapshot, extra?: HookExtra): Promise<boolean>;
9
+ export declare function ensureResumeBinding(dispatcher: CmuxDispatcher, context: PiExtensionContextSnapshot, sessionId: string): Promise<void>;
10
+ export declare function clearResumeBinding(dispatcher: CmuxDispatcher, context: PiExtensionContextSnapshot, sessionId: string): Promise<void>;
11
+ export {};
12
+ //# sourceMappingURL=hooks.d.ts.map