@yagni-app/code-staging 1.1.4-staging.1416.1 → 1.1.4-staging.1420.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/extension/index.js +56 -11
- package/dist/extension/permission/approvedPrefixes.d.ts +65 -0
- package/dist/extension/permission/approvedPrefixes.js +138 -0
- package/dist/extension/permission/gate.js +96 -23
- package/dist/extension/permission/prefixInput.d.ts +58 -0
- package/dist/extension/permission/prefixInput.js +101 -0
- package/dist/extension/sandbox/session.js +4 -2
- package/dist/extension/scratchpad.d.ts +15 -1
- package/dist/extension/scratchpad.js +27 -4
- package/package.json +2 -2
package/dist/extension/index.js
CHANGED
|
@@ -17,7 +17,7 @@ import { makeRecordDecisionTool } from "./recordDecisionTool.js";
|
|
|
17
17
|
import { makeSuggestNextWorkTool } from "./nextWorkTool.js";
|
|
18
18
|
import { BRAND_NAME, brandSystemPrompt, brandingDisabled, buildMastheadString, YAGNI_IDENTITY_DRIVER, YAGNI_IDENTITY_DRIVER_GROUNDING_FREE, YAGNI_IDENTITY_GROUNDING_FREE, YAGNI_IDENTITY_ULTRA, YAGNI_IDENTITY_ULTRA_GROUNDING_FREE } from "./branding.js";
|
|
19
19
|
import { claudeRulesSection } from "./claudeRules.js";
|
|
20
|
-
import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadSection } from "./scratchpad.js";
|
|
20
|
+
import { ensureScratchpadDir, SCRATCHPAD_TMPDIR_ENV, scratchpadDir as scratchpadDirFor, scratchpadOwnerRootFor, scratchpadSection } from "./scratchpad.js";
|
|
21
21
|
import { registerCostCommand } from "./costHud.js";
|
|
22
22
|
import { isDebug } from "./diagnostics.js";
|
|
23
23
|
import { logEvent } from "./errorSink.js";
|
|
@@ -1339,24 +1339,69 @@ export async function registerYagni(pi, deps = {}) {
|
|
|
1339
1339
|
// so the very first turn already carries the section. Fails closed: no
|
|
1340
1340
|
// sessionId (a bare pi run) or a failed mkdir means no section, and the mkdir
|
|
1341
1341
|
// failure is logged so a missing scratchpad is not silent.
|
|
1342
|
+
const scratchpadTmpRoot = env[SCRATCHPAD_TMPDIR_ENV] || undefined;
|
|
1342
1343
|
const scratchpadDirPath = scratchpadDirFor({
|
|
1343
1344
|
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1344
1345
|
cwd: process.cwd(),
|
|
1345
|
-
tmp:
|
|
1346
|
+
tmp: scratchpadTmpRoot,
|
|
1346
1347
|
});
|
|
1347
1348
|
let scratchpadSectionText;
|
|
1348
1349
|
if (scratchpadDirPath) {
|
|
1349
|
-
const ensured = ensureScratchpadDir(scratchpadDirPath
|
|
1350
|
+
const ensured = ensureScratchpadDir(scratchpadDirPath, undefined, {
|
|
1351
|
+
onError: (errorCode) => {
|
|
1352
|
+
logEvent({
|
|
1353
|
+
source: "scratchpad",
|
|
1354
|
+
level: "error",
|
|
1355
|
+
event: "scratchpad_mkdir_failed",
|
|
1356
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1357
|
+
fields: { path: scratchpadDirPath, errorCode },
|
|
1358
|
+
});
|
|
1359
|
+
},
|
|
1360
|
+
});
|
|
1350
1361
|
if (ensured) {
|
|
1351
1362
|
scratchpadSectionText = scratchpadSection(ensured);
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1363
|
+
// Claude Code parity: srt reads CLAUDE_CODE_TMPDIR from THIS process's
|
|
1364
|
+
// env when wrapping a sandboxed bash command and forces the child's
|
|
1365
|
+
// TMPDIR onto it. Point it at the per-user scratchpad OWNER ROOT (not
|
|
1366
|
+
// the scratchpad itself) so sandboxed-bash temp files land in the same
|
|
1367
|
+
// per-user tree the scratchpad lives in, under a path SHORT enough for
|
|
1368
|
+
// named unix-socket binds (the test-runner IPC pattern — a deep
|
|
1369
|
+
// scratchpad TMPDIR trips macOS's 104-char sun_path limit with EINVAL).
|
|
1370
|
+
// The scratchpad remains the sanctioned staging place by PRIORITY (the
|
|
1371
|
+
// prompt section outranks $TMPDIR), not by path convergence.
|
|
1372
|
+
//
|
|
1373
|
+
// Production note: children (subagents, /go stages) inherit this env
|
|
1374
|
+
// because `env` IS process.env there and the runner spawns with it. A
|
|
1375
|
+
// harness injecting deps.env sees the write only in that object —
|
|
1376
|
+
// spawned children of such a harness do not, by construction of the
|
|
1377
|
+
// runner's own env seam.
|
|
1378
|
+
//
|
|
1379
|
+
// Unset when there is no scratchpad (no session id, or the mkdir
|
|
1380
|
+
// failed) — srt keeps its /tmp/claude default and nothing changes for
|
|
1381
|
+
// those sessions.
|
|
1382
|
+
if (!env.CLAUDE_CODE_TMPDIR) {
|
|
1383
|
+
const ownerRoot = scratchpadOwnerRootFor({ tmp: scratchpadTmpRoot });
|
|
1384
|
+
env.CLAUDE_CODE_TMPDIR = ownerRoot;
|
|
1385
|
+
logEvent({
|
|
1386
|
+
source: "scratchpad",
|
|
1387
|
+
level: "info",
|
|
1388
|
+
event: "scratchpad_tmpdir_pinned",
|
|
1389
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1390
|
+
fields: { path: ownerRoot },
|
|
1391
|
+
});
|
|
1392
|
+
}
|
|
1393
|
+
else {
|
|
1394
|
+
// The trail must distinguish "pinned" from "respected an operator
|
|
1395
|
+
// override" — no path value here (the explicit value is the
|
|
1396
|
+
// operator's, not ours to echo).
|
|
1397
|
+
logEvent({
|
|
1398
|
+
source: "scratchpad",
|
|
1399
|
+
level: "info",
|
|
1400
|
+
event: "scratchpad_tmpdir_pin_skipped",
|
|
1401
|
+
sessionId: env.YAGNI_SESSION_ID ?? undefined,
|
|
1402
|
+
fields: {},
|
|
1403
|
+
});
|
|
1404
|
+
}
|
|
1360
1405
|
}
|
|
1361
1406
|
}
|
|
1362
1407
|
// The condensed Claude Code-style transcript: the seven built-ins re-render
|
|
@@ -151,6 +151,71 @@ export declare function matchesCompoundGrants(command: string, grants: readonly
|
|
|
151
151
|
export declare function validateGrant(command: string, policy: ExecPolicy, repoKey: string): ApprovedPrefixGrant | null;
|
|
152
152
|
/** Human label for the remember option: "git push …". */
|
|
153
153
|
export declare function describePrefix(pattern: string[]): string;
|
|
154
|
+
/**
|
|
155
|
+
* The custom-prefix field's seed (the editable input's initial value —
|
|
156
|
+
* Claude's initialValue): the prefix the user most plausibly wants to
|
|
157
|
+
* narrow. Single command → its own derived prefix; compound → the first
|
|
158
|
+
* PROMPT-BAND uncovered segment's prefix (Claude parity: their compound
|
|
159
|
+
* suggestions name only the segments needing approval — a `cd`/read-only
|
|
160
|
+
* rider is not what the user is being asked about). Falls back to the raw
|
|
161
|
+
* command only when nothing derivable exists (Claude's editable-field
|
|
162
|
+
* final fallback — the user narrows from the full text). NEVER the
|
|
163
|
+
* multi-seed join: "cd git push grep" is not a prefix of anything and
|
|
164
|
+
* editing it from scratch is the dead-end the field exists to prevent.
|
|
165
|
+
* PURE — a throwing classify counts as seedable (the LADDER fences, the
|
|
166
|
+
* seed does not).
|
|
167
|
+
*/
|
|
168
|
+
export declare function describeSeedPrefix(command: string, uncoveredSegments: readonly string[], policy: ExecPolicy): string;
|
|
169
|
+
/**
|
|
170
|
+
* The custom-prefix acceptance unit (the decision-(a) fill): given the
|
|
171
|
+
* user's typed word (as a grant) and the command it was typed for, return
|
|
172
|
+
* the complete remember unit — the word's grant PLUS the ladder's
|
|
173
|
+
* self-match-proved seeds for every REMAINING uncovered segment — or null
|
|
174
|
+
* when the word covers nothing, a segment stays unfillable, or the filled
|
|
175
|
+
* set cannot cover the whole compound. PURE — extracted from the gate so
|
|
176
|
+
* every exclusion branch is directly testable. The unit is ONE decision
|
|
177
|
+
* (saved atomically at the gate); persistence itself is per-grant
|
|
178
|
+
* fail-soft, the same pattern as the multi-seed remember path.
|
|
179
|
+
*
|
|
180
|
+
* The user-typed grant must already carry repoKey (it matches at evaluation
|
|
181
|
+
* time); the returned unit's fill seeds carry repoKey/addedAt/cwd, ready
|
|
182
|
+
* to persist.
|
|
183
|
+
*/
|
|
184
|
+
export declare function computeCustomPrefixUnit(args: {
|
|
185
|
+
command: string;
|
|
186
|
+
customGrant: ApprovedPrefixGrant;
|
|
187
|
+
existingGrants: readonly ApprovedPrefixGrant[];
|
|
188
|
+
uncoveredCount: number;
|
|
189
|
+
policy: ExecPolicy;
|
|
190
|
+
repoKey: string;
|
|
191
|
+
cwd: string;
|
|
192
|
+
/** The ladder's fill preview for this command (validateGrantForEscape's
|
|
193
|
+
* token seeds, repoKey/addedAt/cwd already filled) — the SAME preview the
|
|
194
|
+
* gate uses for the title disclosure, passed in so the ladder runs ONCE
|
|
195
|
+
* per ask and disclosure/save agreement is structural, not incidental.
|
|
196
|
+
* Absent → computed here (tests can drive the seam standalone). */
|
|
197
|
+
fillSeeds?: readonly ApprovedPrefixGrant[];
|
|
198
|
+
}): ApprovedPrefixGrant[] | null;
|
|
199
|
+
/**
|
|
200
|
+
* The custom-prefix dialog's TITLE, derived from the SEED word (the
|
|
201
|
+
* overwhelmingly common case: the field opens pre-filled, and the user
|
|
202
|
+
* keeps or edits around it). The disclosure predicts the actual unit for
|
|
203
|
+
* the seed — running the same acceptance seam (computeCustomPrefixUnit)
|
|
204
|
+
* the save will run — so the title advertises exactly what persisting the
|
|
205
|
+
* seed would save, minus the seed's own grant. A user who types a wildly
|
|
206
|
+
* different word gets the after-the-fact save-notify naming the real unit.
|
|
207
|
+
* Returns null when no fill rides along (single commands, or the seed
|
|
208
|
+
* already covers everything) — the plain title.
|
|
209
|
+
* PURE.
|
|
210
|
+
*/
|
|
211
|
+
export declare function describeCustomPrefixFill(command: string, seed: string, args: {
|
|
212
|
+
existingGrants: readonly ApprovedPrefixGrant[];
|
|
213
|
+
uncoveredCount: number;
|
|
214
|
+
policy: ExecPolicy;
|
|
215
|
+
repoKey: string;
|
|
216
|
+
cwd: string;
|
|
217
|
+
fillSeeds: readonly ApprovedPrefixGrant[];
|
|
218
|
+
}): string[] | null;
|
|
154
219
|
/** Preserve the command and output target before a heredoc marker. */
|
|
155
220
|
export declare function heredocPrefix(command: string): string | null;
|
|
156
221
|
/**
|
|
@@ -441,6 +441,144 @@ export function validateGrant(command, policy, repoKey) {
|
|
|
441
441
|
export function describePrefix(pattern) {
|
|
442
442
|
return `${pattern.join(" ")} …`;
|
|
443
443
|
}
|
|
444
|
+
/**
|
|
445
|
+
* The custom-prefix field's seed (the editable input's initial value —
|
|
446
|
+
* Claude's initialValue): the prefix the user most plausibly wants to
|
|
447
|
+
* narrow. Single command → its own derived prefix; compound → the first
|
|
448
|
+
* PROMPT-BAND uncovered segment's prefix (Claude parity: their compound
|
|
449
|
+
* suggestions name only the segments needing approval — a `cd`/read-only
|
|
450
|
+
* rider is not what the user is being asked about). Falls back to the raw
|
|
451
|
+
* command only when nothing derivable exists (Claude's editable-field
|
|
452
|
+
* final fallback — the user narrows from the full text). NEVER the
|
|
453
|
+
* multi-seed join: "cd git push grep" is not a prefix of anything and
|
|
454
|
+
* editing it from scratch is the dead-end the field exists to prevent.
|
|
455
|
+
* PURE — a throwing classify counts as seedable (the LADDER fences, the
|
|
456
|
+
* seed does not).
|
|
457
|
+
*/
|
|
458
|
+
export function describeSeedPrefix(command, uncoveredSegments, policy) {
|
|
459
|
+
if (isSinglePlainCommand(command)) {
|
|
460
|
+
const pattern = derivePrefix(command);
|
|
461
|
+
if (pattern)
|
|
462
|
+
return pattern.join(" ");
|
|
463
|
+
}
|
|
464
|
+
// Compound (or a non-derivable single): the first prompt-band uncovered
|
|
465
|
+
// segment's prefix, skipping read-only riders (cd, cat, ls — the
|
|
466
|
+
// allow-classified segments the user is not being asked about). Each
|
|
467
|
+
// segment is CANONICALIZED first (the ladder's own posture): the raw
|
|
468
|
+
// segments arrive quote-preserving, so `git push 2>&1` reads as
|
|
469
|
+
// `git push 2>'&1'` — an operator-carrying string that neither
|
|
470
|
+
// classifies nor derives; canonicalization strips the safe redirect the
|
|
471
|
+
// same way matching will.
|
|
472
|
+
for (const seg of uncoveredSegments) {
|
|
473
|
+
const canonical = canonicalizeForGrants(seg);
|
|
474
|
+
if (!canonical || !isSinglePlainCommand(canonical))
|
|
475
|
+
continue;
|
|
476
|
+
let readonlySegment = false;
|
|
477
|
+
try {
|
|
478
|
+
readonlySegment = classifyCommand(canonical, policy).decision === "allow";
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
// classify-error counts as seedable — the ladder fences.
|
|
482
|
+
}
|
|
483
|
+
if (readonlySegment)
|
|
484
|
+
continue;
|
|
485
|
+
const pattern = derivePrefix(canonical);
|
|
486
|
+
if (pattern)
|
|
487
|
+
return pattern.join(" ");
|
|
488
|
+
}
|
|
489
|
+
return command;
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The custom-prefix acceptance unit (the decision-(a) fill): given the
|
|
493
|
+
* user's typed word (as a grant) and the command it was typed for, return
|
|
494
|
+
* the complete remember unit — the word's grant PLUS the ladder's
|
|
495
|
+
* self-match-proved seeds for every REMAINING uncovered segment — or null
|
|
496
|
+
* when the word covers nothing, a segment stays unfillable, or the filled
|
|
497
|
+
* set cannot cover the whole compound. PURE — extracted from the gate so
|
|
498
|
+
* every exclusion branch is directly testable. The unit is ONE decision
|
|
499
|
+
* (saved atomically at the gate); persistence itself is per-grant
|
|
500
|
+
* fail-soft, the same pattern as the multi-seed remember path.
|
|
501
|
+
*
|
|
502
|
+
* The user-typed grant must already carry repoKey (it matches at evaluation
|
|
503
|
+
* time); the returned unit's fill seeds carry repoKey/addedAt/cwd, ready
|
|
504
|
+
* to persist.
|
|
505
|
+
*/
|
|
506
|
+
export function computeCustomPrefixUnit(args) {
|
|
507
|
+
const { command, customGrant, existingGrants, uncoveredCount, policy, repoKey, cwd } = args;
|
|
508
|
+
// Non-token (literal) custom inputs keep the single-grant proof.
|
|
509
|
+
if (customGrant.pattern.length === 0) {
|
|
510
|
+
return matchesGrant(command, [customGrant], repoKey) ? [customGrant] : null;
|
|
511
|
+
}
|
|
512
|
+
const ev = evaluateCompoundForEscape(command, [...existingGrants, customGrant], repoKey, policy);
|
|
513
|
+
if (ev.forbidden)
|
|
514
|
+
return null;
|
|
515
|
+
if (ev.uncovered.length === 0)
|
|
516
|
+
return [customGrant];
|
|
517
|
+
// The word must cover AT LEAST ONE segment of this command — a word
|
|
518
|
+
// unrelated to every segment (typed "zzz" for a pnpm compound) is a dead
|
|
519
|
+
// grant no fill can legitimize; refuse it. The fill exists to cover the
|
|
520
|
+
// SIBLINGS of the user's word, never to smuggle it in.
|
|
521
|
+
if (ev.uncovered.length >= uncoveredCount)
|
|
522
|
+
return null;
|
|
523
|
+
// Fill from the ladder: seeds for the remaining uncovered segments. The
|
|
524
|
+
// ladder's seeds are hypothetical (empty repoKey) — fill repoKey/addedAt/
|
|
525
|
+
// cwd BEFORE the recheck so grant matching sees them as real grants for
|
|
526
|
+
// THIS repo, exactly as the remember resolution does.
|
|
527
|
+
const fillSeeds = args.fillSeeds ?? (validateGrantForEscape(command, policy, repoKey)?.seeds ?? [])
|
|
528
|
+
.filter((sd) => sd.pattern.length > 0)
|
|
529
|
+
.map((sd) => ({ ...sd, repoKey, addedAt: new Date().toISOString(), cwd }));
|
|
530
|
+
// A fill with no token seeds cannot complete the unit — refuse (the
|
|
531
|
+
// ladder's literal rung is not a token fill; the shapes it covers are
|
|
532
|
+
// the heredoc class, which the honest warn + re-offer handles).
|
|
533
|
+
if (fillSeeds.length === 0)
|
|
534
|
+
return null;
|
|
535
|
+
const recheck = evaluateCompoundForEscape(command, [...existingGrants, customGrant, ...fillSeeds], repoKey, policy);
|
|
536
|
+
if (recheck.forbidden || recheck.uncovered.length > 0)
|
|
537
|
+
return null;
|
|
538
|
+
// Dedupe: when the typed word is itself the derivable prefix of its
|
|
539
|
+
// segment (the common case — typing "git push"), the fill re-derives the
|
|
540
|
+
// same pattern. One grant per distinct pattern.
|
|
541
|
+
const seen = new Set();
|
|
542
|
+
const unit = [];
|
|
543
|
+
for (const grant of [customGrant, ...fillSeeds]) {
|
|
544
|
+
const key = grant.pattern.join(" ");
|
|
545
|
+
if (seen.has(key))
|
|
546
|
+
continue;
|
|
547
|
+
seen.add(key);
|
|
548
|
+
unit.push(grant);
|
|
549
|
+
}
|
|
550
|
+
return unit;
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* The custom-prefix dialog's TITLE, derived from the SEED word (the
|
|
554
|
+
* overwhelmingly common case: the field opens pre-filled, and the user
|
|
555
|
+
* keeps or edits around it). The disclosure predicts the actual unit for
|
|
556
|
+
* the seed — running the same acceptance seam (computeCustomPrefixUnit)
|
|
557
|
+
* the save will run — so the title advertises exactly what persisting the
|
|
558
|
+
* seed would save, minus the seed's own grant. A user who types a wildly
|
|
559
|
+
* different word gets the after-the-fact save-notify naming the real unit.
|
|
560
|
+
* Returns null when no fill rides along (single commands, or the seed
|
|
561
|
+
* already covers everything) — the plain title.
|
|
562
|
+
* PURE.
|
|
563
|
+
*/
|
|
564
|
+
export function describeCustomPrefixFill(command, seed, args) {
|
|
565
|
+
const seedPattern = derivePrefix(seed);
|
|
566
|
+
if (!seedPattern)
|
|
567
|
+
return null;
|
|
568
|
+
const seedGrant = {
|
|
569
|
+
pattern: seedPattern,
|
|
570
|
+
repoKey: args.repoKey,
|
|
571
|
+
addedAt: new Date().toISOString(),
|
|
572
|
+
cwd: args.cwd,
|
|
573
|
+
};
|
|
574
|
+
const unit = computeCustomPrefixUnit({ command, customGrant: seedGrant, ...args });
|
|
575
|
+
if (!unit)
|
|
576
|
+
return null;
|
|
577
|
+
const fill = unit
|
|
578
|
+
.filter((g) => g.pattern.join(" ") !== seedPattern.join(" "))
|
|
579
|
+
.map((g) => g.pattern.join(" "));
|
|
580
|
+
return fill.length > 0 ? fill : null;
|
|
581
|
+
}
|
|
444
582
|
// --- Escape-flow derivation (the unsandboxed-retry consent ladder) ---
|
|
445
583
|
/** Preserve the command and output target before a heredoc marker. */
|
|
446
584
|
export function heredocPrefix(command) {
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* When the mode leaves plan, stale plan-context messages are filtered out of
|
|
27
27
|
* the context so the model doesn't keep believing it is restricted.
|
|
28
28
|
*/
|
|
29
|
-
import { derivePrefix, describePrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
29
|
+
import { derivePrefix, describePrefix, describeSeedPrefix, evaluateCompoundForEscape, matchesGrant, matchesCompoundGrants, sandboxFailureKey, storagePrefix, validateGrantForAsk, validateGrantForEscape, } from "./approvedPrefixes.js";
|
|
30
30
|
import { consultPrefixMemoized, createPrefixMemo, PREFIX_CONSULT_GRACE_MS, segmentCannotYieldCandidate, } from "./prefixExtract.js";
|
|
31
31
|
import { logEvent } from "../errorSink.js";
|
|
32
32
|
import { makeBlessStore as defaultMakeBlessStore } from "../bless.js";
|
|
@@ -34,6 +34,8 @@ import { classifyCommand, DEFAULT_EXEC_POLICY } from "./execPolicy.js";
|
|
|
34
34
|
import { evaluateRules } from "../permissionRules/engine.js";
|
|
35
35
|
import { isDebug } from "../diagnostics.js";
|
|
36
36
|
import { buildDiagnosticEvent, checkCircuitBreaker, DEFAULT_GUARDIAN_LIMITS, } from "./guardian.js";
|
|
37
|
+
import { askCustomPrefix, CUSTOM_PREFIX_CANCELLED } from "./prefixInput.js";
|
|
38
|
+
import { computeCustomPrefixUnit, describeCustomPrefixFill } from "./approvedPrefixes.js";
|
|
37
39
|
export function createModeHolder(initial = "auto") {
|
|
38
40
|
let current = initial;
|
|
39
41
|
const listeners = new Set();
|
|
@@ -1865,18 +1867,63 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1865
1867
|
// dismissed/user_reject.
|
|
1866
1868
|
let retryErrored = false;
|
|
1867
1869
|
if (!selectThrew && choice === customLabel) {
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1870
|
+
// The seed is the honest single suggestion (Claude's initialValue):
|
|
1871
|
+
// the derived prefix for a single command, the first prompt-band
|
|
1872
|
+
// segment's prefix for a compound — never the multi-seed join
|
|
1873
|
+
// ("cd git push grep" is a prefix of nothing; the join made the
|
|
1874
|
+
// field unrecognizable AND uneditable from a useful base).
|
|
1875
|
+
const seedText = describeSeedPrefix(command, compound.uncovered, execPolicy);
|
|
1876
|
+
// The title DISCLOSES the fill up front (informed consent, no extra
|
|
1877
|
+
// click) — derived from the SEED word: the field opens pre-filled,
|
|
1878
|
+
// so the disclosure predicts the actual unit the acceptance seam
|
|
1879
|
+
// would save for that seed (sibling families only, minus the seed's
|
|
1880
|
+
// own grant). A user who types a wildly different word still gets the
|
|
1881
|
+
// after-the-fact save-notify naming the real unit. ONE ladder preview
|
|
1882
|
+
// feeds both the title and the seam's fill (the gate computes it;
|
|
1883
|
+
// computeCustomPrefixUnit consumes it) — the ladder runs once per
|
|
1884
|
+
// ask, and disclosure/save agreement is structural.
|
|
1885
|
+
const fillPreview = (validateGrantForEscape(command, execPolicy, repoKey)?.seeds ?? [])
|
|
1886
|
+
.filter((sd) => sd.pattern.length > 0)
|
|
1887
|
+
.map((sd) => ({ ...sd, repoKey, addedAt: new Date().toISOString(), cwd }));
|
|
1888
|
+
const fillLabels = describeCustomPrefixFill(command, seedText, {
|
|
1889
|
+
existingGrants: grants,
|
|
1890
|
+
uncoveredCount: compound.uncovered.length,
|
|
1891
|
+
policy: execPolicy,
|
|
1892
|
+
repoKey,
|
|
1893
|
+
cwd,
|
|
1894
|
+
fillSeeds: fillPreview,
|
|
1895
|
+
});
|
|
1896
|
+
const unitTitle = fillLabels !== null
|
|
1897
|
+
? `Don't ask again for commands starting with (also saving: ${fillLabels.join(", ")})`
|
|
1898
|
+
: "Don't ask again for commands starting with";
|
|
1871
1899
|
let custom;
|
|
1900
|
+
let cancelledInput = false;
|
|
1872
1901
|
let inputThrew = false;
|
|
1873
1902
|
try {
|
|
1874
|
-
|
|
1903
|
+
const resolution = await askCustomPrefix(ctx, unitTitle, seedText);
|
|
1904
|
+
if (resolution === CUSTOM_PREFIX_CANCELLED)
|
|
1905
|
+
cancelledInput = true;
|
|
1906
|
+
else if (resolution !== undefined)
|
|
1907
|
+
custom = resolution;
|
|
1875
1908
|
}
|
|
1876
|
-
catch {
|
|
1909
|
+
catch (err) {
|
|
1877
1910
|
inputThrew = true;
|
|
1911
|
+
// Same discipline as every sibling dialog failure in this flow —
|
|
1912
|
+
// a thrown ask (the fallback ctx.ui.input included) must leave a
|
|
1913
|
+
// trail, error class only (never the thrown message). The phase
|
|
1914
|
+
// discriminator is a stable non-message field so multiple ask
|
|
1915
|
+
// failure lines in one turn are tellable apart.
|
|
1916
|
+
logEvent({
|
|
1917
|
+
source: "permission-rules",
|
|
1918
|
+
level: "warn",
|
|
1919
|
+
event: "gate_dialog_error",
|
|
1920
|
+
fields: {
|
|
1921
|
+
error: err instanceof Error ? err.constructor.name : typeof err,
|
|
1922
|
+
phase: "custom-ask",
|
|
1923
|
+
},
|
|
1924
|
+
});
|
|
1878
1925
|
}
|
|
1879
|
-
if (!inputThrew && custom !== undefined && custom.trim().length > 0) {
|
|
1926
|
+
if (!inputThrew && !cancelledInput && custom !== undefined && custom.trim().length > 0) {
|
|
1880
1927
|
const trimmedCustom = custom.trim();
|
|
1881
1928
|
// The custom field accepts two shapes:
|
|
1882
1929
|
// (a) a derivable token pattern — a clean command word (`npx`,
|
|
@@ -1897,24 +1944,50 @@ export function registerPermissionGate(pi, deps = {}) {
|
|
|
1897
1944
|
const customGrant = tokenPattern
|
|
1898
1945
|
? { pattern: tokenPattern, repoKey, addedAt: new Date().toISOString(), cwd }
|
|
1899
1946
|
: { pattern: [], literal: trimmedCustom, repoKey, addedAt: new Date().toISOString(), cwd };
|
|
1900
|
-
//
|
|
1901
|
-
//
|
|
1902
|
-
//
|
|
1903
|
-
//
|
|
1904
|
-
//
|
|
1905
|
-
//
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1947
|
+
// Compound acceptance is FILLING, not all-or-nothing (Claude
|
|
1948
|
+
// parity — their compound per-subcommand rules save as one
|
|
1949
|
+
// decision; our analog: the custom word is the grant for the
|
|
1950
|
+
// segment it covers, and the ladder's own self-match-proved seeds
|
|
1951
|
+
// fill every REMAINING uncovered segment). Under the old
|
|
1952
|
+
// all-or-nothing validation a bare word could NEVER satisfy a
|
|
1953
|
+
// compound (the sibling segments stayed uncovered), so an
|
|
1954
|
+
// honestly-answered field was rejected and the ask re-fired
|
|
1955
|
+
// forever — the live failure this flow fixes. The unit is ONE
|
|
1956
|
+
// decision saved here; persistence itself is per-grant fail-soft,
|
|
1957
|
+
// the same pattern as the multi-seed remember path (a mid-loop
|
|
1958
|
+
// persist failure leaves earlier grants saved — the warn trail
|
|
1959
|
+
// records it). The unit computation is PURE and every exclusion
|
|
1960
|
+
// branch is unit-tested directly (computeCustomPrefixUnit); a
|
|
1961
|
+
// segment the ladder cannot seed (heredoc/quoted shapes) still
|
|
1962
|
+
// refuses: fail closed, warn honestly, one re-offer (below).
|
|
1963
|
+
const customUnit = computeCustomPrefixUnit({
|
|
1964
|
+
command,
|
|
1965
|
+
customGrant,
|
|
1966
|
+
existingGrants: grants,
|
|
1967
|
+
uncoveredCount: compound.uncovered.length,
|
|
1968
|
+
policy: execPolicy,
|
|
1969
|
+
repoKey,
|
|
1970
|
+
cwd,
|
|
1971
|
+
fillSeeds: fillPreview,
|
|
1972
|
+
});
|
|
1973
|
+
if (customUnit) {
|
|
1974
|
+
for (const grantRecord of customUnit) {
|
|
1975
|
+
grants.push(grantRecord);
|
|
1976
|
+
persistGrantFailSoft(grantRecord);
|
|
1977
|
+
}
|
|
1916
1978
|
rememberApproved(cwd, command);
|
|
1917
1979
|
emitGateEvent(slot, { ...eventBase, outcome: "escape_ask_approved_remembered", consulted: false, prefixConsult: foldStatus() });
|
|
1980
|
+
// Save-confirmation: the old flow's only success feedback was
|
|
1981
|
+
// silence, leaving the user asking "did it work?". Name what was
|
|
1982
|
+
// saved — the unit (custom word + filled siblings) — one line,
|
|
1983
|
+
// info tier, fail-soft.
|
|
1984
|
+
if (ctx.hasUI) {
|
|
1985
|
+
try {
|
|
1986
|
+
const savedLabels = customUnit.map((g) => g.pattern.length > 0 ? g.pattern.join(" ") : (g.literal ?? ""));
|
|
1987
|
+
ctx.ui.notify(`Saved "don't ask again" for: ${savedLabels.join(", ")}`, "info");
|
|
1988
|
+
}
|
|
1989
|
+
catch { /* notify must never gate */ }
|
|
1990
|
+
}
|
|
1918
1991
|
return {};
|
|
1919
1992
|
}
|
|
1920
1993
|
// A custom prefix that does not cover this command is a dead rule —
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The custom-prefix field (the escape consent flow's editable input):
|
|
3
|
+
* pi's OWN shipped ExtensionInputComponent with exactly one delta — the
|
|
4
|
+
* input is pre-filled with the derived seed (Claude's initialValue; pi's
|
|
5
|
+
* stock component drops its placeholder, so the old `ctx.ui.input` dialog
|
|
6
|
+
* arrived empty and unlabeled while the turn's working indicator kept
|
|
7
|
+
* animating — a blocked turn cosplaying as a live one, the ticket's
|
|
8
|
+
* "the model went back to work" symptom).
|
|
9
|
+
*
|
|
10
|
+
* The gate's askCustomPrefix seam: TUI mounts the seeded surface via
|
|
11
|
+
* ctx.ui.custom; every other mode (RPC, desktop, headless harnesses)
|
|
12
|
+
* falls back to plain ctx.ui.input, where the corrected seed rides the
|
|
13
|
+
* placeholder (desktop's DialogCard renders it). ctx.mode is a hard
|
|
14
|
+
* guard — pi's RPC custom() RESOLVES undefined instantly (never throws),
|
|
15
|
+
* so an unguarded call would read as a cancelled input. A THROWN or
|
|
16
|
+
* no-mount custom() falls back to the plain input dialog WITH a
|
|
17
|
+
* gate_dialog_error trail line (error class only) — a broken mount must
|
|
18
|
+
* degrade visibly, not silently.
|
|
19
|
+
*/
|
|
20
|
+
import { ExtensionInputComponent } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import type { Component } from "@earendil-works/pi-tui";
|
|
22
|
+
/** pi's input dialog, seeded. Subclass — not a reimplementation. */
|
|
23
|
+
export declare class SeededExtensionInput extends ExtensionInputComponent {
|
|
24
|
+
constructor(title: string, seed: string, onSubmit: (value: string) => void, onCancel: () => void);
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* The mount's resolution. A plain string is the typed prefix; CANCELLED is
|
|
28
|
+
* the user's Esc — DISTINCT from undefined, which pi's custom() resolves
|
|
29
|
+
* with when the harness has no custom mount (a bare `return undefined`
|
|
30
|
+
* path in rpc/custom-less hosts). Conflating the two made cancel re-open
|
|
31
|
+
* the plain input dialog (the cancel-twice bug).
|
|
32
|
+
*/
|
|
33
|
+
export declare const CUSTOM_PREFIX_CANCELLED: unique symbol;
|
|
34
|
+
export type CustomPrefixResolution = string | typeof CUSTOM_PREFIX_CANCELLED | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* The PRODUCTION submit mapping (shared, not duplicated): pi's component
|
|
37
|
+
* submits the raw field; this wrapper trims and maps an emptied field to
|
|
38
|
+
* the cancel sentinel (Claude's allowEmptySubmitToCancel). Exported so the
|
|
39
|
+
* gate's askCustomPrefix mount and the live-mount test install the SAME
|
|
40
|
+
* function — a production regression breaks both, per the review round.
|
|
41
|
+
*/
|
|
42
|
+
export declare function mapCustomPrefixSubmit(value: string): string | typeof CUSTOM_PREFIX_CANCELLED;
|
|
43
|
+
type CustomMount = <T>(factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (r: T) => void) => Component) => Promise<T>;
|
|
44
|
+
interface AskCustomPrefixCtx {
|
|
45
|
+
mode?: string;
|
|
46
|
+
hasUI?: boolean;
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
ui: {
|
|
49
|
+
input: (title: string, placeholder?: string, opts?: {
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
}) => Promise<string | undefined>;
|
|
52
|
+
custom?: CustomMount;
|
|
53
|
+
setWorkingVisible?: (visible: boolean) => void;
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export declare function askCustomPrefix(ctx: AskCustomPrefixCtx, title: string, seed: string): Promise<CustomPrefixResolution>;
|
|
57
|
+
export {};
|
|
58
|
+
//# sourceMappingURL=prefixInput.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The custom-prefix field (the escape consent flow's editable input):
|
|
3
|
+
* pi's OWN shipped ExtensionInputComponent with exactly one delta — the
|
|
4
|
+
* input is pre-filled with the derived seed (Claude's initialValue; pi's
|
|
5
|
+
* stock component drops its placeholder, so the old `ctx.ui.input` dialog
|
|
6
|
+
* arrived empty and unlabeled while the turn's working indicator kept
|
|
7
|
+
* animating — a blocked turn cosplaying as a live one, the ticket's
|
|
8
|
+
* "the model went back to work" symptom).
|
|
9
|
+
*
|
|
10
|
+
* The gate's askCustomPrefix seam: TUI mounts the seeded surface via
|
|
11
|
+
* ctx.ui.custom; every other mode (RPC, desktop, headless harnesses)
|
|
12
|
+
* falls back to plain ctx.ui.input, where the corrected seed rides the
|
|
13
|
+
* placeholder (desktop's DialogCard renders it). ctx.mode is a hard
|
|
14
|
+
* guard — pi's RPC custom() RESOLVES undefined instantly (never throws),
|
|
15
|
+
* so an unguarded call would read as a cancelled input. A THROWN or
|
|
16
|
+
* no-mount custom() falls back to the plain input dialog WITH a
|
|
17
|
+
* gate_dialog_error trail line (error class only) — a broken mount must
|
|
18
|
+
* degrade visibly, not silently.
|
|
19
|
+
*/
|
|
20
|
+
import { ExtensionInputComponent } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import { logEvent } from "../errorSink.js";
|
|
22
|
+
/** pi's input dialog, seeded. Subclass — not a reimplementation. */
|
|
23
|
+
export class SeededExtensionInput extends ExtensionInputComponent {
|
|
24
|
+
constructor(title, seed, onSubmit, onCancel) {
|
|
25
|
+
super(title, undefined, onSubmit, onCancel, undefined);
|
|
26
|
+
// The seed is the EDITABLE INITIAL VALUE (Claude's initialValue), not a
|
|
27
|
+
// placeholder: the user narrows from a real suggestion. The only delta
|
|
28
|
+
// from pi's component.
|
|
29
|
+
this.input.setValue(seed);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The mount's resolution. A plain string is the typed prefix; CANCELLED is
|
|
34
|
+
* the user's Esc — DISTINCT from undefined, which pi's custom() resolves
|
|
35
|
+
* with when the harness has no custom mount (a bare `return undefined`
|
|
36
|
+
* path in rpc/custom-less hosts). Conflating the two made cancel re-open
|
|
37
|
+
* the plain input dialog (the cancel-twice bug).
|
|
38
|
+
*/
|
|
39
|
+
export const CUSTOM_PREFIX_CANCELLED = Symbol("custom-prefix-cancelled");
|
|
40
|
+
/**
|
|
41
|
+
* The PRODUCTION submit mapping (shared, not duplicated): pi's component
|
|
42
|
+
* submits the raw field; this wrapper trims and maps an emptied field to
|
|
43
|
+
* the cancel sentinel (Claude's allowEmptySubmitToCancel). Exported so the
|
|
44
|
+
* gate's askCustomPrefix mount and the live-mount test install the SAME
|
|
45
|
+
* function — a production regression breaks both, per the review round.
|
|
46
|
+
*/
|
|
47
|
+
export function mapCustomPrefixSubmit(value) {
|
|
48
|
+
const trimmed = value.trim();
|
|
49
|
+
return trimmed.length > 0 ? trimmed : CUSTOM_PREFIX_CANCELLED;
|
|
50
|
+
}
|
|
51
|
+
export async function askCustomPrefix(ctx, title, seed) {
|
|
52
|
+
const fallbackInput = () => ctx.ui.input(title, seed, { ...(ctx.signal ? { signal: ctx.signal } : {}) });
|
|
53
|
+
// A dialog waiting on the human must not animate a working spinner —
|
|
54
|
+
// the ticket's "turn resumed" illusion. Suppress while the dialog is
|
|
55
|
+
// up; restore ONLY if the suppression call actually exists and ran
|
|
56
|
+
// (a host without the API — RPC — has no spinner to restore). Fail-soft
|
|
57
|
+
// both ways: UI calls never gate the consent flow.
|
|
58
|
+
let spinnerSuppressed = false;
|
|
59
|
+
try {
|
|
60
|
+
ctx.ui.setWorkingVisible?.(false);
|
|
61
|
+
spinnerSuppressed = true;
|
|
62
|
+
}
|
|
63
|
+
catch { /* UI must never gate */ }
|
|
64
|
+
try {
|
|
65
|
+
if (ctx.mode === "tui" && ctx.hasUI && typeof ctx.ui.custom === "function") {
|
|
66
|
+
try {
|
|
67
|
+
const result = await ctx.ui.custom((_tui, _theme, _kb, done) => {
|
|
68
|
+
return new SeededExtensionInput(title, seed, (value) => done(mapCustomPrefixSubmit(value)), () => done(CUSTOM_PREFIX_CANCELLED));
|
|
69
|
+
});
|
|
70
|
+
// undefined = the harness has NO custom mount (pi resolves
|
|
71
|
+
// custom() with undefined there) — fall back to the plain input.
|
|
72
|
+
// A real cancel arrives as the sentinel, never undefined.
|
|
73
|
+
if (result !== undefined)
|
|
74
|
+
return result;
|
|
75
|
+
return await fallbackInput();
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
// A THROWN custom() is a broken mount degrading every escape ask —
|
|
79
|
+
// never silent. Error class only (never the thrown message; it can
|
|
80
|
+
// carry dialog-layer content), mirroring the gate's gate_dialog_error.
|
|
81
|
+
logEvent({
|
|
82
|
+
source: "permission-rules",
|
|
83
|
+
level: "warn",
|
|
84
|
+
event: "gate_dialog_error",
|
|
85
|
+
fields: { error: err instanceof Error ? err.constructor.name : typeof err, phase: "custom-mount" },
|
|
86
|
+
});
|
|
87
|
+
return await fallbackInput();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return await fallbackInput();
|
|
91
|
+
}
|
|
92
|
+
finally {
|
|
93
|
+
if (spinnerSuppressed) {
|
|
94
|
+
try {
|
|
95
|
+
ctx.ui.setWorkingVisible?.(true);
|
|
96
|
+
}
|
|
97
|
+
catch { /* UI must never gate */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=prefixInput.js.map
|
|
@@ -118,8 +118,10 @@ export function makeBashComposition(manager, settings, cwd, onShellResolutionRet
|
|
|
118
118
|
const heredocNote = "Caveat: heredocs (<<EOF) write temp files relative to the current directory on macOS bash 3.2 — from an unwritable cwd they fail; prefer printf or redirect from a file in the working directory.";
|
|
119
119
|
// $TMPDIR guidance (Claude Code parity, always-on): the sandbox sets
|
|
120
120
|
// TMPDIR to the per-user sandbox-writable temp dir; /tmp itself is NOT
|
|
121
|
-
// writable.
|
|
122
|
-
|
|
121
|
+
// writable. When the system prompt names a scratchpad directory, that
|
|
122
|
+
// directory takes priority for temporary files — stated here so the two
|
|
123
|
+
// guidance lines never read as mutually exclusive always-rules.
|
|
124
|
+
const tmpdirNote = "For temporary files, use the $TMPDIR environment variable unless your system prompt names a scratchpad directory — the scratchpad takes priority for temporary files. TMPDIR is automatically set to the correct sandbox-writable directory in sandbox mode. Do NOT use /tmp directly - use $TMPDIR instead.";
|
|
123
125
|
const wrapped = {
|
|
124
126
|
...def,
|
|
125
127
|
description: `${def.description}\n\n## Command sandbox\nCommands run inside an OS sandbox: ${restrictions.join("; ")}. ${strictNote}\n${heredocNote}\n${tmpdirNote}`,
|
|
@@ -48,6 +48,18 @@ export declare function scratchpadDir(opts?: {
|
|
|
48
48
|
uid?: number;
|
|
49
49
|
tmp?: string;
|
|
50
50
|
}): string | null;
|
|
51
|
+
/**
|
|
52
|
+
* PURE: the per-user scratchpad OWNER ROOT, recomputed from the SAME inputs
|
|
53
|
+
* `scratchpadDir` resolves from (never derived by stripping segments off a
|
|
54
|
+
* built path — a layout change would silently shift a stripped spelling).
|
|
55
|
+
* The value the sandbox forces TMPDIR onto (Claude parity): per-user, shared
|
|
56
|
+
* across sessions, and short enough for named unix-socket binds where the
|
|
57
|
+
* deep scratchpad path would trip macOS's 104-char sun_path limit.
|
|
58
|
+
*/
|
|
59
|
+
export declare function scratchpadOwnerRootFor(opts?: {
|
|
60
|
+
uid?: number;
|
|
61
|
+
tmp?: string;
|
|
62
|
+
}): string;
|
|
51
63
|
/**
|
|
52
64
|
* IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
|
|
53
65
|
* the path on success and null on failure — a null result means "no scratchpad
|
|
@@ -56,7 +68,9 @@ export declare function scratchpadDir(opts?: {
|
|
|
56
68
|
export declare function ensureScratchpadDir(path: string, mkdir?: (p: string, o: {
|
|
57
69
|
mode: number;
|
|
58
70
|
recursive: boolean;
|
|
59
|
-
}) => void
|
|
71
|
+
}) => void, opts?: {
|
|
72
|
+
onError?: (errorCode: string) => void;
|
|
73
|
+
}): string | null;
|
|
60
74
|
/**
|
|
61
75
|
* PURE: the prompt section naming the scratchpad. Gated by the caller on the
|
|
62
76
|
* dir existing; when present, it tells the agent where to put intermediate
|
|
@@ -61,17 +61,39 @@ export function scratchpadDir(opts = {}) {
|
|
|
61
61
|
const cwd = sanitizeCwdSegment(opts.cwd ?? ".");
|
|
62
62
|
return join(tmp, scratchpadOwnerDir(uid), cwd, sessionId, "scratchpad");
|
|
63
63
|
}
|
|
64
|
+
/**
|
|
65
|
+
* PURE: the per-user scratchpad OWNER ROOT, recomputed from the SAME inputs
|
|
66
|
+
* `scratchpadDir` resolves from (never derived by stripping segments off a
|
|
67
|
+
* built path — a layout change would silently shift a stripped spelling).
|
|
68
|
+
* The value the sandbox forces TMPDIR onto (Claude parity): per-user, shared
|
|
69
|
+
* across sessions, and short enough for named unix-socket binds where the
|
|
70
|
+
* deep scratchpad path would trip macOS's 104-char sun_path limit.
|
|
71
|
+
*/
|
|
72
|
+
export function scratchpadOwnerRootFor(opts = {}) {
|
|
73
|
+
const tmp = opts.tmp ?? tmpdir();
|
|
74
|
+
const uid = opts.uid ?? (typeof process.getuid === "function" ? process.getuid() ?? 0 : 0);
|
|
75
|
+
return join(tmp, scratchpadOwnerDir(uid));
|
|
76
|
+
}
|
|
64
77
|
/**
|
|
65
78
|
* IMPURE: ensure the scratchpad dir exists (owner-only), failing soft. Returns
|
|
66
79
|
* the path on success and null on failure — a null result means "no scratchpad
|
|
67
80
|
* this session", which the caller turns into an omitted prompt section.
|
|
68
81
|
*/
|
|
69
|
-
export function ensureScratchpadDir(path, mkdir = mkdirSync) {
|
|
82
|
+
export function ensureScratchpadDir(path, mkdir = mkdirSync, opts = {}) {
|
|
70
83
|
try {
|
|
71
84
|
mkdir(path, { recursive: true, mode: 0o700 });
|
|
72
85
|
return path;
|
|
73
86
|
}
|
|
74
|
-
catch {
|
|
87
|
+
catch (err) {
|
|
88
|
+
// The errno CODE (never the thrown message — the message can carry a
|
|
89
|
+
// path or system detail, and the caller forwards this to the sink).
|
|
90
|
+
// fs errors are plain Error instances whose distinguishing value is
|
|
91
|
+
// .code (ENOTDIR vs EACCES vs EPERM...); the constructor name would
|
|
92
|
+
// always read "Error" and distinguish nothing. The fallback keeps the
|
|
93
|
+
// class name for codeless throws (a non-fs throw), so the callback's
|
|
94
|
+
// value is "errno code, or class name when there is no code".
|
|
95
|
+
const code = err?.code;
|
|
96
|
+
opts.onError?.(code ?? (err instanceof Error ? err.constructor.name : typeof err));
|
|
75
97
|
return null;
|
|
76
98
|
}
|
|
77
99
|
}
|
|
@@ -82,12 +104,13 @@ export function ensureScratchpadDir(path, mkdir = mkdirSync) {
|
|
|
82
104
|
*/
|
|
83
105
|
export function scratchpadSection(dir) {
|
|
84
106
|
return ("# Scratchpad directory\n\n" +
|
|
85
|
-
`
|
|
107
|
+
`IMPORTANT: Always use this scratchpad directory for temporary files instead of /tmp or other system temp directories (including $TMPDIR in sandboxed bash):\n` +
|
|
86
108
|
`${dir}\n\n` +
|
|
109
|
+
"Use this directory for ALL temporary file needs:\n" +
|
|
87
110
|
"- Store intermediate results or data during multi-step tasks.\n" +
|
|
88
111
|
"- Write temporary scripts or configuration files.\n" +
|
|
89
112
|
"- Save outputs that don't belong in the user's project.\n" +
|
|
90
|
-
"-
|
|
113
|
+
"- Staging files for subagents: paths in this directory are readable by every subagent of this session, so hand subagents scratchpad paths rather than $TMPDIR or /tmp paths.\n\n" +
|
|
91
114
|
"The directory is session-specific and isolated from the user's project.");
|
|
92
115
|
}
|
|
93
116
|
//# sourceMappingURL=scratchpad.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yagni-app/code-staging",
|
|
3
|
-
"version": "1.1.4-staging.
|
|
3
|
+
"version": "1.1.4-staging.1420.1",
|
|
4
4
|
"description": "YAGNI Code: a terminal coding agent that already knows your company. One YAGNI login routes the model and grounds the agent in your team's context.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"author": "YAGNI, Inc. <jack@yagni.app> (https://yagni.app)",
|
|
@@ -58,5 +58,5 @@
|
|
|
58
58
|
"turndown": "^7.2.4",
|
|
59
59
|
"typebox": "^1.3.15"
|
|
60
60
|
},
|
|
61
|
-
"yagniSourceSha": "
|
|
61
|
+
"yagniSourceSha": "651ed1d5bda0a76c0ce1257e0dc1a47769e2fe5c"
|
|
62
62
|
}
|