@topolo/cli 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-packs/README.md +154 -0
- package/agent-packs/src/topolo_agent_pack/claude.py +265 -0
- package/agent-packs/src/topolo_agent_pack/cli.py +29 -2
- package/agent-packs/src/topolo_agent_pack/execution.py +154 -0
- package/agent-packs/src/topolo_agent_pack/live.py +49 -8
- package/agent-packs/src/topolo_agent_pack/policy.py +92 -0
- package/dist/claude-pack-backend-IDQQX3DG.js +555 -0
- package/dist/index.js +51 -17
- package/package.json +3 -3
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/lib/claude-pack-backend.ts
|
|
4
|
+
import { createHash, randomUUID } from "crypto";
|
|
5
|
+
import { closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from "fs";
|
|
6
|
+
import * as filesystem from "fs";
|
|
7
|
+
import * as cryptography from "crypto";
|
|
8
|
+
import * as paths from "path";
|
|
9
|
+
import { spawn } from "child_process";
|
|
10
|
+
import { resolve, sep } from "path";
|
|
11
|
+
import { WebSocket } from "undici";
|
|
12
|
+
|
|
13
|
+
// src/lib/claude-native-pack.ts
|
|
14
|
+
async function nativeClaudePackOperation(manager, sidebar, plan, apply, checkpoint, verifyContext = () => {
|
|
15
|
+
}) {
|
|
16
|
+
const fenceKey = /* @__PURE__ */ Symbol.for("topolo.agent-pack.native-operation.v1");
|
|
17
|
+
const previous = manager[fenceKey];
|
|
18
|
+
const operationId = plan.operationId ?? "legacy-configuration";
|
|
19
|
+
if (apply && previous) throw new Error("Native operation is unresolved; reconcile its journal before another mutation.");
|
|
20
|
+
if (plan.mode === "recover" && previous && (previous.operationId !== operationId || previous.running))
|
|
21
|
+
throw new Error("Native recovery identity differs or the operation is still running.");
|
|
22
|
+
if (plan.noEffects && (plan.mode !== "recover" || previous?.effectAttempted)) throw new Error("No-effect recovery is not qualified.");
|
|
23
|
+
if (apply) manager[fenceKey] = { operationId, running: true, effectAttempted: false };
|
|
24
|
+
try {
|
|
25
|
+
verifyContext();
|
|
26
|
+
const checkDeadline = () => {
|
|
27
|
+
if (Date.now() >= plan.deadline) throw new Error("Native operation deadline expired; reconcile journal before retry.");
|
|
28
|
+
};
|
|
29
|
+
const command = async (value) => {
|
|
30
|
+
checkDeadline();
|
|
31
|
+
const result = await sidebar(value);
|
|
32
|
+
if (!result.ok) throw new Error(result.error || "Native sidebar acknowledgement refused.");
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
if (!["configure", "switch", "recover"].includes(plan.mode ?? "configure") || plan.mode === "recover" && apply || plan.outgoing?.length && !["switch", "recover"].includes(plan.mode ?? "configure"))
|
|
36
|
+
throw new Error("Unqualified native operation mode.");
|
|
37
|
+
if (plan.mode === "switch" || plan.mode === "recover") {
|
|
38
|
+
if (typeof manager.ensureArchivedSessionsLoaded !== "function") throw new Error("Archived ownership inventory is unavailable.");
|
|
39
|
+
await manager.ensureArchivedSessionsLoaded("ownership");
|
|
40
|
+
}
|
|
41
|
+
if (!plan.members.length || plan.members.length > 100 || (plan.outgoing?.length ?? 0) > 100 || new Set(plan.members.map((m) => m.sessionId)).size !== plan.members.length || new Set(plan.members.map((m) => m.key)).size !== plan.members.length)
|
|
42
|
+
throw new Error("A unique owned roster of 1\u2013100 sessions is required.");
|
|
43
|
+
const models = manager.getAvailableCodeModels();
|
|
44
|
+
const groups = manager.listCustomGroups();
|
|
45
|
+
if (!groups) throw new Error("Native sidebar groups have not been reported by the running window.");
|
|
46
|
+
for (const member of plan.members) {
|
|
47
|
+
if (!/^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(member.sessionId) || !plan.noEffects && (!["claude-opus-5-5", "claude-sonnet-5-5"].includes(member.model) || !["medium", "high"].includes(member.effort) || !["default", "acceptEdits"].includes(member.permissionMode)))
|
|
48
|
+
throw new Error("Unqualified session settings in plan.");
|
|
49
|
+
if (!plan.noEffects && !models.includes(member.model)) throw new Error("Requested model is absent from the native account catalog: " + member.model);
|
|
50
|
+
if (!plan.noEffects && member.group !== null && groups.filter((g) => g.name === member.group).length > 1)
|
|
51
|
+
throw new Error("Ambiguous native sidebar group: " + member.group);
|
|
52
|
+
}
|
|
53
|
+
const snapshot = async (members = plan.members) => {
|
|
54
|
+
const result = [];
|
|
55
|
+
for (const member of members) {
|
|
56
|
+
checkDeadline();
|
|
57
|
+
const session = await manager.getSession(member.sessionId);
|
|
58
|
+
if (!session || session.sessionId !== member.sessionId || session.cwd !== member.cwd || session.title !== member.title || session.isArchived && !["switch", "recover"].includes(plan.mode ?? "configure"))
|
|
59
|
+
throw new Error("Owned session identity or active membership changed: " + member.key);
|
|
60
|
+
if (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length)
|
|
61
|
+
throw new Error("Session has live or pending work; no changes permitted: " + member.key);
|
|
62
|
+
if (!plan.noEffects && (session.backend?.kind !== "local" || session.backend.remoteTarget || session.worktreePath || session.worktreeLazy || session.spawnedFrom))
|
|
63
|
+
throw new Error("Incoming session is not a qualified local role.");
|
|
64
|
+
result.push({
|
|
65
|
+
key: member.key,
|
|
66
|
+
sessionId: session.sessionId,
|
|
67
|
+
cwd: session.cwd,
|
|
68
|
+
title: session.title,
|
|
69
|
+
group: manager.getSessionCustomGroup(member.sessionId)?.name ?? null,
|
|
70
|
+
pinned: session.isStarred === true,
|
|
71
|
+
model: session.model ?? null,
|
|
72
|
+
effort: session.effort ?? null,
|
|
73
|
+
permissionMode: session.permissionMode ?? null,
|
|
74
|
+
archived: session.isArchived === true
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
79
|
+
const before = await snapshot();
|
|
80
|
+
const outgoing = plan.outgoing ?? [];
|
|
81
|
+
const identities = [...plan.members, ...outgoing].map((m) => m.sessionId);
|
|
82
|
+
if (new Set(identities).size !== identities.length) throw new Error("Incoming and outgoing native identities overlap.");
|
|
83
|
+
const outgoingSnapshot = async (members = outgoing) => {
|
|
84
|
+
const result = [];
|
|
85
|
+
for (const member of members) {
|
|
86
|
+
checkDeadline();
|
|
87
|
+
const session = await manager.getSession(member.sessionId);
|
|
88
|
+
if (!/^local_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(member.sessionId) || !session || session.sessionId !== member.sessionId || session.cwd !== member.cwd || session.title !== member.title)
|
|
89
|
+
throw new Error("Outgoing native identity differs.");
|
|
90
|
+
if (manager.hasLosableWork(member.sessionId) || session.isRunning || session.starting || session.pendingToolPermissions?.length)
|
|
91
|
+
throw new Error("Outgoing session has live or pending work.");
|
|
92
|
+
if (!plan.noEffects && (session.backend?.kind !== "local" || session.backend.remoteTarget || session.worktreePath || session.worktreeLazy || session.spawnedFrom || !manager.archiveCascadeClosureOf || manager.archiveCascadeClosureOf(session).length))
|
|
93
|
+
throw new Error("Outgoing native archive closure is not qualified.");
|
|
94
|
+
result.push({ key: member.key, sessionId: member.sessionId, cwd: session.cwd, title: session.title, archived: session.isArchived === true });
|
|
95
|
+
}
|
|
96
|
+
return result;
|
|
97
|
+
};
|
|
98
|
+
const outgoingBefore = await outgoingSnapshot();
|
|
99
|
+
const sections = await command({ kind: "list_sections" });
|
|
100
|
+
checkpoint({ kind: "baseline", before, outgoingBefore, sections: sections.sections, at: Date.now() });
|
|
101
|
+
if (plan.mode === "recover") {
|
|
102
|
+
if (plan.noEffects) {
|
|
103
|
+
verifyContext();
|
|
104
|
+
checkpoint({ kind: "reconciled-no-effects", after: before, at: Date.now() });
|
|
105
|
+
delete manager[fenceKey];
|
|
106
|
+
return { state: "native-reconciled-no-effects", after: before, applied: false, nativeEffectsReplayed: false };
|
|
107
|
+
}
|
|
108
|
+
for (let i = 0; i < plan.members.length; i++) {
|
|
109
|
+
const wanted = plan.members[i];
|
|
110
|
+
const actual = before[i];
|
|
111
|
+
for (const setting of ["group", "pinned", "model", "effort", "permissionMode"])
|
|
112
|
+
if (actual[setting] !== wanted[setting]) throw new Error("Recovery read-back is incomplete; no native effects replayed.");
|
|
113
|
+
if (actual.archived) throw new Error("Recovery incoming session is still archived.");
|
|
114
|
+
}
|
|
115
|
+
if (outgoingBefore.some((s) => !s.archived)) throw new Error("Recovery outgoing roster is incomplete.");
|
|
116
|
+
verifyContext();
|
|
117
|
+
checkpoint({ kind: "reconciled", after: before, outgoingAfter: outgoingBefore, at: Date.now() });
|
|
118
|
+
delete manager[fenceKey];
|
|
119
|
+
return { state: "native-reconciled", after: before, outgoingAfter: outgoingBefore, applied: false, nativeEffectsReplayed: false };
|
|
120
|
+
}
|
|
121
|
+
if (!apply) return { state: "preflight", before, outgoingBefore, sections: sections.sections, availableModels: models, applied: false };
|
|
122
|
+
const effect = async (description, run) => {
|
|
123
|
+
checkDeadline();
|
|
124
|
+
verifyContext();
|
|
125
|
+
const affected = description.sessionIds ?? (description.sessionId ? [description.sessionId] : null);
|
|
126
|
+
await snapshot(affected ? plan.members.filter((m) => affected.includes(m.sessionId)) : plan.members);
|
|
127
|
+
await outgoingSnapshot(affected ? outgoing.filter((m) => affected.includes(m.sessionId)) : outgoing);
|
|
128
|
+
checkpoint({ kind: "attempt", ...description, at: Date.now() });
|
|
129
|
+
manager[fenceKey].effectAttempted = true;
|
|
130
|
+
await run();
|
|
131
|
+
checkpoint({ kind: "acknowledged", ...description, at: Date.now() });
|
|
132
|
+
};
|
|
133
|
+
const groupIds = new Map(groups.map((g) => [g.name, g.id]));
|
|
134
|
+
if (plan.mode === "switch") {
|
|
135
|
+
for (const member of outgoing) {
|
|
136
|
+
if (!(await manager.getSession(member.sessionId)).isArchived)
|
|
137
|
+
await effect({ operation: "archive", sessionId: member.sessionId }, () => manager.archiveSession(
|
|
138
|
+
member.sessionId,
|
|
139
|
+
{ cleanupWorktree: false, forceWorktreeCleanup: false, discardUntracked: false, isAutomatic: true }
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
for (const member of plan.members) {
|
|
143
|
+
const session = await manager.getSession(member.sessionId);
|
|
144
|
+
if (session.isArchived) {
|
|
145
|
+
if (session.backend?.kind !== "local" || session.backend.remoteTarget || session.worktreePath || session.worktreeLazy || session.spawnedFrom)
|
|
146
|
+
throw new Error("Incoming session is not a qualified local role.");
|
|
147
|
+
await effect({ operation: "unarchive", sessionId: member.sessionId }, async () => {
|
|
148
|
+
await manager.unarchiveSession(member.sessionId);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const name of new Set(plan.members.map((m) => m.group).filter((g) => g !== null))) {
|
|
154
|
+
if (!groupIds.has(name)) await effect({ operation: "create_group", name }, async () => {
|
|
155
|
+
const result = await command({ kind: "create_group", name });
|
|
156
|
+
if (!result.groupId) throw new Error("Native create_group did not return an identity.");
|
|
157
|
+
groupIds.set(name, result.groupId);
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
await effect({ operation: "set_view" }, () => command({
|
|
161
|
+
kind: "set_view",
|
|
162
|
+
view: { groupBy: "custom", status: "active", sortBy: "recency", lastActivityDays: 0 }
|
|
163
|
+
}));
|
|
164
|
+
for (const group of new Set(plan.members.map((m) => m.group))) {
|
|
165
|
+
const sessionIds = plan.members.filter((m) => m.group === group).map((m) => m.sessionId);
|
|
166
|
+
await effect({ operation: "move_sessions", group, sessionIds }, () => command({
|
|
167
|
+
kind: "move_sessions",
|
|
168
|
+
sessionIds,
|
|
169
|
+
...group === null ? {} : { groupId: groupIds.get(group) }
|
|
170
|
+
}));
|
|
171
|
+
}
|
|
172
|
+
for (const member of plan.members) {
|
|
173
|
+
await effect({ operation: "set_pinned", sessionId: member.sessionId }, () => command({ kind: "set_pinned", sessionId: member.sessionId, pinned: member.pinned }));
|
|
174
|
+
await effect({ operation: "set_model", sessionId: member.sessionId }, () => manager.setModel(member.sessionId, member.model));
|
|
175
|
+
await effect({ operation: "set_effort", sessionId: member.sessionId }, () => manager.setEffort(member.sessionId, member.effort));
|
|
176
|
+
await effect({ operation: "set_permission_mode", sessionId: member.sessionId }, async () => {
|
|
177
|
+
if (!await manager.setPermissionMode(member.sessionId, member.permissionMode, "picker"))
|
|
178
|
+
throw new Error("Native permission mode was refused: " + member.key);
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const after = await snapshot();
|
|
182
|
+
const outgoingAfter = await outgoingSnapshot();
|
|
183
|
+
if (after.some((s) => s.archived) || outgoingAfter.some((s) => !s.archived)) throw new Error("Native archive membership read-back mismatch.");
|
|
184
|
+
for (let i = 0; i < plan.members.length; i++) {
|
|
185
|
+
const wanted = plan.members[i];
|
|
186
|
+
const actual = after[i];
|
|
187
|
+
for (const setting of ["group", "pinned", "model", "effort", "permissionMode"])
|
|
188
|
+
if (actual[setting] !== wanted[setting]) throw new Error("Native read-back mismatch: " + wanted.key + " / " + setting);
|
|
189
|
+
}
|
|
190
|
+
verifyContext();
|
|
191
|
+
checkpoint({ kind: "verified", after, outgoingAfter, at: Date.now() });
|
|
192
|
+
delete manager[fenceKey];
|
|
193
|
+
return {
|
|
194
|
+
state: "native-settings-verified",
|
|
195
|
+
before,
|
|
196
|
+
after,
|
|
197
|
+
outgoingAfter,
|
|
198
|
+
verifiedAgents: after.length,
|
|
199
|
+
applied: true,
|
|
200
|
+
modelInferenceVerified: false,
|
|
201
|
+
readOnlyConfinementVerified: false,
|
|
202
|
+
fullPackSwitchSupported: plan.mode === "switch"
|
|
203
|
+
};
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (apply) manager[fenceKey] = {
|
|
206
|
+
operationId,
|
|
207
|
+
running: false,
|
|
208
|
+
recoveryRequired: true,
|
|
209
|
+
effectAttempted: manager[fenceKey]?.effectAttempted === true
|
|
210
|
+
};
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/lib/claude-pack-backend.ts
|
|
216
|
+
var CLAUDE_NATIVE_QUALIFIER = {
|
|
217
|
+
version: "2.31226.1",
|
|
218
|
+
manager: "index.chunk-BCHP6ll9.js",
|
|
219
|
+
sidebar: "index.chunk-4-bSRKfE.js",
|
|
220
|
+
hashes: {
|
|
221
|
+
"index.chunk-BCHP6ll9.js": "ec7c7cda3dd597a3cce034cc6eaea52e6e2b10d1b24f0eb9aee3964638afadf2",
|
|
222
|
+
"index.chunk-4-bSRKfE.js": "594213a92b61bb64a3d61695d2370b624981af91f359c18832debb42a7814d86",
|
|
223
|
+
"index.chunk-Cyt2WTko.js": "29738ec09f4bf44d511caae628b0f786e35956848e6a18c3159c9928d6ac1ad9"
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
function validateInspectorEndpoint(endpoint) {
|
|
227
|
+
const url = new URL(endpoint);
|
|
228
|
+
if (url.protocol !== "ws:" || url.hostname !== "127.0.0.1" || !url.port || url.username || url.password || url.search || url.hash || !/^\/[0-9a-f-]{36}$/.test(url.pathname))
|
|
229
|
+
throw new Error("An explicit ws://127.0.0.1:<port>/<inspector UUID> endpoint is required.");
|
|
230
|
+
return url.href;
|
|
231
|
+
}
|
|
232
|
+
function snapshotClaudeMarkdown(root, fs, crypto, path) {
|
|
233
|
+
const inventory = {};
|
|
234
|
+
const visit = (directory) => {
|
|
235
|
+
const stat = fs.lstatSync(directory);
|
|
236
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error("Owned context directory is not regular: " + directory);
|
|
237
|
+
for (const name of fs.readdirSync(directory).sort()) {
|
|
238
|
+
const file = path.resolve(directory, name);
|
|
239
|
+
const entry = fs.lstatSync(file);
|
|
240
|
+
if (entry.isSymbolicLink()) throw new Error("Owned context must not follow symlinks: " + file);
|
|
241
|
+
if (entry.isDirectory()) visit(file);
|
|
242
|
+
else if (/\.md$/i.test(name)) {
|
|
243
|
+
if (!entry.isFile()) throw new Error("Owned Markdown context is not a regular file: " + file);
|
|
244
|
+
inventory[file] = crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
visit(root);
|
|
249
|
+
return inventory;
|
|
250
|
+
}
|
|
251
|
+
function prepareClaudeNativePlan(store, pack, policyPath, mode = "configure") {
|
|
252
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(pack)) throw new Error("Invalid pack name.");
|
|
253
|
+
const root = realpathSync(store);
|
|
254
|
+
const state = JSON.parse(readFileSync(resolve(root, "claude/state.json"), "utf8"));
|
|
255
|
+
const policy = JSON.parse(readFileSync(policyPath, "utf8"));
|
|
256
|
+
if (state.harness !== "claude" || policy.format !== "topolo-agent-execution-policy" || policy.pack !== pack)
|
|
257
|
+
throw new Error("Claude state and reviewed policy must name the same pack.");
|
|
258
|
+
const records = state.agents.filter((a) => a.pack === pack);
|
|
259
|
+
if (!records.length || records.length !== policy.agents.length) throw new Error("Reviewed policy must cover the complete installed roster.");
|
|
260
|
+
const members = [];
|
|
261
|
+
const context = {};
|
|
262
|
+
const contextInventories = {};
|
|
263
|
+
const captureMarkdown = (cwd) => {
|
|
264
|
+
contextInventories[cwd] = snapshotClaudeMarkdown(cwd, filesystem, cryptography, paths);
|
|
265
|
+
Object.assign(context, contextInventories[cwd]);
|
|
266
|
+
};
|
|
267
|
+
for (const record of records) {
|
|
268
|
+
const matches = policy.agents.filter((a) => a.key === record.key && a.title === record.title);
|
|
269
|
+
if (matches.length !== 1 || record.sourceRevision !== policy.sourceRevision || record.importState !== "desktop-adopted")
|
|
270
|
+
throw new Error("Reviewed policy does not match adopted role: " + record.key);
|
|
271
|
+
const role = matches[0];
|
|
272
|
+
const settings = role.harnesses.claude.nativeSettings;
|
|
273
|
+
const cwd = realpathSync(record.workspace);
|
|
274
|
+
if (!cwd.startsWith(resolve(root, "claude/workspaces", pack) + sep)) throw new Error("Role workspace is outside the owned pack.");
|
|
275
|
+
captureMarkdown(cwd);
|
|
276
|
+
const charter = resolve(cwd, "CLAUDE.md");
|
|
277
|
+
const transcript = realpathSync(record.transcript);
|
|
278
|
+
context[charter] = createHash("sha256").update(readFileSync(charter)).digest("hex");
|
|
279
|
+
context[transcript] = createHash("sha256").update(readFileSync(transcript)).digest("hex");
|
|
280
|
+
members.push({
|
|
281
|
+
key: record.key,
|
|
282
|
+
sessionId: record.desktopSessionId,
|
|
283
|
+
cwd,
|
|
284
|
+
title: record.title,
|
|
285
|
+
group: record.pinnedRequested ? null : role.sidebar.group,
|
|
286
|
+
pinned: record.pinnedRequested === true,
|
|
287
|
+
model: settings.model,
|
|
288
|
+
effort: settings.effortLevel,
|
|
289
|
+
permissionMode: settings.permissions.defaultMode
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
for (const file of [resolve(root, "claude/state.json"), realpathSync(policyPath)])
|
|
293
|
+
context[file] = createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
294
|
+
const activePath = resolve(root, "claude/active.json");
|
|
295
|
+
const active = existsSync(activePath) ? JSON.parse(readFileSync(activePath, "utf8")) : null;
|
|
296
|
+
const target = new Set(members.map((m) => m.key));
|
|
297
|
+
const outgoing = [];
|
|
298
|
+
if (active) {
|
|
299
|
+
if (!Array.isArray(active.keys) || new Set(active.keys).size !== active.keys.length) throw new Error("Invalid verified active roster.");
|
|
300
|
+
for (const key of active.keys.filter((key2) => !target.has(key2))) {
|
|
301
|
+
if (mode !== "switch") throw new Error("Use a native switch to replace the verified active roster.");
|
|
302
|
+
const candidates = state.agents.filter((a) => a.key === key && a.importState === "desktop-adopted");
|
|
303
|
+
if (candidates.length !== 1) throw new Error("Outgoing role has no unique owned adoption record.");
|
|
304
|
+
const record = candidates[0];
|
|
305
|
+
const cwd = realpathSync(record.workspace);
|
|
306
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(record.pack)) throw new Error("Invalid outgoing pack name.");
|
|
307
|
+
if (!cwd.startsWith(resolve(root, "claude/workspaces", record.pack) + sep)) throw new Error("Outgoing workspace is outside the owned pack.");
|
|
308
|
+
captureMarkdown(cwd);
|
|
309
|
+
for (const file of [resolve(cwd, "CLAUDE.md"), realpathSync(record.transcript)])
|
|
310
|
+
context[file] = createHash("sha256").update(readFileSync(file)).digest("hex");
|
|
311
|
+
outgoing.push({ key, sessionId: record.desktopSessionId, cwd, title: record.title });
|
|
312
|
+
}
|
|
313
|
+
} else if (mode === "switch") throw new Error("Configure and verify an initial active roster before switching.");
|
|
314
|
+
return { members, outgoing, context, contextInventories, sourceRevision: policy.sourceRevision, mode, activeBefore: active };
|
|
315
|
+
}
|
|
316
|
+
function nativePackExpression(pid, plan, journal, apply) {
|
|
317
|
+
if (!Number.isSafeInteger(pid) || pid < 1) throw new Error("Explicit Claude main-process PID required.");
|
|
318
|
+
const qualifier = CLAUDE_NATIVE_QUALIFIER;
|
|
319
|
+
return `(async () => {
|
|
320
|
+
const expected = ${JSON.stringify({ pid, qualifier, journal, apply })};
|
|
321
|
+
if (process.pid !== expected.pid || typeof process.getBuiltinModule !== 'function') throw new Error('Claude process identity or Node runtime differs.');
|
|
322
|
+
const load = process.getBuiltinModule('module').createRequire(process.resourcesPath + '/app.asar/package.json');
|
|
323
|
+
if (load('electron').app.getVersion() !== expected.qualifier.version) throw new Error('Unqualified Claude Desktop version.');
|
|
324
|
+
const fs = load('node:fs'), crypto = load('node:crypto'), path = load('node:path');
|
|
325
|
+
const base = process.resourcesPath + '/app.asar/.vite/build/';
|
|
326
|
+
for (const [file, hash] of Object.entries(expected.qualifier.hashes)) {
|
|
327
|
+
if (crypto.createHash('sha256').update(fs.readFileSync(base + file)).digest('hex') !== hash) throw new Error('Claude native module hash changed: ' + file);
|
|
328
|
+
}
|
|
329
|
+
const manager = load(base + expected.qualifier.manager).claudeCodeSessionManager;
|
|
330
|
+
const transport = load(base + expected.qualifier.sidebar);
|
|
331
|
+
const checkpoint = value => {
|
|
332
|
+
const fd = fs.openSync(expected.journal, 'a');
|
|
333
|
+
try { fs.writeSync(fd, JSON.stringify(value) + '\\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
334
|
+
};
|
|
335
|
+
const context = ${JSON.stringify(plan.context)};
|
|
336
|
+
const inventories = ${JSON.stringify(plan.contextInventories)};
|
|
337
|
+
const snapshotMarkdown = (${snapshotClaudeMarkdown.toString()});
|
|
338
|
+
const verifyContext = () => {
|
|
339
|
+
if (!inventories || !Object.keys(inventories).length) throw new Error('Complete context inventory proof is unavailable.');
|
|
340
|
+
for (const [root, inventory] of Object.entries(inventories))
|
|
341
|
+
if (JSON.stringify(snapshotMarkdown(root, fs, crypto, path)) !== JSON.stringify(inventory))
|
|
342
|
+
throw new Error('Context or transcript changed during configuration: Markdown inventory ' + root);
|
|
343
|
+
for (const [file, hash] of Object.entries(context))
|
|
344
|
+
if (crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== hash)
|
|
345
|
+
throw new Error('Context or transcript changed during configuration: ' + file);
|
|
346
|
+
};
|
|
347
|
+
return await (${nativeClaudePackOperation.toString()})(manager,
|
|
348
|
+
value => transport.U(value, transport.V),
|
|
349
|
+
{ members: ${JSON.stringify(plan.members)}, outgoing: ${JSON.stringify(plan.outgoing)},
|
|
350
|
+
operationId: ${JSON.stringify(plan.operationId)}, mode: ${JSON.stringify(plan.recovery ? "recover" : plan.mode)},
|
|
351
|
+
noEffects: ${JSON.stringify(plan.noEffects)},
|
|
352
|
+
deadline: Date.now() + 45000 }, expected.apply, checkpoint, verifyContext);
|
|
353
|
+
})()`;
|
|
354
|
+
}
|
|
355
|
+
function evaluateClaudeMain(endpoint, expression, socketFactory = (url) => new WebSocket(url)) {
|
|
356
|
+
const url = validateInspectorEndpoint(endpoint);
|
|
357
|
+
return new Promise((resolveResult, reject) => {
|
|
358
|
+
const socket = socketFactory(url);
|
|
359
|
+
let finished = false;
|
|
360
|
+
const finish = (error, result) => {
|
|
361
|
+
if (finished) return;
|
|
362
|
+
finished = true;
|
|
363
|
+
clearTimeout(timer);
|
|
364
|
+
socket.close();
|
|
365
|
+
if (error) reject(error);
|
|
366
|
+
else resolveResult(result);
|
|
367
|
+
};
|
|
368
|
+
const timer = setTimeout(() => finish(new Error("Claude backend timed out; reconcile the journal before retry.")), 55e3);
|
|
369
|
+
socket.addEventListener("open", () => socket.send(JSON.stringify({
|
|
370
|
+
id: 1,
|
|
371
|
+
method: "Runtime.evaluate",
|
|
372
|
+
params: { expression, awaitPromise: true, returnByValue: true }
|
|
373
|
+
})));
|
|
374
|
+
socket.addEventListener("message", (event) => {
|
|
375
|
+
try {
|
|
376
|
+
const reply = JSON.parse(String(event.data));
|
|
377
|
+
if (reply.id !== 1) return;
|
|
378
|
+
if (reply.error || reply.result?.exceptionDetails)
|
|
379
|
+
finish(new Error(reply.error?.message || reply.result.exceptionDetails.exception?.description || "Native operation failed; reconcile the journal."));
|
|
380
|
+
else finish(void 0, reply.result.result.value);
|
|
381
|
+
} catch {
|
|
382
|
+
finish(new Error("Invalid Claude backend reply; reconcile the journal."));
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
socket.addEventListener("error", () => finish(new Error("Claude backend connection failed.")));
|
|
386
|
+
socket.addEventListener("close", () => finish(new Error("Claude backend disconnected; reconcile the journal.")));
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
async function lockClaudeLibrary(root) {
|
|
390
|
+
const script = 'import os,sys,fcntl\nf=os.open(sys.argv[1],os.O_RDWR|os.O_CREAT|os.O_NOFOLLOW,0o600)\nfcntl.flock(f,fcntl.LOCK_EX|fcntl.LOCK_NB)\nprint("locked",flush=True)\nsys.stdin.buffer.read()\nos.close(f)';
|
|
391
|
+
const child = spawn("python3", ["-c", script, resolve(root, ".lock")], { stdio: ["pipe", "pipe", "pipe"] });
|
|
392
|
+
await new Promise((done, fail) => {
|
|
393
|
+
let ready = false;
|
|
394
|
+
const timer = setTimeout(() => {
|
|
395
|
+
child.kill();
|
|
396
|
+
fail(new Error("Pack library lock timed out."));
|
|
397
|
+
}, 5e3);
|
|
398
|
+
child.once("error", (error) => {
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
fail(error);
|
|
401
|
+
});
|
|
402
|
+
child.stdout.once("data", (data) => {
|
|
403
|
+
if (String(data).trim() === "locked") {
|
|
404
|
+
ready = true;
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
done();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
child.once("exit", () => {
|
|
410
|
+
clearTimeout(timer);
|
|
411
|
+
if (!ready) fail(new Error("Another operation owns the pack library, or its lock is unavailable."));
|
|
412
|
+
});
|
|
413
|
+
child.stderr.resume();
|
|
414
|
+
});
|
|
415
|
+
return async () => {
|
|
416
|
+
await new Promise((done) => {
|
|
417
|
+
if (child.exitCode !== null || child.signalCode !== null) done();
|
|
418
|
+
else {
|
|
419
|
+
child.once("exit", () => done());
|
|
420
|
+
child.stdin.end();
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
function durableRecord(path, value, flag = "a") {
|
|
426
|
+
const fd = openSync(path, flag, 384);
|
|
427
|
+
try {
|
|
428
|
+
writeFileSync(fd, JSON.stringify(value) + "\n");
|
|
429
|
+
fsyncSync(fd);
|
|
430
|
+
} finally {
|
|
431
|
+
closeSync(fd);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function syncDirectory(path) {
|
|
435
|
+
const fd = openSync(path, "r");
|
|
436
|
+
try {
|
|
437
|
+
fsyncSync(fd);
|
|
438
|
+
} finally {
|
|
439
|
+
closeSync(fd);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
function verifyPlanContext(plan) {
|
|
443
|
+
if (!plan.contextInventories || !Object.keys(plan.contextInventories).length) throw new Error("Complete context inventory proof is unavailable.");
|
|
444
|
+
for (const [root, inventory] of Object.entries(plan.contextInventories))
|
|
445
|
+
if (JSON.stringify(snapshotClaudeMarkdown(root, filesystem, cryptography, paths)) !== JSON.stringify(inventory))
|
|
446
|
+
throw new Error("Context or transcript changed during configuration: Markdown inventory " + root);
|
|
447
|
+
for (const [file, hash] of Object.entries(plan.context))
|
|
448
|
+
if (createHash("sha256").update(readFileSync(file)).digest("hex") !== hash) throw new Error("Context or transcript changed during configuration: " + file);
|
|
449
|
+
}
|
|
450
|
+
async function configureClaudePack(options, evaluate = evaluateClaudeMain) {
|
|
451
|
+
validateInspectorEndpoint(options.endpoint);
|
|
452
|
+
const root = realpathSync(options.store);
|
|
453
|
+
if (lstatSync(resolve(root, "claude")).isSymbolicLink()) throw new Error("Claude library must not be a symlink.");
|
|
454
|
+
const release = await lockClaudeLibrary(root);
|
|
455
|
+
try {
|
|
456
|
+
const markerPath = resolve(root, "claude/native-operation.json");
|
|
457
|
+
const recovering = options.mode === "recover";
|
|
458
|
+
if (recovering && options.apply) throw new Error("Recovery is read-only; --apply is not permitted.");
|
|
459
|
+
if (!recovering && existsSync(markerPath)) throw new Error("An unresolved Claude operation requires read-only recovery before another operation.");
|
|
460
|
+
const directory = resolve(root, "claude/journals");
|
|
461
|
+
mkdirSync(directory, { recursive: true, mode: 448 });
|
|
462
|
+
if (lstatSync(directory).isSymbolicLink()) throw new Error("Claude journal directory must not be a symlink.");
|
|
463
|
+
const operationId = randomUUID();
|
|
464
|
+
let plan;
|
|
465
|
+
let journal;
|
|
466
|
+
let marker;
|
|
467
|
+
if (recovering) {
|
|
468
|
+
if (!existsSync(markerPath) || lstatSync(markerPath).isSymbolicLink()) throw new Error("No owned unresolved Claude operation is available.");
|
|
469
|
+
marker = JSON.parse(readFileSync(markerPath, "utf8"));
|
|
470
|
+
journal = realpathSync(marker.journal);
|
|
471
|
+
if (!journal.startsWith(realpathSync(directory) + sep)) throw new Error("Recovery journal is outside the owned library.");
|
|
472
|
+
const lines = readFileSync(journal, "utf8").split("\n").filter(Boolean);
|
|
473
|
+
const line = lines[0];
|
|
474
|
+
if (createHash("sha256").update(line).digest("hex") !== marker.planHash) throw new Error("Recovery plan identity changed.");
|
|
475
|
+
const record = JSON.parse(line);
|
|
476
|
+
if (record.pack !== options.pack || record.operationId !== marker.operationId || !record.apply) throw new Error("Recovery pack or operation identity differs.");
|
|
477
|
+
plan = { ...record, recovery: true, noEffects: marker.format === 2 && marker.dispatchState === "not-dispatched" && !lines.map((line2) => JSON.parse(line2)).some((r) => r.kind === "attempt") };
|
|
478
|
+
} else {
|
|
479
|
+
plan = { ...prepareClaudeNativePlan(root, options.pack, options.policy, options.mode === "switch" ? "switch" : "configure"), operationId };
|
|
480
|
+
journal = resolve(directory, operationId + ".jsonl");
|
|
481
|
+
const record = { kind: "plan", pack: options.pack, ...plan, apply: options.apply };
|
|
482
|
+
durableRecord(journal, record, "wx");
|
|
483
|
+
marker = { format: 2, dispatchState: "not-dispatched", operationId, journal, planHash: createHash("sha256").update(JSON.stringify(record)).digest("hex") };
|
|
484
|
+
if (options.apply) durableRecord(markerPath, marker, "wx");
|
|
485
|
+
syncDirectory(directory);
|
|
486
|
+
syncDirectory(resolve(root, "claude"));
|
|
487
|
+
}
|
|
488
|
+
try {
|
|
489
|
+
verifyPlanContext(plan);
|
|
490
|
+
if (options.apply) {
|
|
491
|
+
const dispatched = { ...marker, dispatchState: "may-have-effects" };
|
|
492
|
+
const temporaryMarker = resolve(root, "claude/native-dispatch-" + operationId + ".json");
|
|
493
|
+
durableRecord(temporaryMarker, dispatched, "wx");
|
|
494
|
+
renameSync(temporaryMarker, markerPath);
|
|
495
|
+
syncDirectory(resolve(root, "claude"));
|
|
496
|
+
marker = dispatched;
|
|
497
|
+
}
|
|
498
|
+
const result = await evaluate(options.endpoint, nativePackExpression(options.pid, plan, journal, options.apply));
|
|
499
|
+
verifyPlanContext(plan);
|
|
500
|
+
if (options.apply || recovering) {
|
|
501
|
+
if (plan.noEffects) {
|
|
502
|
+
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])))
|
|
503
|
+
throw new Error("No-effect recovery has no qualified native acknowledgement.");
|
|
504
|
+
const activePath2 = resolve(root, "claude/active.json");
|
|
505
|
+
const current2 = existsSync(activePath2) ? JSON.parse(readFileSync(activePath2, "utf8")) : null;
|
|
506
|
+
if (JSON.stringify(current2) !== JSON.stringify(plan.activeBefore)) throw new Error("Active roster changed during no-effect recovery.");
|
|
507
|
+
durableRecord(journal, { kind: "context-verified", context: plan.context, nativeEffects: "none-attempted" });
|
|
508
|
+
if (JSON.stringify(JSON.parse(readFileSync(markerPath, "utf8"))) !== JSON.stringify(marker)) throw new Error("Recovery marker identity changed.");
|
|
509
|
+
unlinkSync(markerPath);
|
|
510
|
+
syncDirectory(resolve(root, "claude"));
|
|
511
|
+
return { ...result, journal, contextPreserved: true };
|
|
512
|
+
}
|
|
513
|
+
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.");
|
|
514
|
+
for (let i = 0; i < plan.members.length; i++) {
|
|
515
|
+
const wanted = plan.members[i];
|
|
516
|
+
const actual = result.after[i];
|
|
517
|
+
if (actual.archived !== false || ["key", "sessionId", "cwd", "title", "group", "pinned", "model", "effort", "permissionMode"].some((key) => actual[key] !== wanted[key])) throw new Error("Native result settings do not match the reviewed roster.");
|
|
518
|
+
}
|
|
519
|
+
if (plan.outgoing.length && (result.outgoingAfter?.length !== plan.outgoing.length || result.outgoingAfter.some((actual, i) => actual.archived !== true || ["key", "sessionId", "cwd", "title"].some((key) => actual[key] !== plan.outgoing[i][key]))))
|
|
520
|
+
throw new Error("Native outgoing archive result does not match the reviewed roster.");
|
|
521
|
+
const activePath = resolve(root, "claude/active.json");
|
|
522
|
+
const current = existsSync(activePath) ? JSON.parse(readFileSync(activePath, "utf8")) : null;
|
|
523
|
+
const active = { operationId: plan.operationId, pack: options.pack, keys: plan.members.map((m) => m.key), sourceRevision: plan.sourceRevision };
|
|
524
|
+
if (JSON.stringify(current) !== JSON.stringify(plan.activeBefore) && JSON.stringify(current) !== JSON.stringify(active))
|
|
525
|
+
throw new Error("Verified active roster changed outside this operation.");
|
|
526
|
+
const temporary = resolve(root, "claude/active-" + plan.operationId + ".json");
|
|
527
|
+
if (existsSync(temporary)) {
|
|
528
|
+
if (lstatSync(temporary).isSymbolicLink() || readFileSync(temporary, "utf8") !== JSON.stringify(active) + "\n") throw new Error("Active commit identity changed.");
|
|
529
|
+
} else durableRecord(temporary, active, "wx");
|
|
530
|
+
renameSync(temporary, activePath);
|
|
531
|
+
syncDirectory(resolve(root, "claude"));
|
|
532
|
+
durableRecord(journal, { kind: "context-verified", context: plan.context, active });
|
|
533
|
+
if (JSON.stringify(JSON.parse(readFileSync(markerPath, "utf8"))) !== JSON.stringify(marker)) throw new Error("Recovery marker identity changed.");
|
|
534
|
+
unlinkSync(markerPath);
|
|
535
|
+
syncDirectory(resolve(root, "claude"));
|
|
536
|
+
} else durableRecord(journal, { kind: "context-verified", context: plan.context });
|
|
537
|
+
return { ...result, journal, contextPreserved: true };
|
|
538
|
+
} catch (error) {
|
|
539
|
+
durableRecord(journal, { kind: "recovery-required" });
|
|
540
|
+
throw new Error(`Claude configuration is incomplete. Reconcile ${journal}. ${error instanceof Error ? error.message : String(error)}`);
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
await release();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
export {
|
|
547
|
+
CLAUDE_NATIVE_QUALIFIER,
|
|
548
|
+
configureClaudePack,
|
|
549
|
+
evaluateClaudeMain,
|
|
550
|
+
lockClaudeLibrary,
|
|
551
|
+
nativePackExpression,
|
|
552
|
+
prepareClaudeNativePlan,
|
|
553
|
+
snapshotClaudeMarkdown,
|
|
554
|
+
validateInspectorEndpoint
|
|
555
|
+
};
|