pi-session-mcp 0.14.1 → 0.14.2
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/.pi-session-mcp-cli/writer-lifecycle.js +83 -16
- package/.pi-session-mcp-cli/writer-models-store.js +64 -0
- package/.pi-session-mcp-cli/writer-tree-removal.js +75 -0
- package/CHANGELOG.md +56 -0
- package/README.md +1 -1
- package/dist/model-catalog.d.ts +18 -5
- package/dist/model-catalog.js +8 -5
- package/dist/sdk-pi-adapter.d.ts +5 -0
- package/dist/sdk-pi-adapter.js +10 -1
- package/dist/writer-launch.d.ts +2 -0
- package/dist/writer-launch.js +3 -0
- package/dist/writer-lifecycle.d.ts +7 -0
- package/dist/writer-lifecycle.js +83 -16
- package/dist/writer-models-store.d.ts +16 -0
- package/dist/writer-models-store.js +64 -0
- package/dist/writer-tree-removal.d.ts +16 -0
- package/dist/writer-tree-removal.js +75 -0
- package/docs/acceptance.md +125 -0
- package/docs/doctor-design.md +1 -1
- package/docs/execution-modes.md +45 -11
- package/docs/installation.md +9 -0
- package/docs/isolation-setup.md +11 -7
- package/docs/roadmap.md +12 -0
- package/docs/sprints/2026-10-29-s12-audit-fixes.md +3 -1
- package/docs/sprints/2026-10-30-s13-index-only-work.md +199 -0
- package/docs/tool-contracts.md +1 -1
- package/package.json +1 -1
- package/skills/pi-session-orchestration/SKILL.md +1 -1
- package/skills/pi-session-profiles/SKILL.md +5 -1
package/dist/writer-lifecycle.js
CHANGED
|
@@ -3,12 +3,15 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { chmod, mkdir, open, readdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, join } from "node:path";
|
|
5
5
|
import { writerLimits } from "./writer-limits.js";
|
|
6
|
+
import { removeWriterTree } from "./writer-tree-removal.js";
|
|
7
|
+
import { copyModelsStore, hostModelsStorePath } from "./writer-models-store.js";
|
|
6
8
|
import { configSchema } from "./config.js";
|
|
7
9
|
import { renderWrapper } from "./writer-wrapper.js";
|
|
8
10
|
import { builtinSkillName } from "./skill-catalog.js";
|
|
9
11
|
import { resolveLibrarySkill, shippedLibrarySkill } from "./skill-library.js";
|
|
10
12
|
import { BOUNDARY_GIT, BoundaryError, IMAGE_PULL_TIMEOUT_MS, isSafeRefName, podmanInfo, resolveImage, throwawayArgs, verifyCloneLayout } from "./writer-boundary.js";
|
|
11
13
|
import { readWriterState, writerName, writerPaths, writeWriterState, WriterStateError } from "./writer-state.js";
|
|
14
|
+
const modelsStoreSource = (ctx) => ctx.modelsStorePath ?? hostModelsStorePath(ctx.home);
|
|
12
15
|
function leasedContext(ctx) {
|
|
13
16
|
if (ctx.leaseFd === undefined)
|
|
14
17
|
return ctx;
|
|
@@ -292,7 +295,7 @@ export async function provisionWriter(ctx, input) {
|
|
|
292
295
|
}
|
|
293
296
|
}
|
|
294
297
|
if (restart || existing?.phase === "provisioning")
|
|
295
|
-
await
|
|
298
|
+
await removeWriterTree(paths.root);
|
|
296
299
|
// With spool delivery the spool directory exists from provisioning on: launch would otherwise add it to the
|
|
297
300
|
// writer root, a host directory the acceptance harness digests around its own first launch.
|
|
298
301
|
const spool = config.events?.delivery === "spool" ? [paths.events] : [];
|
|
@@ -312,6 +315,7 @@ export async function provisionWriter(ctx, input) {
|
|
|
312
315
|
await jsonFile(join(paths.config, "execution-modes.json"), { schemaVersion: 1, workspaces: { [ctx.alias]: { mode: "host", acceptHostWrites: true } } });
|
|
313
316
|
await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: commit });
|
|
314
317
|
await jsonFile(join(paths.agent, "auth.json"), {});
|
|
318
|
+
await copyModelsStore(modelsStoreSource(ctx), paths.agent);
|
|
315
319
|
await writeFile(paths.wrapper, renderWrapper(ctx.nodePath, ctx.releaseRoot, name), { mode: 0o700, flag: "wx" });
|
|
316
320
|
await writeWriterState(ctx.home, name, { ...state, phase: "not_ready" });
|
|
317
321
|
return { code: "writer_provisioned", status: "ok" };
|
|
@@ -320,6 +324,21 @@ export async function provisionWriter(ctx, input) {
|
|
|
320
324
|
return failure(error);
|
|
321
325
|
}
|
|
322
326
|
}
|
|
327
|
+
/**
|
|
328
|
+
* Distinct object names among `objects` that the operator checkout does not have. Objects that an operator ref
|
|
329
|
+
* names need no further call; each remaining one costs one `cat-file`, since the runner has no stdin for a batch.
|
|
330
|
+
*/
|
|
331
|
+
async function missingObjects(ctx, objects) {
|
|
332
|
+
if (objects.length === 0)
|
|
333
|
+
return [];
|
|
334
|
+
const known = new Set((await git(ctx, ["for-each-ref", "--format=%(objectname)"], true))?.split("\n"));
|
|
335
|
+
const missing = [];
|
|
336
|
+
for (const object of new Set(objects)) {
|
|
337
|
+
if (!known.has(object) && await git(ctx, ["cat-file", "-e", object], true) === undefined)
|
|
338
|
+
missing.push(object);
|
|
339
|
+
}
|
|
340
|
+
return missing;
|
|
341
|
+
}
|
|
323
342
|
export async function inspectWork(ctx, name) {
|
|
324
343
|
ctx = leasedContext(ctx);
|
|
325
344
|
const state = await readWriterState(ctx.home, name);
|
|
@@ -327,25 +346,40 @@ export async function inspectWork(ctx, name) {
|
|
|
327
346
|
throw new LifecycleError("writer_state_invalid");
|
|
328
347
|
const paths = writerPaths(ctx.home, name);
|
|
329
348
|
await verifyCloneLayout(paths.clone);
|
|
330
|
-
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\ncp .git/index /tmp/index\nmkdir /tmp/objects\nexport GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/objects GIT_ALTERNATE_OBJECT_DIRECTORIES="$1/.git/objects"\n${BOUNDARY_GIT} add -A\n${BOUNDARY_GIT} rev-parse --verify HEAD\n${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)' refs/
|
|
349
|
+
const script = `set -eu\nexport GIT_CONFIG_NOSYSTEM=1\ncp .git/index /tmp/index\ncp .git/index /tmp/staged-index\nmkdir /tmp/objects\nexport GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/objects GIT_ALTERNATE_OBJECT_DIRECTORIES="$1/.git/objects"\nstaged=$(GIT_INDEX_FILE=/tmp/staged-index ${BOUNDARY_GIT} write-tree)\n${BOUNDARY_GIT} add -A\n${BOUNDARY_GIT} rev-parse --verify HEAD\n${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)'\nif stash=$(${BOUNDARY_GIT} rev-parse -q --verify refs/stash); then\n log=$(${BOUNDARY_GIT} rev-list --walk-reflogs refs/stash)\n printf 'stash %s\\n' "$stash" $log\nfi\n${BOUNDARY_GIT} write-tree\nprintf '%s\\n' "$staged"\n${BOUNDARY_GIT} rev-parse 'HEAD^{tree}'\n${BOUNDARY_GIT} rev-parse "$2^{tree}"`;
|
|
331
350
|
const result = await ctx.runner.run("podman", throwawayArgs(name, paths, state.imageId, { clone: "ro", bundles: false }, script, [paths.clone, state.base.commit]), AbortSignal.timeout(IMAGE_PULL_TIMEOUT_MS));
|
|
332
351
|
if (result.exitCode !== 0)
|
|
333
352
|
throw new LifecycleError("writer_inspect_failed");
|
|
334
353
|
const lines = result.stdout.trim().split("\n");
|
|
335
354
|
const hash = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
336
|
-
const head = lines.shift(), baseTree = lines.pop(), headTree = lines.pop(), tree = lines.pop();
|
|
337
|
-
if (![head, baseTree, headTree, tree].every((value) => value !== undefined && hash.test(value)))
|
|
355
|
+
const head = lines.shift(), baseTree = lines.pop(), headTree = lines.pop(), indexTree = lines.pop(), tree = lines.pop();
|
|
356
|
+
if (![head, baseTree, headTree, indexTree, tree].every((value) => value !== undefined && hash.test(value)))
|
|
338
357
|
throw new LifecycleError("writer_inspect_failed");
|
|
339
|
-
const refs =
|
|
358
|
+
const refs = [], stashes = [];
|
|
359
|
+
for (const line of lines) {
|
|
340
360
|
const [name, commit, extra] = line.split(" ");
|
|
341
|
-
if (!
|
|
361
|
+
if (!commit || !hash.test(commit) || extra !== undefined)
|
|
342
362
|
throw new LifecycleError("writer_inspect_failed");
|
|
343
|
-
|
|
344
|
-
|
|
363
|
+
if (name === "stash")
|
|
364
|
+
stashes.push(commit);
|
|
365
|
+
// Only branch names reach a command (as export names); other refs such as a tag `pkg@1.0.0+build` are
|
|
366
|
+
// only compared, so any ref name Git accepts must not make inspection fail.
|
|
367
|
+
else if (name && (name.startsWith("refs/heads/") ? isSafeRefName(name) : name.startsWith("refs/")))
|
|
368
|
+
refs.push({ name, commit });
|
|
369
|
+
else
|
|
370
|
+
throw new LifecycleError("writer_inspect_failed");
|
|
371
|
+
}
|
|
372
|
+
const branches = refs.filter(({ name }) => name.startsWith("refs/heads/"));
|
|
373
|
+
// Refs outside branches and the stash are not exported (#202): they must name objects the operator
|
|
374
|
+
// checkout already has, or the clone holds work that only it can recover.
|
|
375
|
+
const others = refs.filter(({ name }) => !name.startsWith("refs/heads/") && name !== "refs/stash");
|
|
376
|
+
const unknownRefs = (await missingObjects(ctx, others.map(({ commit }) => commit))).length;
|
|
345
377
|
return {
|
|
346
|
-
baseline: head === state.base.commit &&
|
|
347
|
-
|
|
348
|
-
|
|
378
|
+
baseline: head === state.base.commit && branches.length === 1 && branches[0].name === state.base.ref && branches[0].commit === state.base.commit
|
|
379
|
+
&& !refs.some(({ name }) => name === "refs/stash") && stashes.length === 0 && unknownRefs === 0 && tree === baseTree && indexTree === baseTree,
|
|
380
|
+
dirty: tree !== headTree || indexTree !== headTree, head: head, branches, stashes, unknownRefs,
|
|
381
|
+
indexTree: indexTree, tree: tree, headTree: headTree,
|
|
382
|
+
digest: `sha256:${createHash("sha256").update("inspect-v2\n").update(result.stdout).digest("hex")}`,
|
|
349
383
|
};
|
|
350
384
|
}
|
|
351
385
|
async function requiredState(ctx, name) {
|
|
@@ -379,6 +413,7 @@ async function refreshPrivateFiles(ctx, name, state) {
|
|
|
379
413
|
await rm(path, { force: true });
|
|
380
414
|
await jsonFile(join(paths.config, "writer-guard.json"), { schemaVersion: 1, workspace: ctx.alias, baseRef: state.base.ref, baseCommit: state.base.commit });
|
|
381
415
|
await jsonFile(join(paths.agent, "auth.json"), {});
|
|
416
|
+
await copyModelsStore(modelsStoreSource(ctx), paths.agent);
|
|
382
417
|
}
|
|
383
418
|
export async function collectWriter(ctx, name) {
|
|
384
419
|
ctx = leasedContext(ctx);
|
|
@@ -403,13 +438,37 @@ ${BOUNDARY_GIT} for-each-ref --format='%(refname) %(objectname)' refs/heads | wh
|
|
|
403
438
|
${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "$ref" "$commit"
|
|
404
439
|
fi
|
|
405
440
|
done
|
|
441
|
+
# Every stash entry, newest first as in stash@{n}, under a derived name (#202). The tip covers a missing reflog.
|
|
442
|
+
if stash=$(${BOUNDARY_GIT} rev-parse -q --verify refs/stash); then
|
|
443
|
+
log=$(${BOUNDARY_GIT} rev-list --walk-reflogs refs/stash)
|
|
444
|
+
n=0 previous=
|
|
445
|
+
for commit in "$stash" $log; do
|
|
446
|
+
if [ "$commit" = "$previous" ]; then continue; fi
|
|
447
|
+
previous=$commit
|
|
448
|
+
if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$4-stash-$n" \\
|
|
449
|
+
|| ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$4-stash-$n" "$commit" 2>/dev/null; then
|
|
450
|
+
printf '%s\\n' ref_name_refused
|
|
451
|
+
fi
|
|
452
|
+
n=$((n + 1))
|
|
453
|
+
done
|
|
454
|
+
fi
|
|
406
455
|
cp .git/index /tmp/index
|
|
456
|
+
cp .git/index /tmp/staged-index
|
|
407
457
|
export GIT_INDEX_FILE=/tmp/index GIT_OBJECT_DIRECTORY=/tmp/scratch/objects
|
|
458
|
+
staged=$(GIT_INDEX_FILE=/tmp/staged-index ${BOUNDARY_GIT} write-tree)
|
|
408
459
|
${BOUNDARY_GIT} add -A
|
|
409
460
|
tree=$(${BOUNDARY_GIT} write-tree)
|
|
461
|
+
export GIT_AUTHOR_NAME=pi-writer GIT_AUTHOR_EMAIL=pi-writer@example.invalid
|
|
462
|
+
export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
|
|
463
|
+
if [ "$staged" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ] && [ "$staged" != "$tree" ]; then
|
|
464
|
+
commit=$(printf '%s\\n' 'Index collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$staged" -p HEAD)
|
|
465
|
+
branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
|
|
466
|
+
if ${BOUNDARY_GIT} --git-dir=/tmp/scratch show-ref --verify --quiet "refs/heads/$branch-index" \\
|
|
467
|
+
|| ! ${BOUNDARY_GIT} --git-dir=/tmp/scratch update-ref "refs/heads/$branch-index" "$commit" 2>/dev/null; then
|
|
468
|
+
printf '%s\\n' ref_name_refused
|
|
469
|
+
fi
|
|
470
|
+
fi
|
|
410
471
|
if [ "$tree" != "$(${BOUNDARY_GIT} rev-parse 'HEAD^{tree}')" ]; then
|
|
411
|
-
export GIT_AUTHOR_NAME=pi-writer GIT_AUTHOR_EMAIL=pi-writer@example.invalid
|
|
412
|
-
export GIT_COMMITTER_NAME=pi-writer GIT_COMMITTER_EMAIL=pi-writer@example.invalid
|
|
413
472
|
commit=$(printf '%s\\n' 'WIP collected by pi-session-mcp' | ${BOUNDARY_GIT} commit-tree "$tree" -p HEAD)
|
|
414
473
|
branch=$(${BOUNDARY_GIT} symbolic-ref --short HEAD || printf '%s' "$4")
|
|
415
474
|
# A taken name, or one that conflicts with an exported branch path, is refused rather than aborting the export.
|
|
@@ -459,11 +518,19 @@ fi`;
|
|
|
459
518
|
// Defence in depth (#193): whatever the boundary script exported or reported as nothing to collect,
|
|
460
519
|
// every commit the clone's work refers to must exist as a commit object in the operator checkout
|
|
461
520
|
// before a digest is recorded.
|
|
462
|
-
for (const sha of [work.head, ...work.branches.map((branch) => branch.commit)]) {
|
|
521
|
+
for (const sha of [work.head, ...work.branches.map((branch) => branch.commit), ...work.stashes]) {
|
|
463
522
|
if (sha === undefined || await git(ctx, ["cat-file", "-e", `${sha}^{commit}`], true) === undefined) {
|
|
464
523
|
return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
|
|
465
524
|
}
|
|
466
525
|
}
|
|
526
|
+
// Tags, notes and other refs are not exported: their objects must already be in the operator checkout (#202).
|
|
527
|
+
if (work.unknownRefs > 0)
|
|
528
|
+
return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
|
|
529
|
+
for (const tree of [work.indexTree, work.tree]) {
|
|
530
|
+
if (tree !== work.headTree && await git(ctx, ["cat-file", "-e", `${tree}^{tree}`], true) === undefined) {
|
|
531
|
+
return { status: "failed", code: "writer_collect_incomplete", refs, refused: findings.length, findings };
|
|
532
|
+
}
|
|
533
|
+
}
|
|
467
534
|
await writeWriterState(ctx.home, name, { ...state, collected: { digest: work.digest, at: new Date().toISOString() } });
|
|
468
535
|
return { status: "ok", code: refs.length === 0 ? "nothing_to_collect" : "writer_collected", refs, refused: findings.length, findings };
|
|
469
536
|
}
|
|
@@ -485,7 +552,7 @@ async function recreate(ctx, name, state, base) {
|
|
|
485
552
|
const next = { ...retained, base: { ref: `refs/heads/${base.short}`, commit: base.commit } };
|
|
486
553
|
await writeWriterState(ctx.home, name, { ...next, phase: "provisioning" });
|
|
487
554
|
for (const path of [paths.clone, paths.agent]) {
|
|
488
|
-
await
|
|
555
|
+
await removeWriterTree(path);
|
|
489
556
|
await privateDirectory(path);
|
|
490
557
|
}
|
|
491
558
|
await cloneFromBundle(ctx, paths, state.imageId, base, remote, state.tags);
|
|
@@ -574,7 +641,7 @@ export async function removeWriter(ctx, name, opts) {
|
|
|
574
641
|
if (!work.baseline && state.collected?.digest !== work.digest)
|
|
575
642
|
throw new LifecycleError("writer_uncollected");
|
|
576
643
|
}
|
|
577
|
-
await
|
|
644
|
+
await removeWriterTree(paths.root);
|
|
578
645
|
return { code: "writer_removed", status: "ok" };
|
|
579
646
|
}
|
|
580
647
|
catch (error) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** File name of Pi's persisted model catalog overlay, next to `auth.json` in an agent directory. */
|
|
2
|
+
export declare const MODELS_STORE_FILE = "models-store.json";
|
|
3
|
+
/** Upper bound for the copied catalog; a larger file is skipped rather than read into memory. */
|
|
4
|
+
export declare const MODELS_STORE_MAX_BYTES: number;
|
|
5
|
+
/** The host's persisted catalog overlay, resolved like the host `auth.json` the launcher reads. */
|
|
6
|
+
export declare const hostModelsStorePath: (home: string) => string;
|
|
7
|
+
/**
|
|
8
|
+
* Copies the host's persisted Pi model catalog into a writer's private agent directory, so the writer's Pi resolves the
|
|
9
|
+
* same models as the host (#199). The file holds model catalog data, not credentials, and is copied byte for byte: it is
|
|
10
|
+
* neither parsed nor printed. A missing, non-regular, symbolic-link or oversized source is skipped, and so is any failure:
|
|
11
|
+
* the destination is then removed, so neither a stale copy nor a file left by a worker in the writable agent directory
|
|
12
|
+
* survives, and the writer keeps the static model list shipped with the SDK. The destination is replaced by an atomic
|
|
13
|
+
* rename of a 0600 temporary file, so a link at the destination path is replaced, never followed. Returns whether a copy
|
|
14
|
+
* was made.
|
|
15
|
+
*/
|
|
16
|
+
export declare function copyModelsStore(source: string, agentDir: string): Promise<boolean>;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { mkdir, open, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
/** File name of Pi's persisted model catalog overlay, next to `auth.json` in an agent directory. */
|
|
6
|
+
export const MODELS_STORE_FILE = "models-store.json";
|
|
7
|
+
/** Upper bound for the copied catalog; a larger file is skipped rather than read into memory. */
|
|
8
|
+
export const MODELS_STORE_MAX_BYTES = 32 * 1024 * 1024;
|
|
9
|
+
/** The host's persisted catalog overlay, resolved like the host `auth.json` the launcher reads. */
|
|
10
|
+
export const hostModelsStorePath = (home) => join(home, ".pi", "agent", MODELS_STORE_FILE);
|
|
11
|
+
/**
|
|
12
|
+
* Copies the host's persisted Pi model catalog into a writer's private agent directory, so the writer's Pi resolves the
|
|
13
|
+
* same models as the host (#199). The file holds model catalog data, not credentials, and is copied byte for byte: it is
|
|
14
|
+
* neither parsed nor printed. A missing, non-regular, symbolic-link or oversized source is skipped, and so is any failure:
|
|
15
|
+
* the destination is then removed, so neither a stale copy nor a file left by a worker in the writable agent directory
|
|
16
|
+
* survives, and the writer keeps the static model list shipped with the SDK. The destination is replaced by an atomic
|
|
17
|
+
* rename of a 0600 temporary file, so a link at the destination path is replaced, never followed. Returns whether a copy
|
|
18
|
+
* was made.
|
|
19
|
+
*/
|
|
20
|
+
export async function copyModelsStore(source, agentDir) {
|
|
21
|
+
const copied = await copy(source, agentDir);
|
|
22
|
+
if (!copied)
|
|
23
|
+
await unlink(join(agentDir, MODELS_STORE_FILE)).catch(() => undefined);
|
|
24
|
+
return copied;
|
|
25
|
+
}
|
|
26
|
+
async function copy(source, agentDir) {
|
|
27
|
+
let temporary;
|
|
28
|
+
try {
|
|
29
|
+
// O_NOFOLLOW refuses a symbolic link at the source path; the opened file is checked again after open.
|
|
30
|
+
const input = await open(source, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
31
|
+
let content;
|
|
32
|
+
try {
|
|
33
|
+
const stat = await input.stat();
|
|
34
|
+
if (!stat.isFile() || stat.size > MODELS_STORE_MAX_BYTES)
|
|
35
|
+
return false;
|
|
36
|
+
content = await input.readFile();
|
|
37
|
+
}
|
|
38
|
+
finally {
|
|
39
|
+
await input.close();
|
|
40
|
+
}
|
|
41
|
+
if (content.length > MODELS_STORE_MAX_BYTES)
|
|
42
|
+
return false;
|
|
43
|
+
await mkdir(agentDir, { recursive: true, mode: 0o700 });
|
|
44
|
+
temporary = join(agentDir, `.${MODELS_STORE_FILE}-${randomUUID()}.tmp`);
|
|
45
|
+
const output = await open(temporary, "wx", 0o600);
|
|
46
|
+
try {
|
|
47
|
+
await output.writeFile(content);
|
|
48
|
+
await output.sync();
|
|
49
|
+
}
|
|
50
|
+
finally {
|
|
51
|
+
await output.close();
|
|
52
|
+
}
|
|
53
|
+
await rename(temporary, join(agentDir, MODELS_STORE_FILE));
|
|
54
|
+
temporary = undefined;
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
finally {
|
|
61
|
+
if (temporary !== undefined)
|
|
62
|
+
await unlink(temporary).catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Upper bound for the entries one permission walk visits: a Go module cache holds many thousands. */
|
|
2
|
+
export declare const TREE_WALK_LIMIT = 5000000;
|
|
3
|
+
/**
|
|
4
|
+
* Gives the current user rwx on every real directory below `root` (and on `root` itself) that lacks it. Go creates
|
|
5
|
+
* its module cache with directories that have no owner write bit, and Node's recursive `rm` cannot unlink inside
|
|
6
|
+
* such a directory. Nothing is followed: every entry is `lstat`ed, a symbolic link is never opened or `chmod`ed,
|
|
7
|
+
* and only directories owned by the current user are changed. The walk visits only entries below `root`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function makeTreeRemovable(root: string, limit?: number): Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* Removes a writer's storage tree. Permissions are restored first. Then everything except the top-level `clone` and
|
|
12
|
+
* `state.json` is deleted, then `clone`, then `state.json`, then the root. A part-way failure therefore keeps
|
|
13
|
+
* `state.json`, and as long as anything else fails also `clone`, so a retry without `--discard` can still inspect the
|
|
14
|
+
* writer instead of finding one that setup can no longer address.
|
|
15
|
+
*/
|
|
16
|
+
export declare function removeWriterTree(root: string): Promise<void>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { chmod, lstat, readdir, rm } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/** Upper bound for the entries one permission walk visits: a Go module cache holds many thousands. */
|
|
4
|
+
export const TREE_WALK_LIMIT = 5_000_000;
|
|
5
|
+
const OWNER_RWX = 0o700;
|
|
6
|
+
const errorCode = (error) => (error instanceof Error && "code" in error ? String(error.code) : undefined);
|
|
7
|
+
/**
|
|
8
|
+
* Gives the current user rwx on every real directory below `root` (and on `root` itself) that lacks it. Go creates
|
|
9
|
+
* its module cache with directories that have no owner write bit, and Node's recursive `rm` cannot unlink inside
|
|
10
|
+
* such a directory. Nothing is followed: every entry is `lstat`ed, a symbolic link is never opened or `chmod`ed,
|
|
11
|
+
* and only directories owned by the current user are changed. The walk visits only entries below `root`.
|
|
12
|
+
*/
|
|
13
|
+
export async function makeTreeRemovable(root, limit = TREE_WALK_LIMIT) {
|
|
14
|
+
const uid = process.getuid?.();
|
|
15
|
+
const pending = [root];
|
|
16
|
+
let visited = 0;
|
|
17
|
+
for (let path = pending.pop(); path !== undefined; path = pending.pop()) {
|
|
18
|
+
if (++visited > limit)
|
|
19
|
+
throw new Error("tree_walk_limit_exceeded");
|
|
20
|
+
let stat;
|
|
21
|
+
try {
|
|
22
|
+
stat = await lstat(path);
|
|
23
|
+
}
|
|
24
|
+
catch (error) {
|
|
25
|
+
if (errorCode(error) === "ENOENT")
|
|
26
|
+
continue;
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
if (!stat.isDirectory())
|
|
30
|
+
continue; // Files and symbolic links need no change; removal unlinks a link without following it.
|
|
31
|
+
if (uid !== undefined && stat.uid === uid && (stat.mode & OWNER_RWX) !== OWNER_RWX)
|
|
32
|
+
await chmod(path, (stat.mode & 0o7777) | OWNER_RWX);
|
|
33
|
+
let names;
|
|
34
|
+
try {
|
|
35
|
+
names = await readdir(path);
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
// A directory of another user stays unreadable; the removal that follows reports it.
|
|
39
|
+
if (errorCode(error) === "EACCES" || errorCode(error) === "ENOENT")
|
|
40
|
+
continue;
|
|
41
|
+
throw error;
|
|
42
|
+
}
|
|
43
|
+
for (const name of names)
|
|
44
|
+
pending.push(join(path, name));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Removes a writer's storage tree. Permissions are restored first. Then everything except the top-level `clone` and
|
|
49
|
+
* `state.json` is deleted, then `clone`, then `state.json`, then the root. A part-way failure therefore keeps
|
|
50
|
+
* `state.json`, and as long as anything else fails also `clone`, so a retry without `--discard` can still inspect the
|
|
51
|
+
* writer instead of finding one that setup can no longer address.
|
|
52
|
+
*/
|
|
53
|
+
export async function removeWriterTree(root) {
|
|
54
|
+
let stat;
|
|
55
|
+
try {
|
|
56
|
+
stat = await lstat(root);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (errorCode(error) === "ENOENT")
|
|
60
|
+
return;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
if (!stat.isDirectory()) {
|
|
64
|
+
await rm(root, { force: true });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await makeTreeRemovable(root);
|
|
68
|
+
for (const name of await readdir(root)) {
|
|
69
|
+
if (name !== "state.json" && name !== "clone")
|
|
70
|
+
await rm(join(root, name), { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
await rm(join(root, "clone"), { recursive: true, force: true });
|
|
73
|
+
await rm(join(root, "state.json"), { force: true });
|
|
74
|
+
await rm(root, { recursive: true, force: true });
|
|
75
|
+
}
|
package/docs/acceptance.md
CHANGED
|
@@ -2598,6 +2598,131 @@ image) reported `writer_accepted` from 09:48 to 09:51 UTC. Client-target
|
|
|
2598
2598
|
all 14 writers. Doctor reported no error, only `workspace_mode_unset` for one
|
|
2599
2599
|
workspace without a mode.
|
|
2600
2600
|
|
|
2601
|
+
### v0.14.2 pre-release acceptance
|
|
2602
|
+
|
|
2603
|
+
`v0.14.2` was accepted on 2026-10-30 from 15:41 to 15:47 UTC on its release
|
|
2604
|
+
candidate `e911a6d`, on Linux (`x64`) with Node.js `26.12.1`, npm `12.2.1`,
|
|
2605
|
+
Codex CLI `0.166.0` and Claude Code `2.1.314`. Level 2 passed in the working
|
|
2606
|
+
tree and for the packed tarball, each with delivery off and with delivery on.
|
|
2607
|
+
Level 1 passed except `npm audit`. Later commits on the release pull request
|
|
2608
|
+
change only this record.
|
|
2609
|
+
|
|
2610
|
+
The patch release ships #198 and #199 (PR #201), #200 (PR #203) and #202
|
|
2611
|
+
(PR #204) from `main` at `5acbebb`; the candidate adds only the version, the
|
|
2612
|
+
changelog heading and the roadmap lines. The MCP tool contract, result codes
|
|
2613
|
+
and configuration schema are unchanged. The #200 source changes were accepted
|
|
2614
|
+
on their branch before the merge, see
|
|
2615
|
+
[Issue #200 candidate acceptance](#issue-200-candidate-acceptance-2026-10-30);
|
|
2616
|
+
#198, #199 and #202 were checked in their pull requests by the full test suite
|
|
2617
|
+
and, for #202, the native Podman tests.
|
|
2618
|
+
|
|
2619
|
+
- Level 1 in the checkout of the candidate after `npm ci`: every command exited
|
|
2620
|
+
with `0`, except `npm audit`; `git status --short` printed nothing.
|
|
2621
|
+
- The test run, with `NODE_OPTIONS=--unhandled-rejections=strict`, reported
|
|
2622
|
+
144 files, 4097 passed and 14 skipped, and no unhandled error.
|
|
2623
|
+
- `npm audit` and `npm audit --omit=dev` exited with `1` and name only the
|
|
2624
|
+
known `undici` 8.10.2 finding through `@earendil-works/pi-coding-agent`
|
|
2625
|
+
1.4.0, the latest SDK release, with eight advisories
|
|
2626
|
+
([#120](https://github.com/plagor44-ux/pi-session-mcp/issues/120)).
|
|
2627
|
+
- `npm pack` listed 940 files (tarball shasum
|
|
2628
|
+
`775e6de2a6e810839ceb4d8518b8e7c93eae38f3`, identical in three packs). A
|
|
2629
|
+
scan of the unpacked files for private names, hosts and the maintainer's
|
|
2630
|
+
home path found none; the only matches were the public repository owner in
|
|
2631
|
+
links and the already redacted paths of disposable evaluation homes.
|
|
2632
|
+
- Doctor reported `packageVersion` `0.14.2`. It exited with `2` because no
|
|
2633
|
+
configuration was set (`config_missing`).
|
|
2634
|
+
- Level 2 in the working tree: 160 of 160 checks passed with delivery off and
|
|
2635
|
+
228 of 228 with `--events spool`.
|
|
2636
|
+
- Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
|
|
2637
|
+
228 of 228 with `--events spool`. The install smoke from another fresh prefix
|
|
2638
|
+
reported `smoke_passed` with version `0.14.2`.
|
|
2639
|
+
- In every Level 2 run the real client registrations were unchanged and the
|
|
2640
|
+
disposable prefixes were removed.
|
|
2641
|
+
- Podman-gated writer tests on the candidate (Podman `6.1.4`): 10 of 10 passed
|
|
2642
|
+
(9 lifecycle tests, including the detached-`HEAD` export of #193, the
|
|
2643
|
+
index-only export of #200 and the stash export of #202, and 1 boundary test).
|
|
2644
|
+
- Bubblewrap-gated sandbox tests (`test/sandbox-shell.bwrap.test.ts`, bubblewrap
|
|
2645
|
+
`0.13.1`): 12 of 12 passed, none skipped.
|
|
2646
|
+
|
|
2647
|
+
Not established for `0.14.2`: a Level 3 cycle on the release candidate, and the
|
|
2648
|
+
published-release Level 2 and upgrades, which follow after publishing.
|
|
2649
|
+
|
|
2650
|
+
### Issue #200 candidate acceptance (2026-10-30)
|
|
2651
|
+
|
|
2652
|
+
Recorded on 2026-10-30 for the unreleased branch `fix/writer-index-only-work`
|
|
2653
|
+
([Sprint 13](sprints/2026-10-30-s13-index-only-work.md)), on its source commit
|
|
2654
|
+
`1e1e694`, in a fresh clone on Linux (`x64`) with Node.js `26.12.1`, npm
|
|
2655
|
+
`12.2.1`, Podman `6.1.4`, Codex CLI `0.166.0` and Claude Code `2.1.314`. Later
|
|
2656
|
+
commits on the branch change only documentation.
|
|
2657
|
+
|
|
2658
|
+
- Level 1 after `npm ci`: typecheck, `build:cli`, the test run with
|
|
2659
|
+
`NODE_OPTIONS=--unhandled-rejections=strict` (141 files, 4062 passed, 14
|
|
2660
|
+
skipped, no unhandled error), `build` and `git diff --check` exited with `0`,
|
|
2661
|
+
and the status stayed clean. `npm audit --omit=dev` still exits with `1` for
|
|
2662
|
+
the known `undici` finding (#120).
|
|
2663
|
+
- Podman-gated writer tests, none skipped: 8 of 8 lifecycle tests, including
|
|
2664
|
+
the new index-only case, and 1 of 1 boundary test. The index-only case fails
|
|
2665
|
+
against the source of `7752d3f` (`baseline: true`).
|
|
2666
|
+
- Level 2 in the working tree: 160 of 160 with delivery off and 228 of 228 with
|
|
2667
|
+
`--events spool`.
|
|
2668
|
+
- Packed-tarball Level 2 from a fresh prefix: 160 of 160 with delivery off and
|
|
2669
|
+
228 of 228 with `--events spool`. The install smoke reported `smoke_passed`
|
|
2670
|
+
with version `0.14.1`.
|
|
2671
|
+
- In every Level 2 run the built files and the real client registrations were
|
|
2672
|
+
unchanged, no process remained, the leak check passed and the disposable
|
|
2673
|
+
directories were removed.
|
|
2674
|
+
|
|
2675
|
+
Not established: a Level 3 cycle, macOS, and a productive writer upgrade with
|
|
2676
|
+
this change.
|
|
2677
|
+
|
|
2678
|
+
### v0.14.1 post-release evidence
|
|
2679
|
+
|
|
2680
|
+
Recorded on 2026-10-30 for the published release `0.14.1`.
|
|
2681
|
+
|
|
2682
|
+
Tag binding: a fresh clone of the annotated tag `v0.14.1` (tag object
|
|
2683
|
+
`bd1ba81739742e1692d65425e94d5a091731a402`) peels to the merge commit
|
|
2684
|
+
`7752d3f1555d19fed06f43c0e046da07017a3320` of #197, and
|
|
2685
|
+
`git describe --tags --exact-match` reports `v0.14.1`. CI on the merge commit
|
|
2686
|
+
(run 41013930671) and on the tag (run 41017350738) passed on the first attempt.
|
|
2687
|
+
The npm package records `gitHead` `0753fad`, the head of #197, whose tree
|
|
2688
|
+
(`36654c3`) is identical to the merge commit's.
|
|
2689
|
+
|
|
2690
|
+
- Level 1 on the tag clone after `npm ci`, from 08:27 to 08:29 UTC with
|
|
2691
|
+
Node.js `26.12.1` and npm `12.2.1`: typecheck, `build:cli`, the test run
|
|
2692
|
+
(141 files, 4044 passed, 14 skipped, with
|
|
2693
|
+
`NODE_OPTIONS=--unhandled-rejections=strict`), `build` and
|
|
2694
|
+
`git diff --check` exited with `0`, and the status stayed clean. Doctor
|
|
2695
|
+
reported `packageVersion` `0.14.1` and exited with `2` because no
|
|
2696
|
+
configuration was set (`config_missing`).
|
|
2697
|
+
- `pi-session-mcp@0.14.1` on npm: 933 files, shasum
|
|
2698
|
+
`886a20688a44a967d1f917e9dad5461e83fbb5e5`; `npm pack` of the tag clone gives
|
|
2699
|
+
the same shasum and file count. It differs from the candidate's
|
|
2700
|
+
`47cf66a3856530112c11095d07cb05b7ab9892f4` below because `0753fad` adds the
|
|
2701
|
+
pre-release record to the packaged `docs/acceptance.md`; no other file
|
|
2702
|
+
changed.
|
|
2703
|
+
- Published release, Level 2 on the tag clone, 08:29 to 08:31 UTC: 160 of 160
|
|
2704
|
+
with delivery off and 228 of 228 with `--events spool`. The manifests and
|
|
2705
|
+
the 152 built runtime files were byte-identical before and after the client
|
|
2706
|
+
cycles.
|
|
2707
|
+
- Published npm package, Level 2 from a fresh prefix, 08:31 to 08:33 UTC:
|
|
2708
|
+
160 of 160 with delivery off and 228 of 228 with `--events spool`. The
|
|
2709
|
+
install smoke on the published package reported `smoke_passed` with version
|
|
2710
|
+
`0.14.1`.
|
|
2711
|
+
- In every Level 2 run the real client registrations were unchanged, no process
|
|
2712
|
+
remained and the leak check passed. Clients: Codex CLI `0.166.0` and Claude
|
|
2713
|
+
Code `2.1.314`.
|
|
2714
|
+
- The GitHub release `v0.14.1` was published the same day.
|
|
2715
|
+
|
|
2716
|
+
Installations: the workstation and the second machine (Linux) run `0.14.1`,
|
|
2717
|
+
upgraded by the maintainer with the steps of
|
|
2718
|
+
[Installation](installation.md#container-writers); both client targets passed
|
|
2719
|
+
`--verify`, and Doctor reported no error. On the workstation all 18 writers
|
|
2720
|
+
were provisioned again with `0.14.1` on 2026-10-30 for a profile change and
|
|
2721
|
+
reported `writer_accepted` and `--writer <name> --verify` ok. Those writers
|
|
2722
|
+
were removed before re-provisioning on the strength of the `0.14.1`
|
|
2723
|
+
inspection, which #200 shows to ignore work that exists only in the index;
|
|
2724
|
+
whether any of them held such work cannot be established afterwards.
|
|
2725
|
+
|
|
2601
2726
|
### v0.14.1 pre-release acceptance
|
|
2602
2727
|
|
|
2603
2728
|
`v0.14.1` was accepted on 2026-10-30 from 06:04 to 06:15 UTC on its release
|
package/docs/doctor-design.md
CHANGED
|
@@ -44,7 +44,7 @@ and owners are:
|
|
|
44
44
|
| `writer_not_migrated` | Warning. The writer state still records the legacy launch, so the writer is not in the relay pool | validated writer name |
|
|
45
45
|
| `writer_off_default_base` | Warning. One or more ready writers of a container workspace have a base other than the workspace default base; the remediation states only their count | validated workspace alias |
|
|
46
46
|
| `writer_git_identity_missing` | Clone Git config lacks a readable user name and email (warning); set both in the operator checkout, then reset the writer | validated writer name |
|
|
47
|
-
| `writer_dirty` | HEAD is not the base branch, its commit differs, another branch exists, or Git metadata cannot safely be inspected (warning) | validated writer name |
|
|
47
|
+
| `writer_dirty` | HEAD is not the base branch, its commit differs, another branch exists, or Git metadata cannot safely be inspected (warning). It reads only `HEAD` and refs, never the index or worktree, so an `ok` result is no proof of a clean clone and never authorizes cleanup; the boundary inspection does (#200) | validated writer name |
|
|
48
48
|
| `writer_uncollected_bundle` | Bundles directory is unsafe/unreadable or contains entries other than base.bundle (warning; an absent directory alone does not warn) | validated writer name |
|
|
49
49
|
| `extension_missing` | Pi has no user install of a package that a profile's `extensions` names | `<profile>:<package>` |
|
|
50
50
|
| `extension_version_mismatch` | The installed manifest's version differs from the pinned version, or the manifest or its `pi.extensions` entries are invalid | `<profile>:<package>` |
|
package/docs/execution-modes.md
CHANGED
|
@@ -119,6 +119,11 @@ not selected by tool prefix. Their private state lives under
|
|
|
119
119
|
`$HOME/.local/state/pi-session-mcp/writers/<name>/`: `clone`, `agent`, `config`,
|
|
120
120
|
`bundles`, optional `go`, `wrapper.sh` and `state.json`. This lifecycle metadata
|
|
121
121
|
is distinct from in-memory MCP sessions; it does not implement session resume.
|
|
122
|
+
The writer's `agent` directory also receives a byte copy of the host's persisted
|
|
123
|
+
Pi model catalog (`models-store.json`, catalog data and no credentials) at
|
|
124
|
+
provisioning, reset, recreate and each launch, so that a writer resolves the same
|
|
125
|
+
models as the host. A missing, linked or oversized host file is skipped and the
|
|
126
|
+
writer keeps the SDK's shipped model list.
|
|
122
127
|
Each clone is made via a bundle, with no hardlinks to the operator's repository.
|
|
123
128
|
The workspace records a short default `base` in its container entry, selected
|
|
124
129
|
by `--base` or origin's symbolic HEAD, falling back to local main. Repeating a
|
|
@@ -182,10 +187,18 @@ pi-session-mcp-setup --writer my-project-w1 --finish --apply
|
|
|
182
187
|
|
|
183
188
|
Collection exports changed branches via a bundle into the operator's checkout as
|
|
184
189
|
`refs/remotes/writer-<name>/<branch>`. Uncommitted/untracked work is captured as a
|
|
185
|
-
synthetic `<branch>-wip` commit. A
|
|
190
|
+
synthetic `<branch>-wip` commit. A staged index whose tree differs from both
|
|
191
|
+
HEAD's tree and the worktree (for example a staged change that the worktree
|
|
192
|
+
reverted) is exported as an extra synthetic `<branch>-index` commit, so work
|
|
193
|
+
that exists only in the index is recovered too; the same name rules apply. An
|
|
194
|
+
index that cannot be evaluated, such as an unmerged conflict, makes inspection
|
|
195
|
+
and collection fail closed and every non-`--discard` cleanup refuse. Such a
|
|
196
|
+
writer is not at its baseline, so it is not admitted to a new session either;
|
|
197
|
+
its work stays in the clone until the operator decides on `--discard`. Have the
|
|
198
|
+
worker resolve or abort a merge before its session ends. A clean detached HEAD whose commit no branch in
|
|
186
199
|
the clone reaches is exported as `<base>-detached` (for example `main-detached`,
|
|
187
200
|
imported as `refs/remotes/writer-<name>/main-detached`); a dirty detached HEAD
|
|
188
|
-
keeps the `<base>-wip` export. A `<base>-detached` or `<branch>-
|
|
201
|
+
keeps the `<base>-wip` export. A `<base>-detached`, `<branch>-wip` or `<branch>-index` name that
|
|
189
202
|
an exported branch already takes, or that conflicts with an exported branch's
|
|
190
203
|
path (such as a branch `main-detached/topic`), is refused and makes collection
|
|
191
204
|
incomplete (`writer_collect_incomplete`); the other branches are still
|
|
@@ -198,9 +211,30 @@ commit exist in the operator checkout; otherwise collection is incomplete
|
|
|
198
211
|
commit: if the operator checkout no longer has it (for example after a deleted
|
|
199
212
|
base branch and garbage collection), collect and finish fail this way even on a
|
|
200
213
|
pristine writer, without refused names; fetch the base again or resolve with
|
|
201
|
-
`--reset --discard --apply`.
|
|
202
|
-
|
|
203
|
-
|
|
214
|
+
`--reset --discard --apply`. The digest also covers the index tree, and the
|
|
215
|
+
index and worktree tree objects must exist in the operator checkout. New work,
|
|
216
|
+
including a change to the index alone, invalidates that authorization. A digest
|
|
217
|
+
recorded by 0.14.1 or earlier never matches the versioned digest, so collect
|
|
218
|
+
such a writer again before a destructive operation.
|
|
219
|
+
|
|
220
|
+
Every stash entry is exported as `<base>-stash-<n>`, numbered like
|
|
221
|
+
`stash@{n}` (newest first; for example `main-stash-0`), with its index and
|
|
222
|
+
untracked parts as the commit's parents; `git stash apply` accepts the imported
|
|
223
|
+
ref. The same name rules apply, and every stash commit must exist in the
|
|
224
|
+
operator checkout before a digest is recorded. Tags, notes, replace refs,
|
|
225
|
+
remote-tracking refs and refs in any other namespace are not exported, whatever
|
|
226
|
+
their names. Each must name an object the operator checkout already has,
|
|
227
|
+
otherwise the writer is not at its baseline and collection is incomplete
|
|
228
|
+
(`writer_collect_incomplete`, without a refused name) and no digest is
|
|
229
|
+
recorded. Provisioned tags and remote-tracking refs, and a lightweight tag on
|
|
230
|
+
an exported commit, pass while the operator checkout still has their objects:
|
|
231
|
+
only such a ref's name is lost at cleanup. An annotated tag, a note, a replace
|
|
232
|
+
ref or a moved remote-tracking ref that the worker created blocks cleanup until
|
|
233
|
+
a later session on that writer removes it or the operator decides on
|
|
234
|
+
`--discard`; the server guard admits such a writer as long as its branches,
|
|
235
|
+
stash and worktree are at the baseline. If the operator deletes a provisioned
|
|
236
|
+
annotated tag and its object is garbage-collected, the writer's copy blocks
|
|
237
|
+
cleanup the same way.
|
|
204
238
|
|
|
205
239
|
`--finish` collects, then resets a persistent writer or recreates an ephemeral
|
|
206
240
|
writer, on the workspace default base. A collect refusing any branch name is incomplete (`writer_collect_incomplete`,
|
|
@@ -215,7 +249,7 @@ unexported work. Do not blindly retry or bypass a refusal by changing profiles.
|
|
|
215
249
|
| `--reset --apply [--base <ref>]` | Reset to current source base branch, delete extra branches/stash and clean untracked files, and ignored files with `--clean all`; falls back to recreation if post-reset verification fails |
|
|
216
250
|
| `--recreate --apply` | Rebuild clone and agent copy from the workspace default base, retaining image/settings |
|
|
217
251
|
| `--finish --apply` | Collect, then reset persistent / recreate ephemeral on the workspace default base |
|
|
218
|
-
| `--remove` | Explicit mutation: unregister owned registrations before removing writer state |
|
|
252
|
+
| `--remove` | Explicit mutation: unregister owned registrations before removing writer state; read-only directories inside the writer root (such as Go's module cache) are made removable first, and `state.json` is removed last |
|
|
219
253
|
| `--verify` | Doctor and bounded relay-path MCP handshake/tools/list/capabilities; verify the host registration with client-target verify too; no session or provider request |
|
|
220
254
|
|
|
221
255
|
Other than explicit `--remove` and `--verify`, omitting apply only plans.
|
|
@@ -305,12 +339,12 @@ listed below; dynamic planned and MCP verification outcomes are listed explicitl
|
|
|
305
339
|
| `clone_verification_failed` | Clone layout/hook/config redirects unsafe |
|
|
306
340
|
| `writer_state_invalid` | Missing/malformed/mismatched writer state |
|
|
307
341
|
| `writer_state_unreadable` | Writer state cannot safely be read |
|
|
308
|
-
| `writer_inspect_failed` | Contained work inspection failed |
|
|
342
|
+
| `writer_inspect_failed` | Contained work inspection failed, including an index that cannot be evaluated such as an unmerged conflict |
|
|
309
343
|
| `writer_uncollected` | Changed work lacks a matching complete collection digest; destructive operation refused |
|
|
310
|
-
| `writer_collect_failed` | Bundle generation/verification/import failed |
|
|
311
|
-
| `writer_collect_incomplete` | One or more branch names refused; no cleanup authorization |
|
|
312
|
-
| `ref_name_refused` | Unsafe export name or
|
|
313
|
-
| `nothing_to_collect` | No changed branch or
|
|
344
|
+
| `writer_collect_failed` | Bundle generation/verification/import failed, including an unmerged index |
|
|
345
|
+
| `writer_collect_incomplete` | One or more branch names refused, a collected commit or tree is missing from the operator checkout, or a tag, note or other unexported ref names an object only the clone has; no cleanup authorization |
|
|
346
|
+
| `ref_name_refused` | Unsafe export name, or a `-wip`, `-index`, `-detached` or `-stash-<n>` name collision |
|
|
347
|
+
| `nothing_to_collect` | No changed branch, WIP, index, detached commit or stash entry to export |
|
|
314
348
|
| `writer_collected` | Complete export recorded |
|
|
315
349
|
| `writer_reset_failed` | Reset command or validation failed |
|
|
316
350
|
| `writer_reset` | Persistent clone reset and verified |
|
package/docs/installation.md
CHANGED
|
@@ -105,6 +105,15 @@ Providers registered only by Pi extensions are not listed; the report includes
|
|
|
105
105
|
that note. Invalid catalog ids are omitted and counted in `omittedIds`.
|
|
106
106
|
Catalog creation failures report `catalog_unavailable`.
|
|
107
107
|
|
|
108
|
+
The list includes the models in the catalog overlay that Pi persisted earlier
|
|
109
|
+
(`models-store.json` in Pi's agent directory, written when Pi refreshes its
|
|
110
|
+
model catalogs), restored offline. Pi Session MCP makes no request to refresh
|
|
111
|
+
it: run Pi once so that it refreshes its catalogs, and a model that only the
|
|
112
|
+
refreshed catalog offers becomes available to `--list-models`,
|
|
113
|
+
`--probe-profile` and sessions alike. Without that file only the models
|
|
114
|
+
shipped with the Pi SDK are listed. (ADR 0014 describes the original design
|
|
115
|
+
with a catalog that is created without this restore.)
|
|
116
|
+
|
|
108
117
|
With `PI_SESSION_MCP_CONFIG` already set to the configuration to check:
|
|
109
118
|
|
|
110
119
|
```bash
|