pi-session-mcp 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/.pi-session-mcp-cli/config.js +40 -3
  2. package/.pi-session-mcp-cli/doctor.js +13 -4
  3. package/.pi-session-mcp-cli/skill-library.js +1 -1
  4. package/CHANGELOG.md +121 -0
  5. package/CONTRIBUTING.md +8 -1
  6. package/README.md +4 -2
  7. package/dist/config.d.ts +13 -1
  8. package/dist/config.js +40 -3
  9. package/dist/confined-tools.d.ts +1 -0
  10. package/dist/confined-tools.js +6 -2
  11. package/dist/doctor.d.ts +1 -1
  12. package/dist/doctor.js +13 -4
  13. package/dist/main.js +6 -2
  14. package/dist/model-catalog.d.ts +40 -0
  15. package/dist/model-catalog.js +38 -0
  16. package/dist/profile-probe.d.ts +28 -0
  17. package/dist/profile-probe.js +164 -0
  18. package/dist/sdk-pi-adapter.js +3 -2
  19. package/dist/setup-cli.d.ts +8 -2
  20. package/dist/setup-cli.js +36 -3
  21. package/dist/setup-main.js +10 -3
  22. package/dist/setup-probe-main.d.ts +14 -0
  23. package/dist/setup-probe-main.js +76 -0
  24. package/dist/setup-probe-result.d.ts +39 -0
  25. package/dist/setup-probe-result.js +62 -0
  26. package/dist/setup-writers-common.d.ts +78 -0
  27. package/dist/setup-writers-common.js +177 -0
  28. package/dist/setup-writers-plan.d.ts +34 -0
  29. package/dist/setup-writers-plan.js +105 -0
  30. package/dist/setup-writers-reconcile.d.ts +8 -0
  31. package/dist/setup-writers-reconcile.js +169 -0
  32. package/dist/setup-writers.d.ts +6 -30
  33. package/dist/setup-writers.js +18 -382
  34. package/dist/skill-library.js +1 -1
  35. package/dist/wake-hook.d.ts +11 -0
  36. package/dist/wake-hook.js +51 -8
  37. package/docs/acceptance.md +254 -1
  38. package/docs/adr/0009-execution-modes.md +3 -2
  39. package/docs/adr/0012-leased-writer-pool.md +3 -3
  40. package/docs/adr/0013-hardened-read-only-profiles.md +6 -4
  41. package/docs/adr/0014-profile-probe-and-model-catalog.md +89 -0
  42. package/docs/architecture.md +1 -1
  43. package/docs/client-setup.md +21 -15
  44. package/docs/doctor-design.md +2 -1
  45. package/docs/execution-modes.md +1 -1
  46. package/docs/installation.md +87 -0
  47. package/docs/roadmap.md +23 -5
  48. package/docs/session-mcp.md +5 -3
  49. package/docs/skills.md +42 -25
  50. package/docs/threat-model.md +2 -1
  51. package/docs/tool-contracts.md +4 -2
  52. package/package.json +1 -1
  53. package/skills/pi-session-orchestration/SKILL.md +2 -2
  54. package/skills/pi-session-orchestration/references/start-errors.md +1 -1
  55. package/skills/pi-session-profiles/SKILL.md +124 -0
