@plannotator/pi-extension 0.28.50 → 0.28.51
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/generated/config.ts +33 -1
- package/generated/diff-files.ts +13 -0
- package/generated/guide-viewer-manifest.ts +4 -4
- package/generated/inbox/agent-link.ts +31 -16
- package/generated/inbox/connection.ts +26 -0
- package/generated/open-in-apps.ts +10 -0
- package/generated/question-block.ts +11 -2
- package/generated/review-core.ts +32 -19
- package/generated/review-skill-loader.ts +23 -2
- package/generated/shape/core.ts +190 -74
- package/generated/shape/routes.ts +2 -2
- package/generated/shape/store.ts +147 -48
- package/package.json +1 -1
- package/plannotator.html +335 -335
- package/release.json +1 -1
- package/review-editor.html +451 -441
package/generated/config.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { childEnv } from "./child-env.ts";
|
|
10
|
-
import { join } from "path";
|
|
10
|
+
import { isAbsolute, join, resolve } from "path";
|
|
11
11
|
import { getPlannotatorDataDir } from "./data-dir.ts";
|
|
12
12
|
import {
|
|
13
13
|
readFileSync,
|
|
@@ -559,6 +559,20 @@ export interface PlannotatorConfig {
|
|
|
559
559
|
* a tagged machine, which has no owner. Config only. Default: none.
|
|
560
560
|
*/
|
|
561
561
|
inboxTailscaleAllow?: string[];
|
|
562
|
+
/**
|
|
563
|
+
* Launch agents from the Inbox (compose's "New Claude Code session";
|
|
564
|
+
* packages/server/inbox-launch.ts). `enabled`: config only, default false,
|
|
565
|
+
* off, compose, the thread and every route are as before.
|
|
566
|
+
* `fullPermissions`: Settings → Agents' "Start new sessions with full
|
|
567
|
+
* permissions" (bypass mode); never set, a launch follows the person's
|
|
568
|
+
* login shell (CLAUDE_CODE_EVEN_MORE_DANGEROUSLY_SKIP_PERMISSIONS). `model`: config only, a model name such as
|
|
569
|
+
* `haiku` for each launch's settings file (the manual smoke); anything that
|
|
570
|
+
* is not a plain model name is ignored. `scratchDir`: Settings → Agents'
|
|
571
|
+
* "Scratch folders", where "New scratch folder" makes each new folder (an
|
|
572
|
+
* absolute path; default `<dataDir>/scratch`). Moving it applies to new
|
|
573
|
+
* folders only.
|
|
574
|
+
*/
|
|
575
|
+
inboxLaunch?: { enabled?: boolean; fullPermissions?: boolean; model?: string; scratchDir?: string };
|
|
562
576
|
/**
|
|
563
577
|
* Inject a Plannotator Flavored Markdown reminder into every EnterPlanMode
|
|
564
578
|
* call so the agent is aware it can enrich plans with code-file links,
|
|
@@ -1218,6 +1232,24 @@ export function resolveDefaultDiffTypeFor(
|
|
|
1218
1232
|
return v === 'jj-current' || v === 'jj-last' || v === 'jj-line' || v === 'jj-evolog' || v === 'jj-all' ? v : 'jj-current';
|
|
1219
1233
|
}
|
|
1220
1234
|
|
|
1235
|
+
/** A model name as Claude Code takes it (`haiku`, `claude-opus-4-1`, `opus[1m]`): letters, digits, `.`, `_`, `-`, `[`, `]`. */
|
|
1236
|
+
const LAUNCH_MODEL = /^[\w.\-[\]]{1,64}$/;
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* config.inboxLaunch, coerced: off unless `enabled` is true; `model` only when it is a plain model name;
|
|
1240
|
+
* `scratchDir` only when it is an absolute path other than `/` (null: the default, `<dataDir>/scratch`).
|
|
1241
|
+
*/
|
|
1242
|
+
export function resolveInboxLaunch(config: PlannotatorConfig): { enabled: boolean; fullPermissions: boolean | null; model: string | null; scratchDir: string | null } {
|
|
1243
|
+
const value = config.inboxLaunch && typeof config.inboxLaunch === "object" ? config.inboxLaunch : {};
|
|
1244
|
+
return {
|
|
1245
|
+
enabled: coerceConfigBoolean(value.enabled, false),
|
|
1246
|
+
// null: the person never set the switch (a launch follows their login shell).
|
|
1247
|
+
fullPermissions: parseConfigBoolean(value.fullPermissions) ?? null,
|
|
1248
|
+
model: typeof value.model === "string" && LAUNCH_MODEL.test(value.model) ? value.model : null,
|
|
1249
|
+
scratchDir: typeof value.scratchDir === "string" && isAbsolute(value.scratchDir) && resolve(value.scratchDir) !== resolve("/") ? resolve(value.scratchDir) : null,
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1221
1253
|
/**
|
|
1222
1254
|
* Coerce a config.json value that should be a boolean. JSON parsing preserves
|
|
1223
1255
|
* whatever type the user typed, so a hand-edited `"false"` (quoted) arrives as
|
package/generated/diff-files.ts
CHANGED
|
@@ -113,6 +113,19 @@ export function parseDiffToFiles(rawPatch: string): DiffFile[] {
|
|
|
113
113
|
return files;
|
|
114
114
|
}
|
|
115
115
|
|
|
116
|
+
/** The two blobs a file's patch runs between (its `index` line). */
|
|
117
|
+
export interface PatchBlobIds {
|
|
118
|
+
oldId: string;
|
|
119
|
+
newId: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A file's `index` blob ids; null when there is no such line (GitLab's rebuilt patches) or a side does not exist (all zeros). */
|
|
123
|
+
export function patchBlobIds(patch: string): PatchBlobIds | null {
|
|
124
|
+
const ids = /^index ([0-9a-f]{7,64})\.\.([0-9a-f]{7,64})/m.exec(patch);
|
|
125
|
+
if (!ids || /^0+$/.test(ids[1]!) || /^0+$/.test(ids[2]!)) return null;
|
|
126
|
+
return { oldId: ids[1]!, newId: ids[2]! };
|
|
127
|
+
}
|
|
128
|
+
|
|
116
129
|
/** One line of a hunk, numbered on each side it exists on. `text` drops the ` `/`+`/`-` prefix. */
|
|
117
130
|
export type DiffHunkLine =
|
|
118
131
|
| { kind: "context"; oldLine: number; newLine: number; text: string }
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
import type { GuideViewerAssets } from "./guide-format.ts";
|
|
7
7
|
|
|
8
8
|
export const GUIDE_VIEWER_MANIFEST: Omit<GuideViewerAssets, "baseUrl"> = {
|
|
9
|
-
js: "viewer.
|
|
10
|
-
css: "viewer.
|
|
11
|
-
jsIntegrity: "sha384-
|
|
12
|
-
cssIntegrity: "sha384-
|
|
9
|
+
js: "viewer.Bh38x7eb.js",
|
|
10
|
+
css: "viewer.DQlAZIQO.css",
|
|
11
|
+
jsIntegrity: "sha384-kTcI5/tmk5lJy6WIKj+Q8x8Revf/kzEzg/GObKZdig0kdL2tV/wbGyy1KaNm1aG5",
|
|
12
|
+
cssIntegrity: "sha384-GCY67iUks9B4xUXsjYkdSroZmJDmLMOtQFnvhshUgakVdrt9dCHOGgKaEw2UNxE7",
|
|
13
13
|
langs: {
|
|
14
14
|
"astro": "chunks/astro.Ts5EKq2l.js",
|
|
15
15
|
"c": "chunks/c.BIGW1oBm.js",
|
|
@@ -42,6 +42,8 @@ import {
|
|
|
42
42
|
INBOX_BRIDGE_EVENT_PATH,
|
|
43
43
|
INBOX_BRIDGE_POLL_PATH,
|
|
44
44
|
INBOX_SEND_NOW_POLL_FIELD,
|
|
45
|
+
INBOX_STEER_POLLS_POLL_FIELD,
|
|
46
|
+
inboxClaimName,
|
|
45
47
|
INBOX_TAGS_POLL_FIELD,
|
|
46
48
|
INBOX_TOOL_NAME,
|
|
47
49
|
inboxHealthPeeks,
|
|
@@ -94,13 +96,12 @@ function rememberedToolsFileOf(dataDir: string): string {
|
|
|
94
96
|
}
|
|
95
97
|
|
|
96
98
|
/**
|
|
97
|
-
* The claim folder of one delivery. A reply
|
|
98
|
-
* sessions,
|
|
99
|
-
*
|
|
99
|
+
* The claim folder of one delivery. A reply can go to several sessions, which may share this data dir (two Pi
|
|
100
|
+
* sessions, Pi and OpenCode): a reply that tags agents, or one moved after its session stopped holding the claim. So
|
|
101
|
+
* the claim names the session too, and each is delivered once.
|
|
100
102
|
*/
|
|
101
|
-
function claimDirOf(dataDir: string, replyId: string,
|
|
102
|
-
|
|
103
|
-
return join(dataDir, "inbox", "claims", name);
|
|
103
|
+
function claimDirOf(dataDir: string, replyId: string, session: string): string {
|
|
104
|
+
return join(dataDir, "inbox", "claims", inboxClaimName(replyId, session));
|
|
104
105
|
}
|
|
105
106
|
|
|
106
107
|
/** One session's lease on one host: which of its processes polls and delivers. */
|
|
@@ -659,11 +660,13 @@ export class InboxWake {
|
|
|
659
660
|
(this.steerable || busy === false);
|
|
660
661
|
if (!this.delivering && this.pending.size > 0 && (this.idleTicks >= 2 || urgent)) {
|
|
661
662
|
this.delivering = true;
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
663
|
+
// Not awaited: a steer resolves only when the message enters the run, which a long tool call can hold for
|
|
664
|
+
// minutes, and the tick has to go on polling meanwhile, or the session reads as ended (the Claude Code mod's way).
|
|
665
|
+
void this.deliverNext(this.idleTicks < 2 && urgent)
|
|
666
|
+
.catch(() => undefined)
|
|
667
|
+
.finally(() => {
|
|
668
|
+
this.delivering = false;
|
|
669
|
+
});
|
|
667
670
|
}
|
|
668
671
|
}
|
|
669
672
|
|
|
@@ -733,6 +736,7 @@ export class InboxWake {
|
|
|
733
736
|
started_at: this.startedAt,
|
|
734
737
|
...(this.busy === null ? {} : { busy: this.busy, idle_since: this.idleSince }),
|
|
735
738
|
...(this.steerable ? { [INBOX_SEND_NOW_POLL_FIELD]: true } : {}),
|
|
739
|
+
[INBOX_STEER_POLLS_POLL_FIELD]: true,
|
|
736
740
|
// Tags (approval "inbox-tag-agents"): this connection words a reply it was tagged in truthfully.
|
|
737
741
|
[INBOX_TAGS_POLL_FIELD]: true,
|
|
738
742
|
// What this process loaded (additive): the Inbox says when the session runs an older one.
|
|
@@ -757,6 +761,9 @@ export class InboxWake {
|
|
|
757
761
|
}
|
|
758
762
|
this.backoffMs = 0;
|
|
759
763
|
this.retryAt = 0;
|
|
764
|
+
// What the Inbox stopped handing out is done with here: handed out again (the reply moved back), it is new.
|
|
765
|
+
const handedOut = new Set(commands.map((command) => command.id));
|
|
766
|
+
for (const id of this.settled) if (!handedOut.has(id)) this.settled.delete(id);
|
|
760
767
|
for (const command of commands) {
|
|
761
768
|
// A later poll may carry `now` (the person pressed Send now): the newest command wins.
|
|
762
769
|
if (!this.settled.has(command.id)) this.pending.set(command.id, command);
|
|
@@ -777,14 +784,17 @@ export class InboxWake {
|
|
|
777
784
|
* running turn when one runs (`steer`), else as a turn at once.
|
|
778
785
|
*/
|
|
779
786
|
private async deliverNext(urgent = false): Promise<void> {
|
|
780
|
-
const
|
|
781
|
-
if (!
|
|
787
|
+
const waiting = this.pending.values().next().value as InboxReplyCommand | undefined;
|
|
788
|
+
if (!waiting) return;
|
|
782
789
|
// Still waiting? An agent may have read it through the tool meanwhile, or
|
|
783
790
|
// another process delivered it.
|
|
784
791
|
const fresh = await this.pendingNow(0);
|
|
785
792
|
if (fresh === null || this.disposed) return;
|
|
786
|
-
|
|
787
|
-
|
|
793
|
+
// What the Inbox says now is what goes in: a reply can have moved here since it was first handed out.
|
|
794
|
+
const command = fresh.find((candidate) => candidate.id === waiting.id);
|
|
795
|
+
if (!command) {
|
|
796
|
+
// Not settled: nothing went in here, so if the reply moves back before the next poll, it goes in.
|
|
797
|
+
this.pending.delete(waiting.id);
|
|
788
798
|
return;
|
|
789
799
|
}
|
|
790
800
|
const busy = await this.target.isBusy();
|
|
@@ -795,7 +805,12 @@ export class InboxWake {
|
|
|
795
805
|
}
|
|
796
806
|
// The person may have moved to another process since the last tick.
|
|
797
807
|
if (this.disposed || !this.holdLease(Date.now())) return;
|
|
798
|
-
|
|
808
|
+
// An older version's claim, by the reply id alone: that process took it (the Inbox removes it when it moves the reply).
|
|
809
|
+
if (!command.tagged && existsSync(join(this.connection.dataDir, "inbox", "claims", command.id))) {
|
|
810
|
+
this.settle(command.id);
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
const claim = claimDirOf(this.connection.dataDir, command.id, this.sessionId);
|
|
799
814
|
try {
|
|
800
815
|
mkdirSync(join(claim, ".."), { recursive: true, mode: 0o700 });
|
|
801
816
|
mkdirSync(claim, { mode: 0o700 });
|
|
@@ -31,6 +31,25 @@
|
|
|
31
31
|
* paste). The server imports the path constants from here.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
/** The folder name of one session's claim on one delivery (`inbox/claims/`): a moved reply is a new delivery for the next session. */
|
|
35
|
+
export function inboxClaimName(replyId: string, session: string): string {
|
|
36
|
+
return `${replyId}.${session.replace(/[^A-Za-z0-9._-]/g, "_").slice(0, 120)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The stop notice of a thread handed to another session goes by its own id, not the reply's. A connection only keeps the
|
|
41
|
+
* id and reports it, so what any connection or wake keeps per id (settled, claims, a T3 request), and its report, can
|
|
42
|
+
* never stand for the person's words when the reply moves back to that session. Older connections included.
|
|
43
|
+
*/
|
|
44
|
+
export function inboxStopNoticeId(replyId: string): string {
|
|
45
|
+
return `${replyId}-stop`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The reply a stop notice's id belongs to; null for any other id. */
|
|
49
|
+
export function inboxStopNoticeReply(id: string): string | null {
|
|
50
|
+
return id.length > '-stop'.length && id.endsWith('-stop') ? id.slice(0, -'-stop'.length) : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
34
53
|
// --- CONTRACT (copied byte for byte into apps/hook/hooks/mod/inbox-contract.ts; edit packages/shared/inbox/connection.ts, then paste) ---
|
|
35
54
|
|
|
36
55
|
/** The agent tool every connection registers. */
|
|
@@ -1076,6 +1095,13 @@ function wakeName(value: unknown): string {
|
|
|
1076
1095
|
*/
|
|
1077
1096
|
export const INBOX_SEND_NOW_POLL_FIELD = 'send_now'
|
|
1078
1097
|
|
|
1098
|
+
/**
|
|
1099
|
+
* On a poll, the connection's tick goes on polling while a steer waits for the running turn. A connection without it
|
|
1100
|
+
* (from before that) stops polling until the steer goes in, so the Inbox counts a session it handed one as there.
|
|
1101
|
+
* An older Inbox ignores it.
|
|
1102
|
+
*/
|
|
1103
|
+
export const INBOX_STEER_POLLS_POLL_FIELD = 'steer_polls'
|
|
1104
|
+
|
|
1079
1105
|
/**
|
|
1080
1106
|
* The poll field (`true`) by which a connection says it can answer Ask AI
|
|
1081
1107
|
* live. An older connection never sends it, so a question never waits for it.
|
|
@@ -38,6 +38,12 @@ export interface OpenInApp {
|
|
|
38
38
|
win?: { bin: string };
|
|
39
39
|
/** Linux PATH binary. */
|
|
40
40
|
linux?: { bin: string };
|
|
41
|
+
/**
|
|
42
|
+
* How it opens a file at a line. `mac`: the app's URL scheme, `<scheme>://file/<path>:<line>`
|
|
43
|
+
* through `open -a`. `cli`: `--goto <path>:<line>` or `<path>:<line>` on its Windows and Linux
|
|
44
|
+
* binary. Absent: the file opens at its top.
|
|
45
|
+
*/
|
|
46
|
+
line?: { mac?: string; cli?: 'goto' | 'suffix' };
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -62,6 +68,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
|
|
|
62
68
|
mac: { appName: 'Visual Studio Code' },
|
|
63
69
|
win: { bin: 'code' },
|
|
64
70
|
linux: { bin: 'code' },
|
|
71
|
+
line: { mac: 'vscode', cli: 'goto' },
|
|
65
72
|
},
|
|
66
73
|
{
|
|
67
74
|
id: 'cursor',
|
|
@@ -71,6 +78,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
|
|
|
71
78
|
mac: { appName: 'Cursor' },
|
|
72
79
|
win: { bin: 'cursor' },
|
|
73
80
|
linux: { bin: 'cursor' },
|
|
81
|
+
line: { mac: 'cursor', cli: 'goto' },
|
|
74
82
|
},
|
|
75
83
|
{
|
|
76
84
|
id: 'zed',
|
|
@@ -80,6 +88,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
|
|
|
80
88
|
mac: { appName: 'Zed' },
|
|
81
89
|
win: { bin: 'zed' },
|
|
82
90
|
linux: { bin: 'zed' },
|
|
91
|
+
line: { cli: 'suffix' },
|
|
83
92
|
},
|
|
84
93
|
{
|
|
85
94
|
id: 'zed-preview',
|
|
@@ -99,6 +108,7 @@ export const OPEN_IN_APPS: OpenInApp[] = [
|
|
|
99
108
|
mac: { appName: 'Sublime Text' },
|
|
100
109
|
win: { bin: 'subl' },
|
|
101
110
|
linux: { bin: 'subl' },
|
|
111
|
+
line: { cli: 'suffix' },
|
|
102
112
|
},
|
|
103
113
|
{
|
|
104
114
|
id: 'textmate',
|
|
@@ -265,6 +265,11 @@ const CHOICE_IMAGE_RE = /!\[([^\]\n]*)\]\(([^)\n]+)\)/;
|
|
|
265
265
|
// What may follow a recommendation that names the start of a choice label
|
|
266
266
|
// (`Recommended: Unpin it` for `Unpin it: the toast says…`).
|
|
267
267
|
const LABEL_HEAD_SEP_RE = /^(?:\s*[:—–(]|\s+-\s)/;
|
|
268
|
+
// A recommendation that is only a label's leading token, a letter or a
|
|
269
|
+
// number (`Recommended: A` for `A · Yes`), names the label when the token is
|
|
270
|
+
// followed by a middle dot.
|
|
271
|
+
const LABEL_TOKEN_RE = /^(?:[a-z]|\d+)$/;
|
|
272
|
+
const LABEL_TOKEN_SEP_RE = /^ · /;
|
|
268
273
|
// At the start of the line (no indentation), so an indented line under a
|
|
269
274
|
// choice stays that choice's continuation.
|
|
270
275
|
const DECISION_RE = /^decision\s*:\s*(.*?)\s*$/i;
|
|
@@ -485,7 +490,9 @@ const QUESTION_KEY_RE = /^q-[0-9a-f]{8}(?:-\d{1,3})?$/;
|
|
|
485
490
|
* The one choice a recommendation names by the start of its label: the
|
|
486
491
|
* choice whose label (or any of its other names, such as a task list's full
|
|
487
492
|
* text) begins with the text and then a separator (`:` `—` `–` ` - ` `(`),
|
|
488
|
-
*
|
|
493
|
+
* or whose leading letter or number the text is, followed by ` · `
|
|
494
|
+
* (`Recommended: A` names `A · Yes`), compared case-insensitively with
|
|
495
|
+
* emphasis and spacing folded
|
|
489
496
|
* (`Recommended: Unpin it` names `Unpin it: the toast says…`). -1 when no
|
|
490
497
|
* choice or several do. `names` holds each choice's names, label first.
|
|
491
498
|
*/
|
|
@@ -498,7 +505,9 @@ export const recommendationHeadChoice = (
|
|
|
498
505
|
const begins = (value: string | undefined): boolean => {
|
|
499
506
|
if (!value) return false;
|
|
500
507
|
const folded = value.replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
501
|
-
|
|
508
|
+
if (!folded.startsWith(target)) return false;
|
|
509
|
+
const rest = folded.slice(target.length);
|
|
510
|
+
return LABEL_HEAD_SEP_RE.test(rest) || (LABEL_TOKEN_RE.test(target) && LABEL_TOKEN_SEP_RE.test(rest));
|
|
502
511
|
};
|
|
503
512
|
const hits = names.flatMap((entry, i) => ((typeof entry === 'string' ? [entry] : entry).some(begins) ? [i] : []));
|
|
504
513
|
return hits.length === 1 ? hits[0] : -1;
|
package/generated/review-core.ts
CHANGED
|
@@ -2520,6 +2520,37 @@ function isPathInside(root: string, child: string): boolean {
|
|
|
2520
2520
|
return c === r || c.startsWith(`${r}/`);
|
|
2521
2521
|
}
|
|
2522
2522
|
|
|
2523
|
+
/**
|
|
2524
|
+
* One blob's bytes by any spec git takes (`<rev>:<path>`, or a blob id, whole
|
|
2525
|
+
* or abbreviated), capped at `maxBytes`: the size is read first, and the blob
|
|
2526
|
+
* stream stops at the cap. The spec goes on stdin, never argv.
|
|
2527
|
+
*/
|
|
2528
|
+
export async function readGitObjectBytes(
|
|
2529
|
+
runtime: ReviewGitRuntime,
|
|
2530
|
+
spec: string,
|
|
2531
|
+
maxBytes: number,
|
|
2532
|
+
cwd?: string,
|
|
2533
|
+
): Promise<FileBytesRead> {
|
|
2534
|
+
if (!runtime.runGitBytes) return { kind: "unavailable" };
|
|
2535
|
+
if (/[\r\n]/.test(spec)) return { kind: "missing" };
|
|
2536
|
+
const check = await runtime.runGit(
|
|
2537
|
+
["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
|
|
2538
|
+
{ cwd, stdin: `${spec}\n` },
|
|
2539
|
+
);
|
|
2540
|
+
if (check.exitCode !== 0) return { kind: "missing" };
|
|
2541
|
+
const match = check.stdout.trim().match(/^([0-9a-f]{40,64}) (\S+) (\d+)$/);
|
|
2542
|
+
if (!match || match[2] !== "blob") return { kind: "missing" };
|
|
2543
|
+
const size = Number(match[3]);
|
|
2544
|
+
if (size > maxBytes) return { kind: "too-large", size };
|
|
2545
|
+
const blob = await runtime.runGitBytes(["cat-file", "blob", match[1]], {
|
|
2546
|
+
cwd,
|
|
2547
|
+
maxOutputBytes: maxBytes,
|
|
2548
|
+
});
|
|
2549
|
+
if (blob.truncated) return { kind: "too-large", size };
|
|
2550
|
+
if (blob.exitCode !== 0) return { kind: "missing" };
|
|
2551
|
+
return { kind: "ok", bytes: blob.stdout, etag: `"${match[1]}"` };
|
|
2552
|
+
}
|
|
2553
|
+
|
|
2523
2554
|
/**
|
|
2524
2555
|
* Read one side as raw bytes for the image preview, capped at `maxBytes`.
|
|
2525
2556
|
*
|
|
@@ -2540,25 +2571,7 @@ export async function readDiffSideBytes(
|
|
|
2540
2571
|
cwd?: string,
|
|
2541
2572
|
): Promise<FileBytesRead> {
|
|
2542
2573
|
if (source.kind === "object") {
|
|
2543
|
-
|
|
2544
|
-
const spec = `${source.rev}:${source.path}`;
|
|
2545
|
-
if (/[\r\n]/.test(spec)) return { kind: "missing" };
|
|
2546
|
-
const check = await runtime.runGit(
|
|
2547
|
-
["cat-file", "--batch-check=%(objectname) %(objecttype) %(objectsize)"],
|
|
2548
|
-
{ cwd, stdin: `${spec}\n` },
|
|
2549
|
-
);
|
|
2550
|
-
if (check.exitCode !== 0) return { kind: "missing" };
|
|
2551
|
-
const match = check.stdout.trim().match(/^([0-9a-f]{40,64}) (\S+) (\d+)$/);
|
|
2552
|
-
if (!match || match[2] !== "blob") return { kind: "missing" };
|
|
2553
|
-
const size = Number(match[3]);
|
|
2554
|
-
if (size > maxBytes) return { kind: "too-large", size };
|
|
2555
|
-
const blob = await runtime.runGitBytes(["cat-file", "blob", match[1]], {
|
|
2556
|
-
cwd,
|
|
2557
|
-
maxOutputBytes: maxBytes,
|
|
2558
|
-
});
|
|
2559
|
-
if (blob.truncated) return { kind: "too-large", size };
|
|
2560
|
-
if (blob.exitCode !== 0) return { kind: "missing" };
|
|
2561
|
-
return { kind: "ok", bytes: blob.stdout, etag: `"${match[1]}"` };
|
|
2574
|
+
return readGitObjectBytes(runtime, `${source.rev}:${source.path}`, maxBytes, cwd);
|
|
2562
2575
|
}
|
|
2563
2576
|
|
|
2564
2577
|
if (!runtime.readFileBytes || !runtime.realPath) return { kind: "unavailable" };
|
|
@@ -33,8 +33,8 @@ import {
|
|
|
33
33
|
statSync,
|
|
34
34
|
writeFileSync,
|
|
35
35
|
} from "node:fs";
|
|
36
|
-
import { homedir } from "node:os";
|
|
37
|
-
import { join } from "node:path";
|
|
36
|
+
import { homedir, tmpdir } from "node:os";
|
|
37
|
+
import { join, sep } from "node:path";
|
|
38
38
|
import { getPlannotatorDataDir } from "./data-dir.ts";
|
|
39
39
|
import {
|
|
40
40
|
BUILTIN_DEFAULT_PROFILE,
|
|
@@ -69,6 +69,26 @@ export interface DiscoveredSkill {
|
|
|
69
69
|
// Root resolution
|
|
70
70
|
// ---------------------------------------------------------------------------
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* True when `dir` (already resolved) sits inside a temp folder. A test run
|
|
74
|
+
* (PLANNOTATOR_TEST_SANDBOX) reads only such folders, so a test that left HOME
|
|
75
|
+
* or CLAUDE_CONFIG_DIR alone never reads the person's real skills, while a test
|
|
76
|
+
* that seeds skills under a scratch home still finds them. Like
|
|
77
|
+
* `agentHomeAllowed` in inbox-agents.ts, which this file cannot import: it is
|
|
78
|
+
* vendored to Pi.
|
|
79
|
+
*/
|
|
80
|
+
function isScratchDir(dir: string): boolean {
|
|
81
|
+
return [tmpdir(), "/tmp", "/private/tmp"].some((root) => {
|
|
82
|
+
let base = root;
|
|
83
|
+
try {
|
|
84
|
+
base = realpathSync(root);
|
|
85
|
+
} catch {
|
|
86
|
+
// No such temp root here (/private/tmp off macOS); compare it as written.
|
|
87
|
+
}
|
|
88
|
+
return dir.startsWith(`${base.replace(/[\\/]+$/, "")}${sep}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
72
92
|
/**
|
|
73
93
|
* The ordered global skill roots, honoring env overrides. First-seen wins on a
|
|
74
94
|
* cross-root name clash, so order matters: Claude → Codex → universal.
|
|
@@ -110,6 +130,7 @@ export function resolveGlobalSkillRoots(): Array<{ dir: string; root: SkillRoot
|
|
|
110
130
|
// non-existent root still dedupes against an identical literal.
|
|
111
131
|
key = candidate.dir;
|
|
112
132
|
}
|
|
133
|
+
if (process.env.PLANNOTATOR_TEST_SANDBOX && !isScratchDir(key)) continue;
|
|
113
134
|
if (seen.has(key)) continue;
|
|
114
135
|
seen.add(key);
|
|
115
136
|
roots.push(candidate);
|