@topolo/cli 0.13.7 → 0.13.9

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.
@@ -24,7 +24,8 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
24
24
  if (plan.noEffects && (plan.mode !== "recover" || previous?.effectAttempted)) throw new Error("No-effect recovery is not qualified.");
25
25
  if (apply) manager[fenceKey] = { operationId, running: true, effectAttempted: false };
26
26
  try {
27
- verifyContext();
27
+ const incremental = (plan.mode ?? "configure") === "configure";
28
+ verifyContext(incremental ? [] : void 0);
28
29
  const checkDeadline = () => {
29
30
  if (Date.now() >= plan.deadline) throw new Error("Native operation deadline expired; reconcile journal before retry.");
30
31
  };
@@ -64,18 +65,28 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
64
65
  const result = [];
65
66
  for (const member of members) {
66
67
  checkDeadline();
67
- const session = await manager.getSession(member.sessionId);
68
+ let session = await manager.getSession(member.sessionId);
68
69
  if (!session || session.sessionId !== member.sessionId || session.cwd !== member.cwd || session.title !== member.title || session.isArchived && !["switch", "recover"].includes(plan.mode ?? "configure"))
69
70
  throw new Error("Owned session identity or active membership changed: " + member.key);
70
- if (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length)
71
+ let descriptor;
72
+ if (!incremental && (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length))
71
73
  throw new Error("Session has live or pending work; no changes permitted: " + member.key);
72
74
  if (!plan.noEffects) {
73
- const descriptor = await nativeDescriptor(member);
74
- if (manager.hasLosableWork(member.sessionId) || descriptor.isRunning || descriptor.starting || descriptor.pendingToolPermissions?.length)
75
+ descriptor = await nativeDescriptor(member);
76
+ session = await manager.getSession(member.sessionId);
77
+ checkDeadline();
78
+ if (!session || session.sessionId !== member.sessionId || session.cwd !== member.cwd || session.title !== member.title || (session.isArchived || descriptor.isArchived) && incremental) throw new Error("Owned session identity or active membership changed: " + member.key);
79
+ if (!incremental && (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length || descriptor.isRunning || descriptor.starting || descriptor.pendingToolPermissions?.length))
75
80
  throw new Error("Session has live or pending work; no changes permitted: " + member.key);
76
81
  if (descriptor.backend?.kind !== "local" || descriptor.backend.remoteTarget || descriptor.worktreePath || descriptor.worktreeLazy || descriptor.spawnedFrom)
77
82
  throw new Error("Incoming session is not a qualified local role.");
78
83
  }
84
+ const busyReasons = [
85
+ ...manager.hasLosableWork(member.sessionId) ? ["losable"] : [],
86
+ ...session.isRunning || descriptor?.isRunning ? ["running"] : [],
87
+ ...session.starting || descriptor?.starting ? ["starting"] : [],
88
+ ...session.pendingToolPermissions?.length || descriptor?.pendingToolPermissions?.length ? ["pending-permissions"] : []
89
+ ];
79
90
  result.push({
80
91
  key: member.key,
81
92
  sessionId: session.sessionId,
@@ -86,7 +97,8 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
86
97
  model: session.model ?? null,
87
98
  effort: session.effort ?? null,
88
99
  permissionMode: session.permissionMode ?? null,
89
- archived: session.isArchived === true
100
+ archived: session.isArchived === true,
101
+ ...incremental ? { busy: busyReasons.length > 0, busyReasons } : {}
90
102
  });
91
103
  }
92
104
  return result;
@@ -118,6 +130,124 @@ async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoi
118
130
  const outgoingBefore = await outgoingSnapshot();
119
131
  const sections = await command({ kind: "list_sections" });
120
132
  checkpoint({ kind: "baseline", before, outgoingBefore, sections: sections.sections, at: Date.now() });
133
+ if (incremental) {
134
+ const settings = ["group", "pinned", "model", "effort", "permissionMode"];
135
+ const idleBefore = plan.members.filter((_, i) => !before[i].busy);
136
+ verifyContext([]);
137
+ if (!apply) return {
138
+ state: "preflight",
139
+ before,
140
+ outgoingBefore,
141
+ sections: sections.sections,
142
+ availableModels: models,
143
+ applied: false,
144
+ contextVerifiedSessionIds: idleBefore.map((m) => m.sessionId)
145
+ };
146
+ const appliedFields = [];
147
+ const deferredReasons = /* @__PURE__ */ new Map();
148
+ const differences = (member, row) => settings.filter((field) => member[field] !== row[field]);
149
+ for (const member of plan.members) {
150
+ const initial = (await snapshot([member]))[0];
151
+ if (initial.busy) {
152
+ deferredReasons.set(member.sessionId, initial.busyReasons);
153
+ continue;
154
+ }
155
+ for (const setting of differences(member, initial)) {
156
+ let row = (await snapshot([member]))[0];
157
+ if (row.busy) {
158
+ deferredReasons.set(member.sessionId, row.busyReasons);
159
+ break;
160
+ }
161
+ if (row[setting] === member[setting]) continue;
162
+ verifyContext([member]);
163
+ if (setting === "group" && member.group !== null) {
164
+ let matching = manager.listCustomGroups()?.filter((g) => g.name === member.group);
165
+ if (!matching || matching.length > 1) throw new Error("Ambiguous native sidebar group: " + member.group);
166
+ if (!matching.length) {
167
+ row = (await snapshot([member]))[0];
168
+ if (row.busy) {
169
+ deferredReasons.set(member.sessionId, row.busyReasons);
170
+ break;
171
+ }
172
+ verifyContext([member]);
173
+ checkDeadline();
174
+ checkpoint({ kind: "attempt", operation: "create_group", key: member.key, name: member.group, at: Date.now() });
175
+ manager[fenceKey].effectAttempted = true;
176
+ const created = await command({ kind: "create_group", name: member.group });
177
+ checkpoint({ kind: "acknowledged", operation: "create_group", key: member.key, name: member.group, at: Date.now() });
178
+ matching = manager.listCustomGroups()?.filter((g) => g.name === member.group);
179
+ if (!created.groupId) throw new Error("Native create_group did not return an identity.");
180
+ if (matching?.length !== 1 || matching[0].id !== created.groupId)
181
+ throw new Error("Native group read-back mismatch.");
182
+ verifyContext([member]);
183
+ appliedFields.push({ key: member.key, operation: "create_group", name: member.group });
184
+ }
185
+ }
186
+ row = (await snapshot([member]))[0];
187
+ if (row.busy) {
188
+ deferredReasons.set(member.sessionId, row.busyReasons);
189
+ break;
190
+ }
191
+ if (row[setting] === member[setting]) continue;
192
+ verifyContext([member]);
193
+ checkDeadline();
194
+ if (setting === "model" && !manager.getAvailableCodeModels().includes(member.model))
195
+ throw new Error("Requested model is absent from the native account catalog: " + member.model);
196
+ const operation = setting === "group" ? "move_sessions" : setting === "pinned" ? "set_pinned" : setting === "model" ? "set_model" : setting === "effort" ? "set_effort" : "set_permission_mode";
197
+ checkpoint({ kind: "attempt", operation, setting, key: member.key, sessionId: member.sessionId, at: Date.now() });
198
+ manager[fenceKey].effectAttempted = true;
199
+ if (setting === "group") {
200
+ const matching = member.group === null ? [] : manager.listCustomGroups()?.filter((g) => g.name === member.group);
201
+ if (!matching || member.group !== null && matching.length !== 1) throw new Error("Native group identity changed.");
202
+ await command({
203
+ kind: "move_sessions",
204
+ sessionIds: [member.sessionId],
205
+ ...member.group === null ? {} : { groupId: matching[0].id }
206
+ });
207
+ } else if (setting === "pinned") await command({ kind: "set_pinned", sessionId: member.sessionId, pinned: member.pinned });
208
+ else if (setting === "model") await manager.setModel(member.sessionId, member.model);
209
+ else if (setting === "effort") await manager.setEffort(member.sessionId, member.effort);
210
+ else if (!await manager.setPermissionMode(member.sessionId, member.permissionMode, "picker"))
211
+ throw new Error("Native permission mode was refused: " + member.key);
212
+ checkpoint({ kind: "acknowledged", operation, setting, key: member.key, sessionId: member.sessionId, at: Date.now() });
213
+ const actual = (await snapshot([member]))[0];
214
+ if (actual[setting] !== member[setting]) throw new Error("Native read-back mismatch: " + member.key + " / " + setting);
215
+ verifyContext([member]);
216
+ appliedFields.push({ key: member.key, sessionId: member.sessionId, setting, value: member[setting] });
217
+ checkpoint({ kind: "verified-field", operation, setting, key: member.key, sessionId: member.sessionId, at: Date.now() });
218
+ }
219
+ }
220
+ const after2 = await snapshot();
221
+ const idleAfter = plan.members.filter((member, i) => !after2[i].busy && !deferredReasons.has(member.sessionId));
222
+ verifyContext(idleAfter);
223
+ const deferred = plan.members.flatMap((member, i) => {
224
+ const row = after2[i], fields = differences(member, row);
225
+ if (!fields.length) return [];
226
+ const reasons = deferredReasons.get(member.sessionId) ?? row.busyReasons;
227
+ if (!reasons?.length) throw new Error("Native read-back mismatch: " + member.key + " / " + fields.join(","));
228
+ return [{ key: member.key, sessionId: member.sessionId, fields, reasons }];
229
+ });
230
+ const attestedBusyKeys = after2.filter((row) => row.busy && !deferred.some((d) => d.sessionId === row.sessionId)).map((row) => row.key);
231
+ checkpoint({ kind: deferred.length ? "partial-verified" : "verified", after: after2, appliedFields, deferred, attestedBusyKeys, at: Date.now() });
232
+ delete manager[fenceKey];
233
+ return {
234
+ state: deferred.length ? "native-settings-partial" : "native-settings-verified",
235
+ before,
236
+ after: after2,
237
+ outgoingAfter: [],
238
+ verifiedAgents: after2.length - deferred.length,
239
+ complete: deferred.length === 0,
240
+ applied: appliedFields.length > 0,
241
+ appliedFields,
242
+ deferred,
243
+ attestedBusyKeys,
244
+ contextVerifiedSessionIds: idleAfter.map((m) => m.sessionId),
245
+ busyObservations: [...deferredReasons].map(([sessionId, reasons]) => ({ key: plan.members.find((m) => m.sessionId === sessionId).key, sessionId, reasons })),
246
+ modelInferenceVerified: false,
247
+ readOnlyConfinementVerified: false,
248
+ fullPackSwitchSupported: false
249
+ };
250
+ }
121
251
  if (plan.mode === "recover") {
122
252
  if (plan.noEffects) {
123
253
  verifyContext();
@@ -672,6 +802,7 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
672
802
  if (!records.length || records.length !== policy.agents.length) throw new Error("Reviewed policy must cover the complete installed roster.");
673
803
  const members = [];
674
804
  const context = {};
805
+ const roleContextFiles = {};
675
806
  const contextInventories = {};
676
807
  const captureMarkdown = (cwd) => {
677
808
  contextInventories[cwd] = snapshotClaudeMarkdown(cwd, filesystem, cryptography, paths);
@@ -690,6 +821,7 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
690
821
  const transcript = realpathSync(record.transcript);
691
822
  context[charter] = createHash("sha256").update(readFileSync(charter)).digest("hex");
692
823
  context[transcript] = createHash("sha256").update(readFileSync(transcript)).digest("hex");
824
+ roleContextFiles[record.desktopSessionId] = [charter, transcript];
693
825
  members.push({
694
826
  key: record.key,
695
827
  sessionId: record.desktopSessionId,
@@ -744,6 +876,8 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
744
876
  outgoing,
745
877
  context,
746
878
  contextInventories,
879
+ roleContextFiles,
880
+ globalContextFiles: [resolve(root, "claude/state.json"), realpathSync(policyPath)],
747
881
  approvalRoutes,
748
882
  installationConsent: policy.installationConsent,
749
883
  libraryRoot: root,
@@ -752,6 +886,27 @@ function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
752
886
  activeBefore: active
753
887
  };
754
888
  }
889
+ function verifyClaudePlanContext(plan, members, fs, crypto, path, snapshotMarkdown) {
890
+ if (!plan.contextInventories || !Object.keys(plan.contextInventories).length) throw new Error("Complete context inventory proof is unavailable.");
891
+ if (members && (!plan.roleContextFiles || !plan.globalContextFiles?.length)) throw new Error("Scoped role context proof is unavailable.");
892
+ const selected = members ?? plan.members;
893
+ if (new Set(selected.map((m) => m.sessionId)).size !== selected.length || selected.some((m) => !plan.members.some((p) => p.sessionId === m.sessionId && p.key === m.key && p.cwd === m.cwd && p.title === m.title)))
894
+ throw new Error("Scoped context identity differs from the complete roster.");
895
+ const roots = members ? selected.map((m) => m.cwd) : Object.keys(plan.contextInventories);
896
+ for (const root of roots) {
897
+ const inventory = plan.contextInventories[root];
898
+ if (!inventory || JSON.stringify(snapshotMarkdown(root, fs, crypto, path)) !== JSON.stringify(inventory))
899
+ throw new Error("Context or transcript changed during configuration: Markdown inventory " + root);
900
+ }
901
+ const files = members ? [...plan.globalContextFiles, ...selected.flatMap((m) => {
902
+ const files2 = plan.roleContextFiles[m.sessionId];
903
+ if (!files2?.length) throw new Error("Scoped role context proof is unavailable.");
904
+ return files2;
905
+ })] : Object.keys(plan.context);
906
+ for (const file of files)
907
+ if (!plan.context[file] || crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex") !== plan.context[file])
908
+ throw new Error("Context or transcript changed during configuration: " + file);
909
+ }
755
910
  function qualifyPermissionConsent(policy, key, records, full) {
756
911
  const consent = policy.installationConsent;
757
912
  const intakeKey = consent?.intakeRoutes?.[key];
@@ -784,15 +939,9 @@ function nativePackExpression(pid, plan, journal, apply) {
784
939
  const context = ${JSON.stringify(plan.context)};
785
940
  const inventories = ${JSON.stringify(plan.contextInventories)};
786
941
  const snapshotMarkdown = (${snapshotClaudeMarkdown.toString()});
787
- const verifyContext = () => {
788
- if (!inventories || !Object.keys(inventories).length) throw new Error('Complete context inventory proof is unavailable.');
789
- for (const [root, inventory] of Object.entries(inventories))
790
- if (JSON.stringify(snapshotMarkdown(root, fs, crypto, path)) !== JSON.stringify(inventory))
791
- throw new Error('Context or transcript changed during configuration: Markdown inventory ' + root);
792
- for (const [file, hash] of Object.entries(context))
793
- if (crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== hash)
794
- throw new Error('Context or transcript changed during configuration: ' + file);
795
- };
942
+ const contextPlan = {context, contextInventories: inventories, members: ${JSON.stringify(plan.members)},
943
+ roleContextFiles: ${JSON.stringify(plan.roleContextFiles)}, globalContextFiles: ${JSON.stringify(plan.globalContextFiles)}};
944
+ const verifyContext = members => (${verifyClaudePlanContext.toString()})(contextPlan, members, fs, crypto, path, snapshotMarkdown);
796
945
  const result = await (${nativeClaudePackOperation.toString()})(manager,
797
946
  value => transport.P(value, transport.M),
798
947
  { members: ${JSON.stringify(plan.members)}, outgoing: ${JSON.stringify(plan.outgoing)},
@@ -815,15 +964,61 @@ function nativePackExpression(pid, plan, journal, apply) {
815
964
  row => checkpoint({kind: 'approval', ...row}),
816
965
  fs.readFileSync(expected.journal, 'utf8').split('\\n').filter(Boolean).map(line => JSON.parse(line)));
817
966
  if (expected.apply) {
818
- result.approvalRouting = await invoke({kind: 'install'});
819
967
  const control = ${JSON.stringify(plan.control)};
820
968
  if (!control) throw new Error('Owned native control setup is unavailable.');
821
- const stat = fs.lstatSync(control.secretPath);
822
- if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 63)) throw new Error('Unowned native control secret.');
823
- const secret = JSON.parse(fs.readFileSync(control.secretPath, 'utf8'));
824
- result.approvalRouting.control = await (${nativeClaudeApprovalControl.toString()})(manager,
825
- {socket: control.socket, pid: expected.pid, identity: fingerprint(routes)}, secret.token,
826
- load('node:net'), fs, invoke, qualifyNative, checkpoint);
969
+ const identity = fingerprint(routes);
970
+ const current = manager[Symbol.for('topolo.agent-pack.approval-control.v1')];
971
+ const listener = manager[Symbol.for('topolo.agent-pack.approvals.v1')];
972
+ const incremental = ${JSON.stringify(plan.mode === "configure" && !plan.recovery)};
973
+ const unchanged = listener?.identity === identity && listener.listener && !listener.auditFailed &&
974
+ current?.descriptor.pid === expected.pid && current.descriptor.identity === identity &&
975
+ current.descriptor.socket === control.socket && current.server?.listening === true && !current.failure;
976
+ if (!incremental) {
977
+ result.approvalRouting = await invoke({kind: 'install'});
978
+ const stat = fs.lstatSync(control.secretPath);
979
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 63)) throw new Error('Unowned native control secret.');
980
+ const secret = JSON.parse(fs.readFileSync(control.secretPath, 'utf8'));
981
+ result.approvalRouting.control = await (${nativeClaudeApprovalControl.toString()})(manager,
982
+ {socket: control.socket, pid: expected.pid, identity}, secret.token,
983
+ load('node:net'), fs, invoke, qualifyNative, checkpoint);
984
+ } else if (unchanged) {
985
+ // Re-attest the owned listener/control; do not replace matching hooks
986
+ // or interrupt an in-flight request merely to configure idle settings.
987
+ result.approvalRouting = {state: 'native-approval-listener-installed', roles: routes.length,
988
+ identity, control: current.descriptor, unchanged: true};
989
+ } else {
990
+ qualifyNative();
991
+ const deferred = [];
992
+ for (const route of routes) {
993
+ const descriptor = await manager.getSessionWhenLoaded(route.sessionId, 'topolo-agent-pack-approval', {warnOnMiss: false});
994
+ const session = await manager.getSession(route.sessionId);
995
+ if (!descriptor || descriptor.sessionId !== route.sessionId || descriptor.cwd !== route.cwd || descriptor.title !== route.title ||
996
+ descriptor.isArchived || descriptor.backend?.kind !== 'local' || descriptor.backend.remoteTarget || descriptor.worktreePath ||
997
+ descriptor.worktreeLazy || descriptor.spawnedFrom || !session || session.sessionId !== route.sessionId ||
998
+ session.cwd !== route.cwd || session.title !== route.title || session.isArchived)
999
+ throw new Error('Native approval route ownership changed.');
1000
+ if (session.pendingToolPermissions?.length || descriptor.pendingToolPermissions?.length)
1001
+ deferred.push({key: route.key, fields: ['intakeHooks'], reason: 'pending-permissions'});
1002
+ }
1003
+ if (current?.active || current?.failure || listener?.auditFailed ||
1004
+ (listener && listener.identity !== identity) ||
1005
+ (current && (current.descriptor.pid !== expected.pid || current.descriptor.identity !== identity)))
1006
+ for (const route of routes) deferred.push({key: route.key, fields: ['intakeHooks'], reason: 'existing-control-custody'});
1007
+ if (deferred.length) {
1008
+ result.routingDeferred = deferred;
1009
+ result.state = 'native-settings-partial';result.complete = false;
1010
+ checkpoint({kind: 'routing-deferred', deferred, at: Date.now()});
1011
+ } else {
1012
+ verifyContext(result.after.filter(row => !row.busy).map(row => contextPlan.members.find(m => m.sessionId === row.sessionId)));
1013
+ result.approvalRouting = await invoke({kind: 'install'});
1014
+ const stat = fs.lstatSync(control.secretPath);
1015
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.uid !== process.getuid() || (stat.mode & 63)) throw new Error('Unowned native control secret.');
1016
+ const secret = JSON.parse(fs.readFileSync(control.secretPath, 'utf8'));
1017
+ result.approvalRouting.control = await (${nativeClaudeApprovalControl.toString()})(manager,
1018
+ {socket: control.socket, pid: expected.pid, identity}, secret.token,
1019
+ load('node:net'), fs, invoke, qualifyNative, checkpoint);
1020
+ }
1021
+ }
827
1022
  } else if (${JSON.stringify(plan.recovery === true && !plan.noEffects)}) {
828
1023
  const current = manager[Symbol.for('topolo.agent-pack.approval-control.v1')];
829
1024
  if (!current || current.descriptor.pid !== expected.pid || current.descriptor.identity !== fingerprint(routes))
@@ -920,13 +1115,8 @@ function syncDirectory(path) {
920
1115
  closeSync(fd);
921
1116
  }
922
1117
  }
923
- function verifyPlanContext(plan) {
924
- if (!plan.contextInventories || !Object.keys(plan.contextInventories).length) throw new Error("Complete context inventory proof is unavailable.");
925
- for (const [root, inventory] of Object.entries(plan.contextInventories))
926
- if (JSON.stringify(snapshotClaudeMarkdown(root, filesystem, cryptography, paths)) !== JSON.stringify(inventory))
927
- throw new Error("Context or transcript changed during configuration: Markdown inventory " + root);
928
- for (const [file, hash] of Object.entries(plan.context))
929
- if (createHash("sha256").update(readFileSync(file)).digest("hex") !== hash) throw new Error("Context or transcript changed during configuration: " + file);
1118
+ function verifyPlanContext(plan, members) {
1119
+ verifyClaudePlanContext(plan, members, filesystem, cryptography, paths, snapshotClaudeMarkdown);
930
1120
  }
931
1121
  function verifyClaudeOrganizationPolicy(root, pack, consent, run = spawnSync, locate = existsSync) {
932
1122
  const here = dirname(fileURLToPath(import.meta.url));
@@ -976,18 +1166,35 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
976
1166
  } else {
977
1167
  plan = { ...prepareClaudeNativePlan(root, options.pack, options.policy, options.mode === "switch" ? "switch" : "configure"), operationId };
978
1168
  if (options.apply && plan.approvalRoutes.length) {
979
- const directory2 = resolve(root, "claude/private");
980
- mkdirSync(directory2, { recursive: true, mode: 448 });
981
- const socketDirectory = resolve("/tmp", "topolo-claude-pack-" + createHash("sha256").update(root).digest("hex").slice(0, 16));
982
- mkdirSync(socketDirectory, { recursive: true, mode: 448 });
983
- for (const path of [directory2, socketDirectory]) {
984
- const stat = lstatSync(path);
985
- if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 63)
986
- throw new Error("Native control directory must be owned and private.");
1169
+ const connectionPath = resolve(root, "claude/approval-connection.json");
1170
+ if (plan.mode === "configure" && existsSync(connectionPath)) {
1171
+ const entry = lstatSync(connectionPath);
1172
+ if (!entry.isFile() || entry.isSymbolicLink() || typeof process.getuid !== "function" || entry.uid !== process.getuid() || entry.mode & 63)
1173
+ throw new Error("Unowned native approval connection.");
1174
+ const connection = JSON.parse(readFileSync(connectionPath, "utf8"));
1175
+ if (connection.pid === options.pid && connection.pack === options.pack && JSON.stringify(connection.routes) === JSON.stringify(plan.approvalRoutes) && JSON.stringify(connection.installationConsent) === JSON.stringify(plan.installationConsent)) {
1176
+ if (typeof connection.secretPath !== "string" || typeof connection.socket !== "string" || !resolve(connection.secretPath).startsWith(realpathSync(resolve(root, "claude/private")) + sep) || realpathSync(connection.secretPath) !== resolve(connection.secretPath) || dirname(resolve(connection.socket)) !== resolve("/tmp", "topolo-claude-pack-" + createHash("sha256").update(root).digest("hex").slice(0, 16)))
1177
+ throw new Error("Native approval connection is outside owned control paths.");
1178
+ const secret = lstatSync(connection.secretPath);
1179
+ if (!secret.isFile() || secret.isSymbolicLink() || typeof process.getuid !== "function" || secret.uid !== process.getuid() || secret.mode & 63)
1180
+ throw new Error("Unowned native control secret.");
1181
+ plan.control = { socket: connection.socket, secretPath: connection.secretPath };
1182
+ }
1183
+ }
1184
+ if (!plan.control) {
1185
+ const directory2 = resolve(root, "claude/private");
1186
+ mkdirSync(directory2, { recursive: true, mode: 448 });
1187
+ const socketDirectory = resolve("/tmp", "topolo-claude-pack-" + createHash("sha256").update(root).digest("hex").slice(0, 16));
1188
+ mkdirSync(socketDirectory, { recursive: true, mode: 448 });
1189
+ for (const path of [directory2, socketDirectory]) {
1190
+ const stat = lstatSync(path);
1191
+ if (!stat.isDirectory() || stat.isSymbolicLink() || stat.uid !== process.getuid?.() || stat.mode & 63)
1192
+ throw new Error("Native control directory must be owned and private.");
1193
+ }
1194
+ plan.control = { socket: resolve(socketDirectory, operationId + ".sock"), secretPath: resolve(directory2, operationId + ".json") };
1195
+ durableRecord(plan.control.secretPath, { token: randomBytes(32).toString("hex") }, "wx");
1196
+ syncDirectory(directory2);
987
1197
  }
988
- plan.control = { socket: resolve(socketDirectory, operationId + ".sock"), secretPath: resolve(directory2, operationId + ".json") };
989
- durableRecord(plan.control.secretPath, { token: randomBytes(32).toString("hex") }, "wx");
990
- syncDirectory(directory2);
991
1198
  }
992
1199
  journal = resolve(directory, operationId + ".jsonl");
993
1200
  const record = { kind: "plan", pack: options.pack, ...plan, apply: options.apply };
@@ -998,7 +1205,8 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
998
1205
  syncDirectory(resolve(root, "claude"));
999
1206
  }
1000
1207
  try {
1001
- verifyPlanContext(plan);
1208
+ const incremental = !recovering && plan.mode === "configure";
1209
+ verifyPlanContext(plan, incremental ? [] : void 0);
1002
1210
  const organizationPolicy = options.apply && plan.approvalRoutes.length ? qualify(root, options.pack, plan.installationConsent) : null;
1003
1211
  if (options.apply) {
1004
1212
  const dispatched = { ...marker, dispatchState: "may-have-effects" };
@@ -1009,7 +1217,18 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
1009
1217
  marker = dispatched;
1010
1218
  }
1011
1219
  const result = await evaluate(options.endpoint, nativePackExpression(options.pid, plan, journal, options.apply));
1012
- verifyPlanContext(plan);
1220
+ if (incremental && result.contextVerifiedSessionIds !== void 0) {
1221
+ if (!Array.isArray(result.contextVerifiedSessionIds) || new Set(result.contextVerifiedSessionIds).size !== result.contextVerifiedSessionIds.length || result.contextVerifiedSessionIds.some((id) => !plan.members.some((m) => m.sessionId === id)))
1222
+ throw new Error("Native scoped context identities differ.");
1223
+ const observations = result.busyObservations ?? [];
1224
+ if (!Array.isArray(observations) || observations.some((row) => !plan.members.some((m) => m.key === row.key && m.sessionId === row.sessionId) || !Array.isArray(row.reasons) || !row.reasons.length || row.reasons.some((reason) => !["running", "starting", "losable", "pending-permissions"].includes(reason))))
1225
+ throw new Error("Native busy context attestation differs.");
1226
+ for (let i = 0; i < plan.members.length; i++)
1227
+ if (!result.contextVerifiedSessionIds.includes(plan.members[i].sessionId) && result.after?.[i]?.busy !== true && !observations.some((row) => row.sessionId === plan.members[i].sessionId))
1228
+ throw new Error("Native result omits idle context qualification.");
1229
+ }
1230
+ const contextMembers = incremental && Array.isArray(result.after) ? plan.members.filter((m, i) => result.after[i]?.busy !== true && (result.contextVerifiedSessionIds === void 0 || result.contextVerifiedSessionIds.includes(m.sessionId))) : void 0;
1231
+ verifyPlanContext(plan, contextMembers);
1013
1232
  if (options.apply || recovering) {
1014
1233
  if (plan.noEffects) {
1015
1234
  if (result.state !== "native-reconciled-no-effects" || result.nativeEffectsReplayed !== false || result.after?.length !== plan.members.length || result.after.some((actual, i) => ["key", "sessionId", "cwd", "title"].some((key) => actual[key] !== plan.members[i][key])))
@@ -1023,6 +1242,46 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
1023
1242
  syncDirectory(resolve(root, "claude"));
1024
1243
  return { ...result, journal, contextPreserved: true };
1025
1244
  }
1245
+ if (incremental && result.state === "native-settings-partial") {
1246
+ const settings = ["group", "pinned", "model", "effort", "permissionMode"];
1247
+ if (result.after?.length !== plan.members.length || !Array.isArray(result.deferred) || !result.deferred.length && !result.routingDeferred?.length) throw new Error("Native partial result has no qualified complete roster.");
1248
+ if (new Set(result.deferred.map((d) => d.sessionId)).size !== result.deferred.length || result.deferred.some((d) => !plan.members.some((m) => m.key === d.key && m.sessionId === d.sessionId) || !Array.isArray(d.fields) || !d.fields.length || new Set(d.fields).size !== d.fields.length || d.fields.some((field) => !settings.includes(field)) || !Array.isArray(d.reasons) || !d.reasons.length || d.reasons.some((reason) => !["running", "starting", "losable", "pending-permissions"].includes(reason))))
1249
+ throw new Error("Native deferred identity or fields differ.");
1250
+ if (result.routingDeferred !== void 0 && (!Array.isArray(result.routingDeferred) || result.routingDeferred.some((d) => !plan.approvalRoutes.some((r) => r.key === d.key) || JSON.stringify(d.fields) !== JSON.stringify(["intakeHooks"]) || !["pending-permissions", "existing-control-custody"].includes(d.reason))))
1251
+ throw new Error("Native deferred routing ownership differs.");
1252
+ for (let i = 0; i < plan.members.length; i++) {
1253
+ const wanted = plan.members[i], actual = result.after[i];
1254
+ if (actual.archived !== false || ["key", "sessionId", "cwd", "title"].some((key) => actual[key] !== wanted[key]))
1255
+ throw new Error("Native partial roster identity differs.");
1256
+ const mismatch = settings.filter((key) => actual[key] !== wanted[key]);
1257
+ const deferred = result.deferred.find((d) => d.sessionId === wanted.sessionId);
1258
+ if (JSON.stringify(mismatch.slice().sort()) !== JSON.stringify((deferred?.fields ?? []).slice().sort()))
1259
+ throw new Error("Native partial result hides an undeferred mismatch.");
1260
+ }
1261
+ const activePath2 = resolve(root, "claude/active.json");
1262
+ const active2 = existsSync(activePath2) ? JSON.parse(readFileSync(activePath2, "utf8")) : null;
1263
+ if (JSON.stringify(active2) !== JSON.stringify(plan.activeBefore)) throw new Error("Active roster changed during partial configuration.");
1264
+ durableRecord(journal, {
1265
+ kind: "partial-verified",
1266
+ after: result.after,
1267
+ deferred: result.deferred,
1268
+ routingDeferred: result.routingDeferred ?? [],
1269
+ appliedFields: result.appliedFields ?? [],
1270
+ activeUnchanged: true
1271
+ });
1272
+ if (JSON.stringify(JSON.parse(readFileSync(markerPath, "utf8"))) !== JSON.stringify(marker)) throw new Error("Recovery marker identity changed.");
1273
+ unlinkSync(markerPath);
1274
+ syncDirectory(resolve(root, "claude"));
1275
+ return {
1276
+ ...result,
1277
+ complete: false,
1278
+ journal,
1279
+ contextPreserved: contextMembers?.length === plan.members.length,
1280
+ idleContextsVerified: contextMembers?.map((m) => m.key),
1281
+ activeRosterUnchanged: true,
1282
+ resume: "explicit-configure-only"
1283
+ };
1284
+ }
1026
1285
  if (result.state !== (recovering ? "native-reconciled" : "native-settings-verified") || result.after?.length !== plan.members.length) throw new Error("Native result has no complete verified roster.");
1027
1286
  for (let i = 0; i < plan.members.length; i++) {
1028
1287
  const wanted = plan.members[i];
@@ -1066,7 +1325,12 @@ async function configureClaudePack(options, evaluate = evaluateClaudeMain, quali
1066
1325
  unlinkSync(markerPath);
1067
1326
  syncDirectory(resolve(root, "claude"));
1068
1327
  } else durableRecord(journal, { kind: "context-verified", context: plan.context });
1069
- return { ...result, journal, contextPreserved: true };
1328
+ return {
1329
+ ...result,
1330
+ journal,
1331
+ contextPreserved: !incremental || contextMembers?.length === plan.members.length,
1332
+ ...incremental ? { idleContextsVerified: contextMembers?.map((m) => m.key) } : {}
1333
+ };
1070
1334
  } catch (error) {
1071
1335
  durableRecord(journal, { kind: "recovery-required" });
1072
1336
  throw new Error(`Claude configuration is incomplete. Reconcile ${journal}. ${error instanceof Error ? error.message : String(error)}`);
@@ -1203,5 +1467,6 @@ export {
1203
1467
  runClaudeSmokeHelper,
1204
1468
  snapshotClaudeMarkdown,
1205
1469
  validateInspectorEndpoint,
1206
- verifyClaudeOrganizationPolicy
1470
+ verifyClaudeOrganizationPolicy,
1471
+ verifyClaudePlanContext
1207
1472
  };