@@ -0,0 +1,177 @@
1
+ import { chmod, mkdir } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { ALIAS_PATTERN } from "./execution-profile.js";
6
+ import { runDoctor } from "./doctor.js";
7
+ import { MAX_WRITERS } from "./execution-modes.js";
8
+ import { acquireOverrideLock, OverrideLockError } from "./override-lock.js";
9
+ import { createProcessRunner } from "./setup-process.js";
10
+ import { DurableOwnershipStore } from "./setup-ownership.js";
11
+ import { localHookNeededByOthers, ownsWakeHook, runWakeHook } from "./setup-hooks.js";
12
+ import { createWriterRegistrationAdapter, unregisterWriter } from "./client-adapters/writer-registration.js";
13
+ import { IMAGE_PULL_TIMEOUT_MS } from "./writer-boundary.js";
14
+ import { acquireWriterLease, WriterLeaseError } from "./writer-lease.js";
15
+ import { ensureWriterAbsent } from "./writer-fence.js";
16
+ import * as lifecycle from "./writer-lifecycle.js";
17
+ import * as acceptance from "./writer-acceptance.js";
18
+ import { readWriterState, writerName, writerPaths, writerRegistrationName, writersRoot, WRITER_NAME_PATTERN } from "./writer-state.js";
19
+ /** Services, fencing, the Doctor gate and removal shared by mode changes, reconciliation and single-writer operations. */
20
+ export const SETUP_LEASE_WAIT_MS = 10_000;
21
+ export const FENCE_CODES = ["writer_busy", "writer_fenced", "writer_lease_unavailable"];
22
+ /**
23
+ * Failures of shared setup state rather than of one writer: they end the whole reconciliation (#180). The ownership
24
+ * store's own failures count; `ownership_unavailable` and `ownership_diverged` concern one writer's registration.
25
+ */
26
+ export const INFRASTRUCTURE_CODE = /^(?:ownership_(?!unavailable$|diverged$)[a-z_]+|config_[a-z_]+|operation_aborted|platform_unsupported)$/;
27
+ export const GLOBAL_ERRORS = new Set(["node_version_unsupported", "package_metadata_unreadable", "build_missing", "build_stale", "version_mismatch", "config_missing", "config_unreadable", "config_invalid", "skill_controller_selected"]);
28
+ const safeAlias = (alias) => ALIAS_PATTERN.test(alias) ? alias : "invalid";
29
+ export const targetFor = (alias, name) => ({ client: "writer", scope: safeAlias(alias), alias: WRITER_NAME_PATTERN.test(name) || (alias === "host" && name === "control") ? name : "invalid" });
30
+ export const modesTarget = (alias) => ({ client: "modes", scope: "workspace", alias: safeAlias(alias) });
31
+ export const codeFor = (error) => error instanceof OverrideLockError || error instanceof WriterLeaseError ? error.code : error instanceof Error && /^[a-z][a-z0-9_]{0,63}$/.test(error.message) ? error.message : "writer_operation_failed";
32
+ export function services(deps) {
33
+ const home = deps.home ?? homedir();
34
+ const runner = deps.runner ?? createProcessRunner({ timeoutMs: IMAGE_PULL_TIMEOUT_MS, maxOutputBytes: 1_048_576, ...(deps.signal ? { signal: deps.signal } : {}) });
35
+ return { home, runner, life: deps.lifecycle ?? lifecycle, accept: deps.acceptance ?? acceptance,
36
+ ownership: deps.ownership ?? new DurableOwnershipStore(join(home, ".local", "state", "pi-session-mcp", "ownership.json"), { ...(deps.signal ? { signal: deps.signal } : {}) }),
37
+ adapter: deps.adapter ?? createWriterRegistrationAdapter(runner) };
38
+ }
39
+ export async function context(alias, deps) {
40
+ const config = await loadConfig(deps.configPath);
41
+ const checkout = config.workspaces.get(alias);
42
+ if (!checkout)
43
+ throw new Error("alias_unknown");
44
+ const { home, runner } = services(deps);
45
+ return { home, runner, checkout, alias, releaseRoot: deps.releaseRoot, nodePath: process.execPath };
46
+ }
47
+ export async function locked(deps, action) {
48
+ const directory = writersRoot(deps.home ?? homedir());
49
+ await mkdir(directory, { recursive: true, mode: 0o700 });
50
+ await chmod(directory, 0o700);
51
+ const lock = await (deps.lock ?? acquireOverrideLock)(directory, { ...(deps.signal ? { signal: deps.signal } : {}) });
52
+ try {
53
+ return await action();
54
+ }
55
+ finally {
56
+ await lock.release();
57
+ }
58
+ }
59
+ export async function fenced(ctx, name, deps, action) {
60
+ let lease;
61
+ try {
62
+ lease = await (deps.lease ?? acquireWriterLease)(ctx.home, name, { waitMs: SETUP_LEASE_WAIT_MS, ...(deps.signal ? { signal: deps.signal } : {}) });
63
+ }
64
+ catch (error) {
65
+ throw new Error(error instanceof WriterLeaseError && error.code === "writer_busy" ? "writer_busy" : "writer_lease_unavailable");
66
+ }
67
+ try {
68
+ try {
69
+ await (deps.ensureAbsent ?? ensureWriterAbsent)(ctx.runner, writerPaths(ctx.home, name), name, { leaseFd: lease.fd });
70
+ }
71
+ catch {
72
+ throw new Error("writer_fenced");
73
+ }
74
+ return await action({ ...ctx, leaseFd: lease.fd });
75
+ }
76
+ finally {
77
+ await lease.release();
78
+ }
79
+ }
80
+ export function intent(ctx, name, client) {
81
+ return { client, name: writerRegistrationName(name), wrapperPath: writerPaths(ctx.home, name).wrapper, projectDir: ctx.checkout };
82
+ }
83
+ /**
84
+ * The wake hook of a writer's Claude Code registration, in the local settings of the operator's
85
+ * project. Claude Code only: a Codex writer has no hook. The writer's own configuration decides
86
+ * whether delivery is wanted; the entry is shared per project, see `localHookNeededByOthers`.
87
+ * Findings without news (`hook_not_enabled`, `hook_not_owned`) are not reported.
88
+ */
89
+ export async function writerHook(ctx, name, deps, operation, enabled) {
90
+ const { ownership } = services(deps);
91
+ const roots = { packageRoot: deps.releaseRoot, home: ctx.home, cwd: ctx.checkout, env: process.env, nodePath: ctx.nodePath };
92
+ // Nothing wanted and nothing owned: not even the project's settings are looked at.
93
+ if (!enabled && !await ownsWakeHook("local", roots, ownership))
94
+ return undefined;
95
+ const request = (access, op) => runWakeHook({
96
+ operation: op, scope: "local", enabled, ownership: access,
97
+ roots,
98
+ keep: () => localHookNeededByOthers({ ownership: access, home: ctx.home, cwd: ctx.checkout, configPath: deps.configPath, except: { writer: name } }),
99
+ });
100
+ const outcome = operation === "apply" || operation === "remove" ? await ownership.transaction((access) => request(access, operation)) : await request(ownership, operation);
101
+ return outcome.code === "hook_not_enabled" || outcome.code === "hook_not_owned" ? undefined : outcome;
102
+ }
103
+ export class DoctorGateError extends Error {
104
+ findings;
105
+ constructor(findings) {
106
+ super("doctor_failed");
107
+ this.findings = findings;
108
+ }
109
+ }
110
+ export async function doctorGate(ctx, deps) {
111
+ const doctor = await (deps.doctor ?? runDoctor)({ home: ctx.home, packageRoot: deps.releaseRoot, configPath: deps.configPath, loadConfig: () => loadConfig(deps.configPath) });
112
+ const ownSubjects = new Set([ctx.alias, ...Array.from({ length: 16 }, (_, index) => writerName(ctx.alias, index + 1))]);
113
+ // A refused profile of this repository is its own finding: collect, finish and the profile change that fixes it must stay possible (#141).
114
+ // Another repository's writer that is not ready is untouched by this step and expected after an upgrade; counting it would deadlock
115
+ // the repository steps of two container repositories (#148).
116
+ const own = (check) => ownSubjects.has(check.subject)
117
+ || (check.id === "container_profile_refused" && check.subject.startsWith(`${ctx.alias}:`))
118
+ || (check.id === "writer_not_ready" && WRITER_NAME_PATTERN.test(check.subject));
119
+ const blocking = doctor.checks.filter((check) => check.severity === "error" && !own(check));
120
+ // One finding per check and alias: several `config_invalid` locations (#173) all map to the alias `invalid`.
121
+ const findings = blocking.map((check) => ({ status: "failed", code: check.id, target: { client: "doctor", scope: "check", alias: gateAlias(check.id, check.subject) } }));
122
+ const unique = findings.filter((finding, index) => findings.findIndex((other) => other.code === finding.code && other.target.alias === finding.target.alias) === index);
123
+ if (unique.length)
124
+ throw new DoctorGateError(unique);
125
+ }
126
+ /** The alias that leads a Doctor subject: `<profile>:<package>` and writer names report their alias part; global checks have none. */
127
+ function gateAlias(id, subject) {
128
+ if (GLOBAL_ERRORS.has(id))
129
+ return "invalid";
130
+ const head = subject.split(":")[0] ?? "";
131
+ return safeAlias(WRITER_NAME_PATTERN.test(head) ? head.slice(0, head.lastIndexOf("-w")) : head);
132
+ }
133
+ export async function preflight(ctx, names, discard, deps) {
134
+ if (discard)
135
+ return;
136
+ for (const name of names) {
137
+ const state = await readWriterState(ctx.home, name);
138
+ const work = await (deps.lifecycle ?? lifecycle).inspectWork(ctx, name);
139
+ if (!work.baseline && state?.collected?.digest !== work.digest)
140
+ throw new Error("writer_uncollected");
141
+ }
142
+ }
143
+ export async function remove(ctx, name, discard, deps, extras = []) {
144
+ await preflight(ctx, [name], discard, deps);
145
+ const { adapter, ownership, life } = services(deps);
146
+ // Remove registrations before deleting the wrapper used to prove ownership.
147
+ for (const client of ["claude-code", "codex"]) {
148
+ const expected = intent(ctx, name, client);
149
+ const owned = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
150
+ if (owned)
151
+ await unregisterWriter(ownership, adapter, expected);
152
+ else if (["equivalent", "divergent"].includes(await adapter.inspect(expected)))
153
+ throw new Error("ownership_unavailable");
154
+ }
155
+ // After the registration is gone. A hook that cannot be edited is reported, not a reason to keep the
156
+ // writer: the operator's settings are not the writer's to hold hostage, and the record keeps the entry owned.
157
+ const hook = await writerHook(ctx, name, deps, "remove", false).catch(() => ({ status: "failed", code: "hook_install_failed" }));
158
+ if (hook)
159
+ extras.push(hook);
160
+ return life.removeWriter(ctx, name, { discard });
161
+ }
162
+ export const writerIndex = (name) => Number(name.slice(name.lastIndexOf("-w") + 2));
163
+ /** Which writers a mode entry keeps, which of those still need provisioning, and which existing writers it no longer wants. */
164
+ export async function plan(ctx, entry) {
165
+ const keep = [], missing = [], surplus = [];
166
+ for (let index = 1; index <= MAX_WRITERS; index++) {
167
+ const name = writerName(ctx.alias, index), state = await readWriterState(ctx.home, name);
168
+ if (entry.mode === "container" && index <= entry.container.writers) {
169
+ keep.push(name);
170
+ if (!state || state.phase === "provisioning")
171
+ missing.push(name);
172
+ }
173
+ else if (state)
174
+ surplus.push(name);
175
+ }
176
+ return { keep, missing, surplus };
177
+ }
@@ -0,0 +1,34 @@
1
+ import { type ContainerSettings, type WorkspaceModeEntry } from "./execution-modes.js";
2
+ import type { ModeRequest } from "./setup-modes.js";
3
+ import { type SetupOperation, type SetupResult } from "./setup-result.js";
4
+ import type * as lifecycle from "./writer-lifecycle.js";
5
+ import { type WriterPlan, type WriterSetupDependencies } from "./setup-writers-common.js";
6
+ /** Base inference, the settings check and the plans a mode change shows or checks before it writes the modes file. */
7
+ export type SettingsDrift = (name: string, ctx?: lifecycle.WriterContext) => Promise<lifecycle.WriterOutcome | undefined>;
8
+ /** What a writer's configuration is rendered from: the raw configuration file and the skill override directory. */
9
+ export interface RenderInputs {
10
+ readonly rawConfig: unknown;
11
+ readonly overrideDirectory: string;
12
+ }
13
+ export declare function renderInputs(configPath: string): Promise<RenderInputs>;
14
+ /**
15
+ * The one settings check (#134, #163) of the pre-write gate, the dry run and reconciliation: whether a writer's
16
+ * provisioned settings or rendered configuration differ from `settings`. Reconcile passes its fenced context per writer.
17
+ */
18
+ export declare function settingsDriftCheck(inputs: RenderInputs, ctx: lifecycle.WriterContext, settings: ContainerSettings, deps: WriterSetupDependencies): SettingsDrift;
19
+ /**
20
+ * The request as the modes file should record it: `--base` as provisioning resolves it, so a refused or
21
+ * remote-tracking form never reaches the modes file; without `--base`, the one base the existing writers share, or
22
+ * for a first provisioning the base it will resolve (origin/HEAD, else main), so default starts find writers.
23
+ * Returns a refusal instead when the base cannot be resolved or existing writers disagree.
24
+ */
25
+ export declare function inferBase(configPath: string, request: ModeRequest, deps: WriterSetupDependencies, containerRuntime: () => Promise<boolean>, failed: (code: string) => SetupResult): Promise<ModeRequest | SetupResult>;
26
+ /**
27
+ * Apply only: the Doctor gate, a fence and the destructive-work refusal for each surplus writer, and the settings
28
+ * check of kept writers before the write, so a kept writer with changed settings refuses the mode change itself and
29
+ * the modes file never records a mode no writer matches (#163). Read-only like the dry run, which fences nothing;
30
+ * reconcile re-checks under its fence. Missing writers have no settings yet. Returns a refusal or `undefined`.
31
+ */
32
+ export declare function gateBeforeWrite(configPath: string, ctx: lifecycle.WriterContext, entry: WorkspaceModeEntry, planned: WriterPlan, discard: boolean, operation: SetupOperation, deps: WriterSetupDependencies): Promise<SetupResult | undefined>;
33
+ /** The dry run's writer plan: kept writers with the same settings check as apply (#134), then the surplus to remove. */
34
+ export declare function plannedWriters(configPath: string, ctx: lifecycle.WriterContext, proposed: WorkspaceModeEntry, planned: WriterPlan, deps: WriterSetupDependencies): Promise<SetupResult>;
@@ -0,0 +1,105 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { homedir } from "node:os";
3
+ import { loadConfig } from "./config.js";
4
+ import { MAX_WRITERS } from "./execution-modes.js";
5
+ import { result } from "./setup-result.js";
6
+ import { skillOverrideDirectory } from "./skill-library.js";
7
+ import { sameWriterLimits, writerLimits } from "./writer-limits.js";
8
+ import { readWriterState, writerName } from "./writer-state.js";
9
+ import { codeFor, context, doctorGate, FENCE_CODES, fenced, modesTarget, preflight, services, targetFor, writerIndex } from "./setup-writers-common.js";
10
+ export async function renderInputs(configPath) {
11
+ return { rawConfig: JSON.parse(await readFile(configPath, "utf8")), overrideDirectory: skillOverrideDirectory(configPath) };
12
+ }
13
+ /**
14
+ * The one settings check (#134, #163) of the pre-write gate, the dry run and reconciliation: whether a writer's
15
+ * provisioned settings or rendered configuration differ from `settings`. Reconcile passes its fenced context per writer.
16
+ */
17
+ export function settingsDriftCheck(inputs, ctx, settings, deps) {
18
+ const { life } = services(deps);
19
+ return (name, scoped = ctx) => life.writerSettingsDrift(scoped, { index: writerIndex(name), settings, ...inputs });
20
+ }
21
+ /**
22
+ * The request as the modes file should record it: `--base` as provisioning resolves it, so a refused or
23
+ * remote-tracking form never reaches the modes file; without `--base`, the one base the existing writers share, or
24
+ * for a first provisioning the base it will resolve (origin/HEAD, else main), so default starts find writers.
25
+ * Returns a refusal instead when the base cannot be resolved or existing writers disagree.
26
+ */
27
+ export async function inferBase(configPath, request, deps, containerRuntime, failed) {
28
+ const operation = request.apply ? "apply" : "dry-run";
29
+ if (request.baseRef !== undefined) {
30
+ try {
31
+ return { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps), request.baseRef) };
32
+ }
33
+ catch (error) {
34
+ return failed(codeFor(error));
35
+ }
36
+ }
37
+ if (!(request.mode === "container" || request.mode === undefined && await containerRuntime()))
38
+ return request;
39
+ const config = await loadConfig(configPath);
40
+ if (config.workspaceModes.get(request.alias)?.container?.base)
41
+ return request;
42
+ const bases = new Set();
43
+ for (let index = 1; index <= MAX_WRITERS; index++) {
44
+ const state = await readWriterState(deps.home ?? homedir(), writerName(request.alias, index));
45
+ if (state)
46
+ bases.add(state.base.ref);
47
+ }
48
+ if (bases.size > 1)
49
+ return result(operation, [{ target: modesTarget(request.alias), status: "failed", code: "workspace_base_ambiguous" }]);
50
+ if (bases.size === 1)
51
+ return { ...request, baseRef: [...bases][0].replace(/^refs\/heads\//, "") };
52
+ if (request.mode !== "container")
53
+ return request;
54
+ try {
55
+ return { ...request, baseRef: await services(deps).life.baseBranchName(await context(request.alias, deps)) };
56
+ }
57
+ catch (error) {
58
+ return failed(codeFor(error));
59
+ }
60
+ }
61
+ /**
62
+ * Apply only: the Doctor gate, a fence and the destructive-work refusal for each surplus writer, and the settings
63
+ * check of kept writers before the write, so a kept writer with changed settings refuses the mode change itself and
64
+ * the modes file never records a mode no writer matches (#163). Read-only like the dry run, which fences nothing;
65
+ * reconcile re-checks under its fence. Missing writers have no settings yet. Returns a refusal or `undefined`.
66
+ */
67
+ export async function gateBeforeWrite(configPath, ctx, entry, planned, discard, operation, deps) {
68
+ await doctorGate(ctx, deps);
69
+ for (const name of planned.surplus) {
70
+ try {
71
+ await fenced(ctx, name, deps, (leased) => preflight(leased, [name], discard, deps));
72
+ }
73
+ catch (error) {
74
+ // Refuse the mode change, but name the writer that could not be fenced.
75
+ if (FENCE_CODES.includes(codeFor(error)))
76
+ return result(operation, [{ target: targetFor(ctx.alias, name), status: "failed", code: codeFor(error) }]);
77
+ throw error;
78
+ }
79
+ }
80
+ if (entry.mode !== "container")
81
+ return undefined;
82
+ const drift = settingsDriftCheck(await renderInputs(configPath), ctx, entry.container, deps);
83
+ const drifted = [];
84
+ for (const name of planned.keep) {
85
+ if (planned.missing.includes(name))
86
+ continue;
87
+ const outcome = await drift(name);
88
+ if (outcome)
89
+ drifted.push({ target: targetFor(ctx.alias, name), ...outcome });
90
+ }
91
+ return drifted.length ? result(operation, drifted) : undefined;
92
+ }
93
+ /** The dry run's writer plan: kept writers with the same settings check as apply (#134), then the surplus to remove. */
94
+ export async function plannedWriters(configPath, ctx, proposed, planned, deps) {
95
+ const drift = settingsDriftCheck(await renderInputs(configPath), ctx, proposed.container, deps);
96
+ const kept = [];
97
+ for (const name of planned.keep) {
98
+ const outcome = await drift(name);
99
+ const state = await readWriterState(ctx.home, name);
100
+ const updatingLimits = state && state.phase !== "provisioning" && !sameWriterLimits(state.limits, writerLimits(proposed.container));
101
+ kept.push({ target: targetFor(ctx.alias, name), ...(outcome ?? { status: "planned", code: updatingLimits ? "writer_limits_update_planned" : "writer_planned" }) });
102
+ }
103
+ const refused = kept.some((finding) => finding.status === "failed" || finding.status === "divergent");
104
+ return result("dry-run", [...kept, ...planned.surplus.map((name) => ({ target: targetFor(ctx.alias, name), status: "planned", code: "writer_remove_planned" }))], refused ? "failed" : "planned");
105
+ }
@@ -0,0 +1,8 @@
1
+ import { type SetupResult } from "./setup-result.js";
2
+ import { type WriterSetupDependencies } from "./setup-writers-common.js";
3
+ /** Writer reconciliation after a mode change is written: provisioning, migration to the relay launch, acceptance and removal. */
4
+ export type ReconcileRequest = {
5
+ discard: boolean;
6
+ baseRef?: string;
7
+ };
8
+ export declare function reconcileUnlocked(configPath: string, alias: string, request: ReconcileRequest, deps: WriterSetupDependencies): Promise<SetupResult>;
@@ -0,0 +1,169 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { loadConfig } from "./config.js";
5
+ import { result } from "./setup-result.js";
6
+ import { unregisterWriter } from "./client-adapters/writer-registration.js";
7
+ import { sameWriterLimits, writerLimits } from "./writer-limits.js";
8
+ import { renderWrapper, wrapperDigest } from "./writer-wrapper.js";
9
+ import { readWriterState, writeWriterState, writerPaths } from "./writer-state.js";
10
+ import { codeFor, context, doctorGate, FENCE_CODES, fenced, INFRASTRUCTURE_CODE, intent, plan, preflight, remove, services, targetFor, writerHook, writerIndex } from "./setup-writers-common.js";
11
+ import { renderInputs, settingsDriftCheck } from "./setup-writers-plan.js";
12
+ /**
13
+ * Moves a kept writer to the relay launch: its owned registrations are removed (both clients are decided before
14
+ * anything is removed, so a divergent registration of one client never costs the writer its owned registration of the
15
+ * other; a client that is not installed has nothing to keep), and so is its wake hook; the wrapper is rebound to this
16
+ * release. A changed launch or wrapper leaves the writer unaccepted. Returns a refusal outcome or `undefined`.
17
+ */
18
+ async function migrateToRelay(ctx, name, deps, findings) {
19
+ const { ownership, adapter } = services(deps);
20
+ let divergent = false;
21
+ const owned = [];
22
+ for (const client of ["claude-code", "codex"]) {
23
+ const expected = intent(ctx, name, client);
24
+ const record = await ownership.get({ client, scope: client === "codex" ? "user" : "local", alias: expected.name });
25
+ const current = await adapter.inspect(expected);
26
+ if (record ? current === "divergent" : current === "equivalent" || current === "divergent")
27
+ divergent = true;
28
+ else if (record)
29
+ owned.push(expected);
30
+ }
31
+ if (!divergent) {
32
+ for (const expected of owned) {
33
+ try {
34
+ await unregisterWriter(ownership, adapter, expected);
35
+ }
36
+ catch (error) {
37
+ if (codeFor(error) !== "ownership_diverged")
38
+ throw error;
39
+ divergent = true;
40
+ }
41
+ }
42
+ }
43
+ const hook = await writerHook(ctx, name, deps, "remove", false);
44
+ if (hook)
45
+ findings.push({ target: targetFor(ctx.alias, name), ...hook });
46
+ const state = await readWriterState(ctx.home, name);
47
+ if (!state)
48
+ throw new Error("writer_state_invalid");
49
+ if (divergent) {
50
+ if (state.launch !== "legacy")
51
+ await writeWriterState(ctx.home, name, { ...state, launch: "legacy" });
52
+ return { status: "failed", code: "registration_divergent" };
53
+ }
54
+ const paths = writerPaths(ctx.home, name), wrapper = renderWrapper(ctx.nodePath, ctx.releaseRoot, name);
55
+ if (!(await lstat(paths.wrapper).catch(() => undefined))?.isFile())
56
+ throw new Error("writer_state_invalid");
57
+ const wrapperChanged = wrapperDigest(await readFile(paths.wrapper, "utf8")) !== wrapperDigest(wrapper);
58
+ if (wrapperChanged) {
59
+ const temporary = join(paths.root, `.wrapper-${randomUUID()}.tmp`);
60
+ try {
61
+ await writeFile(temporary, wrapper, { flag: "wx", mode: 0o700 });
62
+ await rename(temporary, paths.wrapper);
63
+ }
64
+ finally {
65
+ await rm(temporary, { force: true });
66
+ }
67
+ }
68
+ // A launch change also needs fresh provider-free acceptance, even when wrapper bytes match.
69
+ if (state.launch !== "relay" || wrapperChanged) {
70
+ const { acceptance: _oldAcceptance, ...unaccepted } = state;
71
+ await writeWriterState(ctx.home, name, { ...unaccepted, launch: "relay", phase: "not_ready" });
72
+ }
73
+ return undefined;
74
+ }
75
+ export async function reconcileUnlocked(configPath, alias, request, deps) {
76
+ const findings = [];
77
+ const ctx = await context(alias, { ...deps, configPath });
78
+ const config = await loadConfig(configPath), entry = config.workspaceModes.get(alias);
79
+ const planned = await plan(ctx, entry), { life, accept } = services(deps);
80
+ const add = (name, outcome) => { findings.push({ target: targetFor(alias, name), ...outcome }); };
81
+ await doctorGate(ctx, deps);
82
+ // A surplus writer that cannot be fenced is reported on its own target and left in place; the others proceed.
83
+ const unfenced = new Set();
84
+ for (const name of planned.surplus) {
85
+ try {
86
+ await fenced(ctx, name, deps, (leased) => preflight(leased, [name], request.discard, deps));
87
+ }
88
+ catch (error) {
89
+ if (!FENCE_CODES.includes(codeFor(error)))
90
+ throw error;
91
+ unfenced.add(name);
92
+ add(name, { status: "failed", code: codeFor(error) });
93
+ }
94
+ }
95
+ const inputs = await renderInputs(configPath), drift = settingsDriftCheck(inputs, ctx, entry.container, deps);
96
+ // Each writer's reconciliation, including first provisioning and acceptance, owns one fence.
97
+ // A writer added without --base joins the recorded default base, so default starts find it. A recorded base that is
98
+ // the repository default stays unset: provisioning then resolves origin/HEAD, as the first provisioning did.
99
+ let baseRef = request.baseRef;
100
+ const recordedBase = entry.container?.base;
101
+ if (baseRef === undefined && recordedBase !== undefined && recordedBase !== await life.baseBranchName(ctx).catch(() => undefined))
102
+ baseRef = recordedBase;
103
+ for (const name of planned.keep) {
104
+ try {
105
+ await fenced(ctx, name, deps, async (ctx) => {
106
+ if (planned.missing.includes(name)) {
107
+ const outcome = await life.provisionWriter(ctx, { index: writerIndex(name), settings: entry.container, ...inputs, ...(baseRef ? { baseRef } : {}) });
108
+ add(name, outcome);
109
+ if (outcome.status === "failed")
110
+ return;
111
+ }
112
+ // Settings first: a drifted writer is left exactly as it is, neither migrated nor accepted again (#134).
113
+ if (!planned.missing.includes(name)) {
114
+ const drifted = await drift(name, ctx);
115
+ if (drifted) {
116
+ add(name, drifted);
117
+ return;
118
+ }
119
+ const state = await readWriterState(ctx.home, name);
120
+ if (!state)
121
+ throw new Error("writer_state_invalid");
122
+ const limits = writerLimits(entry.container);
123
+ if (!sameWriterLimits(state.limits, limits)) {
124
+ const { limits: _previous, ...rest } = state;
125
+ await writeWriterState(ctx.home, name, { ...rest, ...(limits ? { limits } : {}) });
126
+ add(name, { status: "ok", code: "writer_limits_updated" });
127
+ }
128
+ }
129
+ const refused = await migrateToRelay(ctx, name, deps, findings);
130
+ if (refused) {
131
+ add(name, refused);
132
+ return;
133
+ }
134
+ // A ready phase with a stale binding (package upgrade, new image, Podman version) is accepted again (#130).
135
+ const current = await accept.acceptanceCurrent(ctx, name);
136
+ if (current === "stale") {
137
+ const outcome = await accept.acceptWriter(ctx, name);
138
+ add(name, outcome);
139
+ if (outcome.status === "failed")
140
+ return;
141
+ }
142
+ else if (current === "unknown") {
143
+ add(name, { status: "failed", code: "podman_unavailable" });
144
+ return;
145
+ }
146
+ });
147
+ }
148
+ catch (error) {
149
+ // A broken writer is reported on its own target and the others are still reconciled.
150
+ if (INFRASTRUCTURE_CODE.test(codeFor(error)))
151
+ throw error;
152
+ add(name, { status: "failed", code: codeFor(error) });
153
+ }
154
+ }
155
+ for (const name of planned.surplus) {
156
+ if (unfenced.has(name))
157
+ continue;
158
+ const extras = [];
159
+ try {
160
+ add(name, await fenced(ctx, name, deps, (leased) => remove(leased, name, request.discard, deps, extras)));
161
+ }
162
+ catch (error) {
163
+ add(name, { status: "failed", code: codeFor(error) });
164
+ }
165
+ for (const extra of extras)
166
+ findings.push({ target: targetFor(alias, name), ...extra });
167
+ }
168
+ return result("apply", findings);
169
+ }
@@ -1,41 +1,17 @@
1
- import { runDoctor } from "./doctor.js";
2
1
  import { type ModeRequest } from "./setup-modes.js";
3
- import { acquireOverrideLock } from "./override-lock.js";
4
- import { createCommandMcpStdioLauncher } from "./setup-process.js";
5
- import { type OwnershipStore } from "./setup-ownership.js";
6
2
  import { type SetupResult } from "./setup-result.js";
7
- import { type WriterRegistrationAdapter } from "./client-adapters/writer-registration.js";
8
- import { verifyMcpCapabilities } from "./client-adapters/mcp-verifier.js";
9
- import { acquireWriterLease } from "./writer-lease.js";
10
- import { ensureWriterAbsent } from "./writer-fence.js";
11
- import * as lifecycle from "./writer-lifecycle.js";
12
- import * as acceptance from "./writer-acceptance.js";
13
- export declare const SETUP_LEASE_WAIT_MS = 10000;
3
+ import { type WriterSetupDependencies } from "./setup-writers-common.js";
4
+ /**
5
+ * Writer setup entry points: a repository's mode change with its writers (planning and base inference in
6
+ * `setup-writers-plan.ts`, reconciliation in `setup-writers-reconcile.ts`), single-writer operations and the isolation control.
7
+ */
8
+ export { SETUP_LEASE_WAIT_MS, type WriterSetupDependencies } from "./setup-writers-common.js";
14
9
  export type WriterOperation = "collect" | "reset" | "recreate" | "finish" | "remove" | "verify";
15
10
  export interface WriterOptions {
16
11
  apply: boolean;
17
12
  discard: boolean;
18
13
  baseRef?: string;
19
14
  }
20
- export interface WriterSetupDependencies {
21
- configPath: string;
22
- releaseRoot: string;
23
- home?: string;
24
- platform?: NodeJS.Platform;
25
- signal?: AbortSignal;
26
- runner?: lifecycle.WriterContext["runner"];
27
- lifecycle?: typeof lifecycle;
28
- acceptance?: typeof acceptance;
29
- ownership?: OwnershipStore;
30
- adapter?: WriterRegistrationAdapter;
31
- doctor?: typeof runDoctor;
32
- verify?: typeof verifyMcpCapabilities;
33
- launch?: typeof createCommandMcpStdioLauncher;
34
- lock?: typeof acquireOverrideLock;
35
- lease?: typeof acquireWriterLease;
36
- ensureAbsent?: typeof ensureWriterAbsent;
37
- containerRuntime?: () => Promise<boolean>;
38
- }
39
15
  /** Serializes planning, destructive-work refusal, mode commit and writer reconciliation. */
40
16
  export declare function changeWriterMode(configPath: string, request: ModeRequest, deps: WriterSetupDependencies): Promise<SetupResult>;
41
17
  export declare function runWriterOperation(name: string, operation: WriterOperation, opts: WriterOptions, deps: WriterSetupDependencies): Promise<SetupResult>;