@yagni-app/code 0.2.1 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +30 -0
- package/dist/cli.js +147 -3
- package/dist/connectClaudeCode.d.ts +77 -0
- package/dist/connectClaudeCode.js +228 -0
- package/dist/connectCodex.d.ts +75 -0
- package/dist/connectCodex.js +201 -0
- package/dist/doctor.d.ts +1 -1
- package/dist/doctor.js +1 -1
- package/dist/extension/advisor.d.ts +4 -4
- package/dist/extension/advisor.js +6 -7
- package/dist/extension/approvedPrefixes.d.ts +103 -0
- package/dist/extension/approvedPrefixes.js +282 -0
- package/dist/extension/askAdvisorTool.d.ts +20 -5
- package/dist/extension/askAdvisorTool.js +126 -20
- package/dist/extension/askYagniTool.d.ts +23 -0
- package/dist/extension/askYagniTool.js +91 -2
- package/dist/extension/branding.d.ts +34 -3
- package/dist/extension/branding.js +114 -13
- package/dist/extension/chipEditor.d.ts +30 -9
- package/dist/extension/chipEditor.js +173 -59
- package/dist/extension/claudeRules.d.ts +0 -2
- package/dist/extension/claudeRules.js +0 -8
- package/dist/extension/cmux/dispatcher.d.ts +25 -0
- package/dist/extension/cmux/dispatcher.js +266 -0
- package/dist/extension/cmux/hooks.d.ts +12 -0
- package/dist/extension/cmux/hooks.js +192 -0
- package/dist/extension/cmux/index.d.ts +3 -0
- package/dist/extension/cmux/index.js +155 -0
- package/dist/extension/cmux/naming.d.ts +5 -0
- package/dist/extension/cmux/naming.js +23 -0
- package/dist/extension/cmux/state.d.ts +33 -0
- package/dist/extension/cmux/state.js +142 -0
- package/dist/extension/config.d.ts +44 -1
- package/dist/extension/config.js +37 -4
- package/dist/extension/costHud.d.ts +16 -22
- package/dist/extension/costHud.js +8 -47
- package/dist/extension/crashReport.js +1 -3
- package/dist/extension/execPolicy.d.ts +135 -0
- package/dist/extension/execPolicy.js +936 -0
- package/dist/extension/flywheel.d.ts +44 -0
- package/dist/extension/flywheel.js +53 -0
- package/dist/extension/footer.d.ts +118 -0
- package/dist/extension/footer.js +308 -0
- package/dist/extension/guardian.d.ts +139 -0
- package/dist/extension/guardian.js +237 -0
- package/dist/extension/index.d.ts +35 -7
- package/dist/extension/index.js +338 -33
- package/dist/extension/mineBeat.d.ts +95 -0
- package/dist/extension/mineBeat.js +193 -0
- package/dist/extension/permission.d.ts +124 -10
- package/dist/extension/permission.js +603 -52
- package/dist/extension/pipeline/childRegistry.d.ts +41 -0
- package/dist/extension/pipeline/childRegistry.js +118 -0
- package/dist/extension/pipeline/finish.js +5 -1
- package/dist/extension/pipeline/goCommand.d.ts +1 -1
- package/dist/extension/pipeline/goCommand.js +40 -9
- package/dist/extension/pipeline/goStatusCommands.d.ts +10 -0
- package/dist/extension/pipeline/goStatusCommands.js +61 -1
- package/dist/extension/pipeline/personas.js +26 -1
- package/dist/extension/pipeline/resilience.d.ts +2 -1
- package/dist/extension/pipeline/resilience.js +21 -2
- package/dist/extension/pipeline/runRegistry.d.ts +23 -1
- package/dist/extension/pipeline/runRegistry.js +57 -1
- package/dist/extension/pipeline/runner.js +4 -0
- package/dist/extension/pipeline/verify.d.ts +4 -0
- package/dist/extension/pipeline/verify.js +48 -26
- package/dist/extension/recordDecisionTool.d.ts +8 -0
- package/dist/extension/recordDecisionTool.js +24 -0
- package/dist/extension/redact.d.ts +20 -0
- package/dist/extension/redact.js +64 -0
- package/dist/extension/rerouteNotice.d.ts +3 -4
- package/dist/extension/rerouteNotice.js +20 -11
- package/dist/extension/subagentRender.d.ts +129 -0
- package/dist/extension/subagentRender.js +441 -0
- package/dist/extension/subagents.d.ts +11 -8
- package/dist/extension/subagents.js +162 -37
- package/dist/extension/ticketTools.d.ts +37 -0
- package/dist/extension/ticketTools.js +117 -0
- package/dist/extension/todos.d.ts +28 -1
- package/dist/extension/todos.js +76 -1
- package/dist/extension/tokenProvider.js +46 -5
- package/dist/extension/ultra.d.ts +27 -0
- package/dist/extension/ultra.js +76 -0
- package/dist/launch.d.ts +7 -0
- package/dist/launch.js +24 -12
- package/dist/login.d.ts +4 -2
- package/dist/login.js +19 -4
- package/dist/padding.d.ts +22 -0
- package/dist/padding.js +25 -0
- package/dist/promptEnrichment.d.ts +40 -0
- package/dist/promptEnrichment.js +85 -0
- package/dist/signalForward.d.ts +60 -0
- package/dist/signalForward.js +130 -0
- package/dist/token.d.ts +25 -0
- package/dist/token.js +45 -0
- package/package.json +6 -5
- package/dist/extension/boostCommand.d.ts +0 -144
- package/dist/extension/boostCommand.js +0 -263
|
@@ -40,9 +40,33 @@ export const YAGNI_IDENTITY = "You are YAGNI Code, an autonomous terminal coding
|
|
|
40
40
|
* exist.
|
|
41
41
|
*/
|
|
42
42
|
export const DRIVER_DELEGATION_PARAGRAPH = "Delegation: fan codebase mapping, wide searches, and mechanical multi-file " +
|
|
43
|
-
"work out to subagents (they run on cheaper tiers).
|
|
44
|
-
"
|
|
45
|
-
"for
|
|
43
|
+
"work out to subagents (they run on cheaper tiers). Reach for the stock " +
|
|
44
|
+
"agents by name: `searcher` for read-only reconnaissance and summarizing, " +
|
|
45
|
+
"`implementer` for executing a change you have already fully specified, " +
|
|
46
|
+
"`verification` for an adversarial pass that tries to break completed work " +
|
|
47
|
+
"before you rely on it. Keep judgment, synthesis, and the conversation " +
|
|
48
|
+
"with the user in this session. Do not spawn a subagent for work you can " +
|
|
49
|
+
"finish in a couple of tool calls.";
|
|
50
|
+
/**
|
|
51
|
+
* The ultra-mode delegation directive (/ultra): the user has opted into
|
|
52
|
+
* aggressive multi-agent orchestration, so the driver is told to structure
|
|
53
|
+
* meaningful work as a diamond — split, fan out workers, fan out refuting
|
|
54
|
+
* checkers, synthesize — instead of delegating only when convenient.
|
|
55
|
+
* DRIVER-ONLY for the same reason as {@link DRIVER_DELEGATION_PARAGRAPH}.
|
|
56
|
+
*/
|
|
57
|
+
export const ULTRA_DELEGATION_PARAGRAPH = "Delegation (ultra mode): the user has switched this session to ultra mode " +
|
|
58
|
+
"— aggressive multi-agent orchestration. Structure any meaningful task as " +
|
|
59
|
+
"a diamond: SPLIT the job into independent pieces; FAN OUT parallel " +
|
|
60
|
+
"subagents on cheaper tiers (`searcher` to scout, `implementer` or " +
|
|
61
|
+
"`general` to execute); CHECK by fanning out `verification` subagents told " +
|
|
62
|
+
"to refute the work, each through a different lens (correctness, edge cases, " +
|
|
63
|
+
"fit with this codebase); then SYNTHESIZE the results yourself. Treat " +
|
|
64
|
+
"agreement between checkers — not a single pass — as confirmation, and " +
|
|
65
|
+
"surface what they could not verify. Delegate by default and reserve this " +
|
|
66
|
+
"session for splitting, judging, and synthesis; only trivial work you can " +
|
|
67
|
+
"finish in a couple of tool calls skips the diamond. Subagents cannot touch " +
|
|
68
|
+
"your todo_write checklist, so keep it current yourself: update it when you " +
|
|
69
|
+
"split the job and again as each fanned-out piece lands, not only at the end.";
|
|
46
70
|
/**
|
|
47
71
|
* The identity used for the interactive DRIVER session ONLY: {@link
|
|
48
72
|
* YAGNI_IDENTITY} plus {@link DRIVER_DELEGATION_PARAGRAPH}. The caller (index.ts)
|
|
@@ -51,7 +75,15 @@ export const DRIVER_DELEGATION_PARAGRAPH = "Delegation: fan codebase mapping, wi
|
|
|
51
75
|
* module stays a pure string, with no env dependency of its own.
|
|
52
76
|
*/
|
|
53
77
|
export const YAGNI_IDENTITY_DRIVER = `${YAGNI_IDENTITY}\n\n${DRIVER_DELEGATION_PARAGRAPH}`;
|
|
54
|
-
|
|
78
|
+
/** The driver identity while /ultra is on: base identity + the diamond directive. */
|
|
79
|
+
export const YAGNI_IDENTITY_ULTRA = `${YAGNI_IDENTITY}\n\n${ULTRA_DELEGATION_PARAGRAPH}`;
|
|
80
|
+
/**
|
|
81
|
+
* Every identity variant this module can install, longest-composite-first, so
|
|
82
|
+
* the re-brand swap below never leaves a shorter variant's orphaned delegation
|
|
83
|
+
* paragraph behind (driver/ultra both start with the base identity).
|
|
84
|
+
*/
|
|
85
|
+
const KNOWN_IDENTITIES = [YAGNI_IDENTITY_ULTRA, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY];
|
|
86
|
+
// pi 0.84.1's exact identity sentence (dist/core/system-prompt.js). Exported as
|
|
55
87
|
// the identity anchor the CLI's pi-contract tripwire test reads back from pi's
|
|
56
88
|
// built system prompt, so a pi bump that reworded the opener (silently defeating
|
|
57
89
|
// our rebrand) fails CI loudly. Export-only: no behavior change here.
|
|
@@ -100,22 +132,35 @@ export function brandingDisabled(env) {
|
|
|
100
132
|
* Steps: (1) drop pi's self-referential documentation block, (2) replace the
|
|
101
133
|
* identity sentence (with a loose fallback, else prepend), (3) scrub residual
|
|
102
134
|
* brand tokens outside user content, (4) append the company brief + a proactive
|
|
103
|
-
* ask_yagni directive
|
|
135
|
+
* ask_yagni directive, (5) append the repository-rules section verbatim, (6)
|
|
136
|
+
* close with the identity reminder, which must stay last (recency). Idempotent:
|
|
137
|
+
* re-branding keeps a single identity, brief, rules section, and reminder.
|
|
104
138
|
*/
|
|
105
139
|
export function brandSystemPrompt(original, opts = {}) {
|
|
106
140
|
const identity = opts.identity ?? YAGNI_IDENTITY;
|
|
107
141
|
let s = original;
|
|
108
142
|
// 1. Drop pi's self-referential documentation block.
|
|
109
143
|
s = s.replace(PI_DOCS_BLOCK_RE, "");
|
|
110
|
-
// 2. Own the identity.
|
|
144
|
+
// 2. Own the identity. A previously-branded prompt may open with a DIFFERENT
|
|
145
|
+
// variant than the one now requested (/ultra toggles the driver between the
|
|
146
|
+
// delegation and diamond paragraphs mid-session) — swap it in place rather
|
|
147
|
+
// than prepending a second identity block.
|
|
111
148
|
if (PI_IDENTITY_RE.test(s)) {
|
|
112
149
|
s = s.replace(PI_IDENTITY_RE, identity);
|
|
113
150
|
}
|
|
114
151
|
else if (PI_IDENTITY_LOOSE_RE.test(s)) {
|
|
115
152
|
s = s.replace(PI_IDENTITY_LOOSE_RE, identity);
|
|
116
153
|
}
|
|
117
|
-
else
|
|
118
|
-
|
|
154
|
+
else {
|
|
155
|
+
const trimmed = s.trimStart();
|
|
156
|
+
const lead = s.slice(0, s.length - trimmed.length);
|
|
157
|
+
const existing = KNOWN_IDENTITIES.find((k) => trimmed.startsWith(k));
|
|
158
|
+
if (existing && existing !== identity) {
|
|
159
|
+
s = `${lead}${identity}${trimmed.slice(existing.length)}`;
|
|
160
|
+
}
|
|
161
|
+
else if (!existing && !trimmed.startsWith(identity)) {
|
|
162
|
+
s = `${identity}\n\n${s}`;
|
|
163
|
+
}
|
|
119
164
|
}
|
|
120
165
|
// 3. Safety net for residual brand tokens (never inside user content).
|
|
121
166
|
s = scrubOutsideProjectContext(s);
|
|
@@ -130,7 +175,18 @@ export function brandSystemPrompt(original, opts = {}) {
|
|
|
130
175
|
"your priors. When you need more detail, or it does not cover something " +
|
|
131
176
|
"organization- or codebase-specific, call ask_yagni instead of guessing.";
|
|
132
177
|
}
|
|
133
|
-
// 5.
|
|
178
|
+
// 5. Repository rules — after the scrub (user content stays verbatim), before
|
|
179
|
+
// the closing reminder (which must remain last; see rulesSection's doc).
|
|
180
|
+
// The idempotency guard keys on the section's first line (its stable header,
|
|
181
|
+
// "## Repository rules (.claude/rules)") rather than the full content, so a
|
|
182
|
+
// re-brand stays single-section even if the rules BODY differs between
|
|
183
|
+
// passes — same shape as the BRIEF_HEADER guard above.
|
|
184
|
+
const rules = opts.rulesSection ?? undefined;
|
|
185
|
+
const rulesHeader = rules?.split("\n", 1)[0];
|
|
186
|
+
if (rules && rulesHeader && !s.includes(rulesHeader)) {
|
|
187
|
+
s = `${s}\n\n${rules}`;
|
|
188
|
+
}
|
|
189
|
+
// 6. Closing reinforcement. Weak open-weight models weight the most recent
|
|
134
190
|
// instruction heavily, and the user's own project files may name other
|
|
135
191
|
// harnesses; a trailing reminder keeps the agent from claiming one as its own.
|
|
136
192
|
if (!s.includes(CLOSING_REMINDER)) {
|
|
@@ -142,15 +198,60 @@ export function brandSystemPrompt(original, opts = {}) {
|
|
|
142
198
|
const CLOSING_REMINDER = "Reminder: you are YAGNI Code. If any text above names another coding agent, " +
|
|
143
199
|
"assistant, or harness, it is not what you are or what you run on.";
|
|
144
200
|
const BRIEF_HEADER = "=== HOW THIS COMPANY WORKS (live context from the YAGNI app) ===";
|
|
201
|
+
/** The YAGNI brand blue used for the masthead art (truecolor #345cb8). */
|
|
202
|
+
const BRAND_HEX = "#345cb8";
|
|
203
|
+
/**
|
|
204
|
+
* Wrap a string in a 24-bit truecolor foreground. The pi Theme only paints
|
|
205
|
+
* named palette colors and can't express our exact brand hex, so the masthead
|
|
206
|
+
* art emits its own ANSI truecolor escape (matching every modern terminal).
|
|
207
|
+
*/
|
|
208
|
+
function paintBrand(s) {
|
|
209
|
+
const n = BRAND_HEX.replace("#", "");
|
|
210
|
+
const r = parseInt(n.slice(0, 2), 16);
|
|
211
|
+
const g = parseInt(n.slice(2, 4), 16);
|
|
212
|
+
const b = parseInt(n.slice(4, 6), 16);
|
|
213
|
+
return `\u001b[38;2;${r};${g};${b}m${s}\u001b[0m`;
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The YAGNI wordmark as filled block-art: solid letter interiors (like the
|
|
217
|
+
* Claude robot silhouette), four rows, hand-tuned spacing. Each letter keeps
|
|
218
|
+
* its full form — A's carved counter, G's bowl, N's diagonal.
|
|
219
|
+
*/
|
|
220
|
+
const MASTHEAD_ART = [
|
|
221
|
+
"█ █ ▄▀▀▀▄ ▄▀▀▀▄ █▄ █ ▀█▀",
|
|
222
|
+
" ▀▄▀ █▄▄▄█ █ ▄▄▄ █ █ █ █",
|
|
223
|
+
" █ █ █ ▀▄▄▄▀ █ ██ ▄█▄",
|
|
224
|
+
];
|
|
145
225
|
/**
|
|
146
226
|
* Build the YAGNI Code startup masthead string, rendered into a header that
|
|
147
227
|
* REPLACES pi's built-in startup banner (which otherwise shows "pi v<version>"
|
|
148
228
|
* and a "Pi can explain its own features…" onboarding line). Kept here as a
|
|
149
229
|
* pure string builder so its content is unit-testable without a terminal.
|
|
230
|
+
*
|
|
231
|
+
* Layout: the YAGNI art on the left, and to its right a three-line block —
|
|
232
|
+
* bold white "YAGNI Code" + version, the tagline, and the cwd. Art rows are
|
|
233
|
+
* padded to a uniform width so the right column stays straight.
|
|
150
234
|
*/
|
|
151
|
-
export function buildMastheadString(theme) {
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
|
|
235
|
+
export function buildMastheadString(theme, opts = {}) {
|
|
236
|
+
const title = theme.bold(theme.fg("text", "YAGNI Code"));
|
|
237
|
+
const version = opts.version?.trim();
|
|
238
|
+
const line1 = version ? `${title} ${theme.fg("dim", `v${version}`)}` : title;
|
|
239
|
+
const right = [
|
|
240
|
+
line1,
|
|
241
|
+
theme.fg("dim", "a terminal coding agent that knows your company"),
|
|
242
|
+
...(opts.cwd ? [theme.fg("dim", opts.cwd)] : []),
|
|
243
|
+
];
|
|
244
|
+
const artW = Math.max(...MASTHEAD_ART.map((l) => [...l].length));
|
|
245
|
+
const gap = " ";
|
|
246
|
+
const rows = Math.max(MASTHEAD_ART.length, right.length);
|
|
247
|
+
const lines = [];
|
|
248
|
+
for (let i = 0; i < rows; i++) {
|
|
249
|
+
const rawArt = MASTHEAD_ART[i] ?? "";
|
|
250
|
+
const pad = " ".repeat(Math.max(0, artW - [...rawArt].length));
|
|
251
|
+
const left = rawArt ? paintBrand(rawArt) + pad : " ".repeat(artW);
|
|
252
|
+
const r = right[i] ?? "";
|
|
253
|
+
lines.push(r ? `${left}${gap}${r}` : left);
|
|
254
|
+
}
|
|
255
|
+
return lines.join("\n");
|
|
155
256
|
}
|
|
156
257
|
//# sourceMappingURL=branding.js.map
|
|
@@ -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.
|
|
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
|
*/
|
|
@@ -48,21 +48,33 @@ export interface ImageAttachment {
|
|
|
48
48
|
*/
|
|
49
49
|
export declare function unwrapBracketedPaste(data: string): string | null;
|
|
50
50
|
/**
|
|
51
|
-
* Recognize a pasted
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
51
|
+
* Recognize a pasted payload that is entirely image-file paths and decode
|
|
52
|
+
* them. This covers Finder/desktop drag-drop and Ghostty/cmux's Cmd+V
|
|
53
|
+
* (which writes the clipboard image to a `clipboard-*.png` temp file and
|
|
54
|
+
* pastes its path) — Claude Code parity: dragging a screenshot in becomes
|
|
55
|
+
* `[Image #N]`, not a literal path.
|
|
56
|
+
*
|
|
57
|
+
* Guardrails against converting something the user meant as text: the WHOLE
|
|
58
|
+
* paste must be path tokens, every token must be an absolute path to a real
|
|
59
|
+
* file with an image extension AND genuine image magic bytes, and the
|
|
60
|
+
* conversion is visible — each image becomes a chip in the prompt, so nothing
|
|
61
|
+
* is ever attached silently. Returns null when the paste is not such a list
|
|
62
|
+
* (caller passes it through as ordinary text).
|
|
57
63
|
*/
|
|
58
|
-
export declare function
|
|
64
|
+
export declare function readPastedImagePaths(pastedText: string): ClipboardImage[] | null;
|
|
59
65
|
/**
|
|
60
66
|
* Read an image from the system clipboard without forking pi. Best-effort and
|
|
61
|
-
* cross-platform
|
|
67
|
+
* cross-platform using ONLY tools the OS ships with: osascript (macOS),
|
|
62
68
|
* wl-paste (Wayland), xclip (X11), PowerShell (Windows/WSL). Returns null when
|
|
63
69
|
* the clipboard holds no image (caller then pastes text instead).
|
|
64
70
|
*/
|
|
65
71
|
export declare const defaultClipboardImageReader: ClipboardImageReader;
|
|
72
|
+
/**
|
|
73
|
+
* Parse osascript's clipboard dump — `«data PNGf89504E47…»` — into raw bytes.
|
|
74
|
+
* The 4-char tag after `«data ` is the pasteboard flavor; the hex that follows
|
|
75
|
+
* is the image. Returns null when the output holds no such dump.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseOsascriptImageHex(out: string): Uint8Array | null;
|
|
66
78
|
/**
|
|
67
79
|
* A CustomEditor that turns clipboard image paste into numbered `[Image #N]`
|
|
68
80
|
* chips. The image bytes live here, keyed by chip number; the editor text holds
|
|
@@ -72,6 +84,15 @@ export declare class ChipEditor extends CustomEditor {
|
|
|
72
84
|
private stashed;
|
|
73
85
|
private chipCounter;
|
|
74
86
|
private readImage;
|
|
87
|
+
/**
|
|
88
|
+
* Fired when Ctrl+C lands on an EMPTY prompt. pi's own binding is
|
|
89
|
+
* clear-editor (Esc is the interrupt key), but with nothing typed that is a
|
|
90
|
+
* no-op, and every peer harness (Claude Code, Codex, Gemini, Aider) treats a
|
|
91
|
+
* single Ctrl+C as "stop what's happening". The hook lets the extension wire
|
|
92
|
+
* exactly that; the key still falls through to pi afterwards, so clear and
|
|
93
|
+
* the 500ms double-press exit window are untouched.
|
|
94
|
+
*/
|
|
95
|
+
onEmptyCtrlC?: () => void;
|
|
75
96
|
constructor(tui: TUI, theme: EditorTheme, keybindings: ConstructorParameters<typeof CustomEditor>[2], readImage?: ClipboardImageReader, options?: EditorOptions);
|
|
76
97
|
/**
|
|
77
98
|
* Paste-image must fire on Cmd+V too (Claude Code parity on macOS). Claude
|
|
@@ -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.
|
|
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
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 (
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
229
|
-
// so we strip the markers here and
|
|
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
|
|
321
|
+
const images = readPastedImagePaths(unwrapped);
|
|
233
322
|
logImagePaste({
|
|
234
323
|
event: "paste_path",
|
|
235
|
-
outcome:
|
|
236
|
-
|
|
237
|
-
bytes:
|
|
238
|
-
|
|
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 (
|
|
242
|
-
|
|
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
|
|
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
|