@wrongstack/core 0.302.0 → 0.303.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 (74) hide show
  1. package/README.md +1 -1
  2. package/dist/agent-status-tracker.d.ts +6 -2
  3. package/dist/chronicle/index.js +1949 -1671
  4. package/dist/chronicle/metrics-store.d.ts +14 -0
  5. package/dist/chronicle/project-server-protocol.d.ts +13 -0
  6. package/dist/chronicle/project-server.js +1756 -1573
  7. package/dist/chronicle/rollup-adapter.d.ts +2 -0
  8. package/dist/chronicle/sqlite-journal.d.ts +59 -0
  9. package/dist/coordination/agents/index.js +4313 -3516
  10. package/dist/coordination/agents/project-agent-auto-optimize.d.ts +116 -0
  11. package/dist/coordination/agents/project-agent-capture-window.d.ts +29 -0
  12. package/dist/coordination/agents/project-agent-config-io.d.ts +11 -0
  13. package/dist/coordination/agents/project-agent-consolidation.d.ts +29 -2
  14. package/dist/coordination/agents/project-agent-files.d.ts +12 -3
  15. package/dist/coordination/agents/project-agent-identity-types.d.ts +4 -0
  16. package/dist/coordination/agents/project-agent-identity.d.ts +22 -9
  17. package/dist/coordination/agents/project-agent-learning-entries.d.ts +8 -2
  18. package/dist/coordination/agents/project-agent-learning-structured.d.ts +27 -1
  19. package/dist/coordination/agents/project-agent-optimizer.d.ts +49 -0
  20. package/dist/coordination/agents/project-agent-skill-layer.d.ts +101 -0
  21. package/dist/coordination/agents/role-skills.d.ts +11 -1
  22. package/dist/coordination/index.d.ts +1 -1
  23. package/dist/coordination/index.js +4927 -3589
  24. package/dist/coordination/mail-tools.d.ts +3 -3
  25. package/dist/core/context.d.ts +4 -0
  26. package/dist/core/continue-intent.d.ts +2 -0
  27. package/dist/core/conversation-state.d.ts +5 -0
  28. package/dist/core/index.js +129 -19
  29. package/dist/defaults/index.js +1620 -768
  30. package/dist/execution/index.js +2941 -2630
  31. package/dist/goal/index.js +7 -0
  32. package/dist/index.d.ts +3 -1
  33. package/dist/index.js +12269 -9212
  34. package/dist/infrastructure/index.js +722 -672
  35. package/dist/kernel/events/agent-events.d.ts +28 -0
  36. package/dist/kernel/events/memory-events.d.ts +62 -0
  37. package/dist/plugin/index.js +2167 -1986
  38. package/dist/security/index.js +69 -3
  39. package/dist/security/kanban-boundary.d.ts +5 -1
  40. package/dist/session-catalog/client.d.ts +62 -0
  41. package/dist/session-catalog/endpoint.d.ts +6 -0
  42. package/dist/session-catalog/index.d.ts +6 -0
  43. package/dist/session-catalog/index.js +2000 -0
  44. package/dist/session-catalog/project-server.d.ts +3 -0
  45. package/dist/session-catalog/project-server.js +1861 -0
  46. package/dist/session-catalog/protocol.d.ts +284 -0
  47. package/dist/session-catalog/registry.d.ts +59 -0
  48. package/dist/session-catalog/store.d.ts +71 -0
  49. package/dist/storage/index.d.ts +42 -38
  50. package/dist/storage/index.js +13896 -12931
  51. package/dist/storage/plan-store.d.ts +1 -1
  52. package/dist/storage/session-event-bridge.d.ts +2 -2
  53. package/dist/storage/session-store.d.ts +6 -0
  54. package/dist/tasking/index.js +5 -0
  55. package/dist/tools/index.js +2832 -2606
  56. package/dist/types/config/root.d.ts +11 -1
  57. package/dist/types/config/skills-fleet-brain.d.ts +34 -0
  58. package/dist/types/config/ui.d.ts +14 -0
  59. package/dist/types/config.d.ts +1 -0
  60. package/dist/types/context-evidence.d.ts +2 -0
  61. package/dist/types/index.d.ts +2 -2
  62. package/dist/types/index.js +20 -0
  63. package/dist/types/messages.d.ts +8 -0
  64. package/dist/types/multi-agent.d.ts +7 -0
  65. package/dist/types/session.d.ts +19 -0
  66. package/dist/types/task-graph.d.ts +2 -0
  67. package/dist/types/tool-executor.d.ts +2 -0
  68. package/dist/utils/context-evidence.d.ts +13 -1
  69. package/dist/utils/index.js +29 -2
  70. package/instructions/system-lite.md +23 -8
  71. package/instructions/system-pro.md +29 -9
  72. package/instructions/system.md +29 -9
  73. package/package.json +7 -3
  74. package/skills/wrongstack-kanban/SKILL.md +39 -8
@@ -13,703 +13,753 @@ var __export = (target, all) => {
13
13
  __defProp(target, name, { get: all[name], enumerable: true });
14
14
  };
15
15
 
16
- // src/utils/pid.ts
17
- function isPidAlive(pid) {
18
- if (!Number.isInteger(pid) || pid <= 0) return false;
19
- if (pid === process.pid) return true;
20
- try {
21
- process.kill(pid, 0);
22
- return true;
23
- } catch (err) {
24
- const code = err.code;
25
- if (code === "EPERM") return true;
26
- return false;
27
- }
16
+ // src/session-catalog/protocol.ts
17
+ function encodeSessionCatalogMessage(message) {
18
+ return `${JSON.stringify(message)}
19
+ `;
28
20
  }
29
- var init_pid = __esm({
30
- "src/utils/pid.ts"() {
21
+ var SESSION_CATALOG_PROTOCOL_VERSION, SESSION_CATALOG_MAX_FRAME_CHARS;
22
+ var init_protocol = __esm({
23
+ "src/session-catalog/protocol.ts"() {
31
24
  "use strict";
25
+ SESSION_CATALOG_PROTOCOL_VERSION = 1;
26
+ SESSION_CATALOG_MAX_FRAME_CHARS = 4 * 1024 * 1024;
32
27
  }
33
28
  });
34
29
 
35
- // src/session-registry-atomic-file.ts
36
- import { randomUUID } from "node:crypto";
37
- import * as fs6 from "node:fs/promises";
38
- import { hostname } from "node:os";
30
+ // src/session-catalog/endpoint.ts
31
+ import { createHash as createHash2 } from "node:crypto";
32
+ import * as os3 from "node:os";
39
33
  import * as path8 from "node:path";
40
- function lockOwnerStamp() {
41
- return `${HOST_NAME}:${process.pid}`;
42
- }
43
- async function maybeUnlinkOwnedLock(lockPath) {
44
- const owner = await fs6.readFile(lockPath, "utf8").catch(() => null);
45
- if (owner === null || owner.trim() !== lockOwnerStamp()) return;
46
- for (let attempt = 0; attempt < 3; attempt++) {
47
- try {
48
- await fs6.unlink(lockPath);
49
- return;
50
- } catch {
51
- if (attempt < 2) await new Promise((resolve5) => setTimeout(resolve5, 5));
52
- }
53
- }
54
- }
55
- async function breakStaleLock(lockPath) {
56
- try {
57
- const content = await fs6.readFile(lockPath, "utf8").catch(() => "");
58
- const trimmed = content.trim();
59
- const colonIdx = trimmed.indexOf(":");
60
- if (colonIdx === -1) {
61
- const barePid = Number.parseInt(trimmed, 10);
62
- if (Number.isInteger(barePid) && barePid > 0 && !isPidAlive(barePid)) {
63
- return await breakStaleLockVerified(lockPath, async () => {
64
- const reread = await fs6.readFile(lockPath, "utf8").catch(() => "");
65
- return reread.trim() === trimmed;
66
- });
67
- }
68
- return await breakStaleLockVerified(lockPath, async () => {
69
- const st = await fs6.stat(lockPath);
70
- return Date.now() - st.mtimeMs > STALE_LOCK_MS;
71
- });
72
- }
73
- const ownerHost = trimmed.slice(0, colonIdx);
74
- const ownerPid = Number.parseInt(trimmed.slice(colonIdx + 1), 10);
75
- if (ownerHost === HOST_NAME && Number.isInteger(ownerPid) && ownerPid > 0) {
76
- if (isPidAlive(ownerPid)) {
77
- const stat5 = await fs6.stat(lockPath);
78
- if (Date.now() - stat5.mtimeMs > SAME_HOST_STALE_MS) {
79
- return await breakStaleLockVerified(lockPath, async () => {
80
- const st = await fs6.stat(lockPath);
81
- return Date.now() - st.mtimeMs > SAME_HOST_STALE_MS;
82
- });
83
- }
84
- return false;
85
- }
86
- return await breakStaleLockVerified(lockPath, async () => {
87
- const reread = await fs6.readFile(lockPath, "utf8").catch(() => "");
88
- return reread.trim() === trimmed;
89
- });
90
- }
91
- return await breakStaleLockVerified(lockPath, async () => {
92
- const st = await fs6.stat(lockPath);
93
- return Date.now() - st.mtimeMs > STALE_LOCK_MS;
94
- });
95
- } catch {
96
- return true;
97
- }
34
+ function normalizedPath(value) {
35
+ const resolved = path8.resolve(value);
36
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
98
37
  }
99
- async function breakStaleLockVerified(lockPath, verify) {
100
- if (!await verify()) return false;
101
- return await breakLockAtomically(lockPath) === true;
38
+ function sessionCatalogProjectServerKey(projectDir) {
39
+ return createHash2("sha256").update(normalizedPath(projectDir)).digest("hex").slice(0, 24);
102
40
  }
103
- async function breakLockAtomically(lockPath) {
104
- const tombstone = `${lockPath}.stale-${randomUUID()}.tmp`;
105
- try {
106
- await fs6.rename(lockPath, tombstone);
107
- } catch {
108
- return false;
109
- }
110
- void fs6.unlink(tombstone).catch(() => void 0);
111
- return true;
112
- }
113
- async function writeAtomicFile(filePath, registry) {
114
- const tmp = path8.join(
115
- path8.dirname(filePath),
116
- `.${path8.basename(filePath)}.${randomUUID().slice(0, 8)}.tmp`
117
- );
118
- let tmpPersisted = false;
119
- try {
120
- const handle = await fs6.open(tmp, "w");
121
- try {
122
- await handle.writeFile(JSON.stringify(registry, null, 2), "utf8");
123
- await handle.sync().catch(() => void 0);
124
- } finally {
125
- await handle.close();
126
- }
127
- tmpPersisted = true;
128
- try {
129
- await fs6.rename(tmp, filePath);
130
- tmpPersisted = false;
131
- } catch (renameErr) {
132
- const code = renameErr?.code;
133
- if (code === "EPERM" || code === "EBUSY" || code === "EACCES") {
134
- await fs6.copyFile(tmp, filePath);
135
- try {
136
- const destHandle = await fs6.open(filePath, "r+");
137
- try {
138
- await destHandle.sync().catch(() => void 0);
139
- } finally {
140
- await destHandle.close();
141
- }
142
- } catch {
143
- }
144
- await fs6.unlink(tmp).catch(() => void 0);
145
- tmpPersisted = false;
146
- } else {
147
- throw renameErr;
148
- }
149
- }
150
- } catch (err) {
151
- if (tmpPersisted) await fs6.unlink(tmp).catch(() => void 0);
152
- throw err;
41
+ function sessionCatalogProjectServerEndpoint(projectDir) {
42
+ const key = sessionCatalogProjectServerKey(projectDir);
43
+ if (process.platform === "win32") {
44
+ return `\\\\.\\pipe\\wrongstack-session-catalog-v${SESSION_CATALOG_PROTOCOL_VERSION}-${key}`;
153
45
  }
46
+ return path8.join(os3.tmpdir(), `wssc-v${SESSION_CATALOG_PROTOCOL_VERSION}`, `${key}.sock`);
154
47
  }
155
- async function pruneStaleTempFiles(filePath) {
156
- try {
157
- const dir = path8.dirname(filePath);
158
- const base = path8.basename(filePath);
159
- const now = Date.now();
160
- const stale = [];
161
- for (const name of await fs6.readdir(dir)) {
162
- const isTemp = (name.startsWith(`${base}.`) || name.startsWith(`.${base}.`)) && name.endsWith(".tmp");
163
- if (!isTemp) continue;
164
- const stat5 = await fs6.stat(path8.join(dir, name)).catch(() => null);
165
- if (!stat5) continue;
166
- if (now - stat5.mtimeMs > STALE_TMP_MS) stale.push({ name, mtimeMs: stat5.mtimeMs });
167
- }
168
- stale.sort((a, b) => b.mtimeMs - a.mtimeMs);
169
- await Promise.all(
170
- stale.slice(MAX_STALE_TMP_FILES).map(async ({ name }) => {
171
- await fs6.unlink(path8.join(dir, name)).catch(() => void 0);
172
- })
173
- );
174
- } catch {
175
- }
48
+ function sessionCatalogProjectServerMetadataPath(projectDir) {
49
+ return path8.join(projectDir, SESSION_CATALOG_METADATA_FILE);
176
50
  }
177
- var STALE_LOCK_MS, SAME_HOST_STALE_MS, STALE_TMP_MS, MAX_STALE_TMP_FILES, HOST_NAME;
178
- var init_session_registry_atomic_file = __esm({
179
- "src/session-registry-atomic-file.ts"() {
51
+ var SESSION_CATALOG_METADATA_FILE;
52
+ var init_endpoint = __esm({
53
+ "src/session-catalog/endpoint.ts"() {
180
54
  "use strict";
181
- init_pid();
182
- STALE_LOCK_MS = 1e4;
183
- SAME_HOST_STALE_MS = 2 * STALE_LOCK_MS;
184
- STALE_TMP_MS = 6e4;
185
- MAX_STALE_TMP_FILES = 20;
186
- HOST_NAME = hostname();
55
+ init_protocol();
56
+ SESSION_CATALOG_METADATA_FILE = ".session-catalog-server.json";
187
57
  }
188
58
  });
189
59
 
190
- // src/session-registry.ts
191
- var session_registry_exports = {};
192
- __export(session_registry_exports, {
193
- SessionRegistry: () => SessionRegistry,
194
- getSessionRegistry: () => getSessionRegistry,
195
- hasSessionRegistry: () => hasSessionRegistry
196
- });
197
- import * as fs7 from "node:fs/promises";
60
+ // src/session-catalog/client.ts
61
+ import { spawn } from "node:child_process";
62
+ import * as fs6 from "node:fs";
63
+ import * as net from "node:net";
198
64
  import * as path9 from "node:path";
199
- function sameOwner(entry, owner) {
200
- return entry.pid === owner.pid && entry.startedAt === owner.startedAt;
201
- }
202
- function parseRegistry(raw) {
203
- try {
204
- const parsed = JSON.parse(raw);
205
- if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
206
- return parsed;
65
+ import { fileURLToPath } from "node:url";
66
+ function locateServer(moduleUrl, exists) {
67
+ for (const candidate of [
68
+ "./project-server.js",
69
+ "../session-catalog/project-server.js",
70
+ "./session-catalog/project-server.js",
71
+ "../../dist/session-catalog/project-server.js"
72
+ ]) {
73
+ try {
74
+ const url = new URL(candidate, moduleUrl);
75
+ if (url.protocol === "file:" && exists(fileURLToPath(url))) return url;
76
+ } catch {
207
77
  }
208
- } catch {
209
78
  }
210
- return {};
79
+ return null;
211
80
  }
212
- function deriveSessionStatus(agents) {
213
- const hasRunning = agents.some((a) => a.status === "running" || a.status === "streaming");
214
- const hasWaiting = agents.some((a) => a.status === "waiting_user");
215
- const hasError = agents.some((a) => a.status === "error");
216
- return hasRunning || hasWaiting || hasError ? "active" : "idle";
81
+ function resolveSessionCatalogDaemonAvailability(moduleUrl = import.meta.url, exists = fs6.existsSync) {
82
+ if (process.env["WRONGSTACK_SESSION_CATALOG_INLINE"] || process.env["WRONGSTACK_SESSION_CATALOG_SERVER"] === "0")
83
+ return { kind: "inline-requested" };
84
+ const url = locateServer(moduleUrl, exists);
85
+ return url ? { kind: "available", url } : { kind: "missing-build" };
217
86
  }
218
- function getSessionRegistry(globalRoot) {
219
- if (!_instance && globalRoot) {
220
- _instance = new SessionRegistry(globalRoot);
221
- }
222
- if (!_instance) {
223
- throw new Error("SessionRegistry not initialized. Call getSessionRegistry(globalRoot) first.");
224
- }
225
- return _instance;
87
+ function resolveSessionCatalogProjectServerUrl(moduleUrl = import.meta.url, exists = fs6.existsSync) {
88
+ const availability = resolveSessionCatalogDaemonAvailability(moduleUrl, exists);
89
+ return availability.kind === "available" ? availability.url : null;
90
+ }
91
+ function normalize2(value) {
92
+ const resolved = path9.resolve(value);
93
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
226
94
  }
227
- function hasSessionRegistry() {
228
- return _instance !== null;
95
+ function delay(ms) {
96
+ return new Promise((resolve8) => setTimeout(resolve8, ms));
229
97
  }
230
- var REGISTRY_FILE, HEARTBEAT_INTERVAL_MS, STALE_TIMEOUT_MS, PID_CHECK_AFTER_MS, CLOSING_GRACE_MS, AGENT_STALE_MS, OWNERSHIP_LOCK_WAIT_MS, OPTIONAL_LOCK_WAIT_MS, LOCK_RETRY_MAX_MS, AGENTS_WRITE_THROTTLE_MS, SessionOwnershipConflictError, SessionRegistryWriteError, TEMP_PRUNE_INTERVAL_MS, pidAlive, SessionRegistry, _instance;
231
- var init_session_registry = __esm({
232
- "src/session-registry.ts"() {
98
+ var CONNECT_TIMEOUT_MS, START_TIMEOUT_MS, CALL_TIMEOUT_MS, MAX_PENDING_REQUESTS, MAX_EVENT_LISTENERS, SessionCatalogProjectClient;
99
+ var init_client = __esm({
100
+ "src/session-catalog/client.ts"() {
233
101
  "use strict";
234
- init_session_registry_atomic_file();
235
- init_pid();
236
- REGISTRY_FILE = "session-registry.json";
237
- HEARTBEAT_INTERVAL_MS = 5e3;
238
- STALE_TIMEOUT_MS = 3e4;
239
- PID_CHECK_AFTER_MS = HEARTBEAT_INTERVAL_MS * 2;
240
- CLOSING_GRACE_MS = 15e3;
241
- AGENT_STALE_MS = 5 * 6e4;
242
- OWNERSHIP_LOCK_WAIT_MS = 8e3;
243
- OPTIONAL_LOCK_WAIT_MS = 750;
244
- LOCK_RETRY_MAX_MS = 100;
245
- AGENTS_WRITE_THROTTLE_MS = 300;
246
- SessionOwnershipConflictError = class extends Error {
247
- };
248
- SessionRegistryWriteError = class extends Error {
249
- };
250
- TEMP_PRUNE_INTERVAL_MS = STALE_TMP_MS;
251
- pidAlive = isPidAlive;
252
- SessionRegistry = class {
253
- filePath;
254
- heartbeatTimer = null;
255
- currentSessionId = null;
256
- lastTempPruneAt = 0;
257
- tempPrunePromise = null;
258
- /** Latest agent snapshot not yet written; superseded by newer calls. */
259
- pendingAgents = null;
260
- /** Shared settle promise for all updateAgents calls coalesced into one trailing write. */
261
- pendingAgentsFlush = null;
262
- pendingAgentsResolve = null;
263
- agentsFlushTimer = null;
264
- lastAgentsWriteAt = 0;
265
- /**
266
- * Last full entry this process registered. Kept so the heartbeat can
267
- * re-create our entry if it ever goes missing because the file was reset or
268
- * a reader pruned a stale snapshot.
269
- */
270
- lastEntry = null;
271
- ownershipLockWaitMs;
272
- constructor(globalRoot, options = {}) {
273
- this.filePath = path9.join(globalRoot, REGISTRY_FILE);
274
- this.ownershipLockWaitMs = Math.max(0, options.ownershipLockWaitMs ?? OWNERSHIP_LOCK_WAIT_MS);
102
+ init_endpoint();
103
+ init_protocol();
104
+ CONNECT_TIMEOUT_MS = 750;
105
+ START_TIMEOUT_MS = 1e4;
106
+ CALL_TIMEOUT_MS = 3e4;
107
+ MAX_PENDING_REQUESTS = 1024;
108
+ MAX_EVENT_LISTENERS = 64;
109
+ SessionCatalogProjectClient = class {
110
+ constructor(options) {
111
+ this.options = options;
112
+ this.endpoint = sessionCatalogProjectServerEndpoint(options.projectDir);
113
+ }
114
+ options;
115
+ endpoint;
116
+ socket = null;
117
+ info = null;
118
+ buffer = "";
119
+ connecting = null;
120
+ connectResolve = null;
121
+ connectReject = null;
122
+ authToken;
123
+ nextId = 1;
124
+ pending = /* @__PURE__ */ new Map();
125
+ eventListeners = /* @__PURE__ */ new Set();
126
+ reconnectTimer;
127
+ explicitlyClosed = false;
128
+ async call(op, args, options = {}) {
129
+ await this.ensureConnected(true);
130
+ return this.request({ type: "request", op, args }, options.timeoutMs ?? CALL_TIMEOUT_MS);
275
131
  }
276
- // ── Public API ──────────────────────────────────────────────────────────
277
132
  /**
278
- * Register the current session. Call once on session start.
279
- * Starts the heartbeat timer.
133
+ * Call an already-running project daemon without starting one.
134
+ *
135
+ * Cross-project discovery must use this path: observing another project is
136
+ * never sufficient authority to wake that project's IPC owner.
280
137
  */
281
- async register(entry) {
282
- const full = {
283
- ...entry,
284
- status: "active",
285
- lastHeartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
286
- agentCount: entry.agents?.length ?? 0,
287
- agents: entry.agents ?? []
138
+ async callExisting(op, args, options = {}) {
139
+ await this.ensureConnected(false);
140
+ return this.request({ type: "request", op, args }, options.timeoutMs ?? CALL_TIMEOUT_MS);
141
+ }
142
+ ping() {
143
+ return this.call("ping", {}, { timeoutMs: 3e3 });
144
+ }
145
+ async subscribe(listener) {
146
+ if (!this.eventListeners.has(listener) && this.eventListeners.size >= MAX_EVENT_LISTENERS) {
147
+ throw new Error(`Session Catalog listener limit reached (${MAX_EVENT_LISTENERS})`);
148
+ }
149
+ this.explicitlyClosed = false;
150
+ this.eventListeners.add(listener);
151
+ try {
152
+ await this.call("subscribe", {});
153
+ } catch (error) {
154
+ this.eventListeners.delete(listener);
155
+ throw error;
156
+ }
157
+ return async () => {
158
+ this.eventListeners.delete(listener);
159
+ if (this.eventListeners.size === 0)
160
+ await this.callExisting("unsubscribe", {}).catch(() => void 0);
288
161
  };
289
- const existingRegistry = await this.readAndPrune();
290
- const currentOwner = existingRegistry[entry.sessionId];
291
- if (currentOwner && currentOwner.pid !== entry.pid && pidAlive(currentOwner.pid)) {
292
- throw new SessionOwnershipConflictError(
293
- `Session ${entry.sessionId} is already open in another running wstack (pid ${currentOwner.pid}).`
294
- );
162
+ }
163
+ async shutdown(reason) {
164
+ try {
165
+ await this.ensureConnected(false);
166
+ } catch {
167
+ return { stopped: false };
295
168
  }
296
- await this.atomicUpdate((registry) => {
297
- const now = Date.now();
298
- for (const [id, existing] of Object.entries(registry)) {
299
- if (existing.pid === entry.pid) {
300
- if (id !== entry.sessionId) delete registry[id];
301
- continue;
302
- }
303
- const heartbeatAge = now - new Date(existing.lastHeartbeatAt).getTime();
304
- if (heartbeatAge > PID_CHECK_AFTER_MS && !pidAlive(existing.pid)) {
305
- delete registry[id];
169
+ const result = await this.request({ type: "shutdown", ...reason !== void 0 ? { reason } : {} }, 5e3).catch(
170
+ () => ({ stopped: false })
171
+ );
172
+ if (result.stopped) {
173
+ const metadataPath = sessionCatalogProjectServerMetadataPath(this.options.projectDir);
174
+ const deadline = Date.now() + 5e3;
175
+ while (fs6.existsSync(metadataPath) && Date.now() < deadline) await delay(20);
176
+ if (result.pid && result.pid !== process.pid) {
177
+ while (Date.now() < deadline) {
178
+ try {
179
+ process.kill(result.pid, 0);
180
+ await delay(20);
181
+ } catch {
182
+ break;
183
+ }
306
184
  }
307
185
  }
308
- const lockedOwner = registry[entry.sessionId];
309
- if (lockedOwner && lockedOwner.pid !== entry.pid && pidAlive(lockedOwner.pid)) {
310
- throw new SessionOwnershipConflictError(
311
- `Session ${entry.sessionId} is already open in another running wstack (pid ${lockedOwner.pid}).`
312
- );
313
- }
314
- registry[entry.sessionId] = full;
315
- }, true);
316
- this.cancelPendingAgentsFlush();
317
- if (this.heartbeatTimer) {
318
- clearInterval(this.heartbeatTimer);
319
- this.heartbeatTimer = null;
320
186
  }
321
- this.lastAgentsWriteAt = 0;
322
- this.currentSessionId = entry.sessionId;
323
- this.lastEntry = full;
324
- this.heartbeatTimer = setInterval(() => {
325
- void this.heartbeat();
326
- }, HEARTBEAT_INTERVAL_MS);
327
- if (this.heartbeatTimer.unref) this.heartbeatTimer.unref();
187
+ return result;
328
188
  }
329
- /**
330
- * Update agent status for the current session. Call on every
331
- * significant status change (agent start, tool start, user wait, error).
332
- *
333
- * Writes are coalesced: the first call in a quiet window writes
334
- * immediately; calls arriving within {@link AGENTS_WRITE_THROTTLE_MS} of
335
- * the last write collapse into one trailing write carrying the newest
336
- * snapshot. The in-memory cache ({@link lastEntry}) is always updated
337
- * synchronously, so heartbeat re-inserts never carry stale agents.
338
- */
339
- async updateAgents(agents) {
340
- if (!this.currentSessionId) return;
341
- this.pendingAgents = agents;
342
- if (this.lastEntry) {
343
- this.lastEntry.agents = agents;
344
- this.lastEntry.agentCount = agents.length;
345
- this.lastEntry.status = deriveSessionStatus(agents);
346
- this.lastEntry.lastHeartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
347
- }
348
- const sinceLastWrite = Date.now() - this.lastAgentsWriteAt;
349
- if (!this.agentsFlushTimer && sinceLastWrite >= AGENTS_WRITE_THROTTLE_MS) {
350
- await this.writeAgentsSnapshot();
351
- return;
352
- }
353
- if (!this.agentsFlushTimer) {
354
- const delay = Math.max(0, AGENTS_WRITE_THROTTLE_MS - sinceLastWrite);
355
- this.pendingAgentsFlush = new Promise((resolve5, reject) => {
356
- this.pendingAgentsResolve = resolve5;
357
- const timer = setTimeout(() => {
358
- this.agentsFlushTimer = null;
359
- this.pendingAgentsFlush = null;
360
- this.pendingAgentsResolve = null;
361
- this.writeAgentsSnapshot().then(resolve5, reject);
362
- }, delay);
363
- if (typeof timer.unref === "function") timer.unref();
364
- this.agentsFlushTimer = timer;
189
+ async close() {
190
+ this.explicitlyClosed = true;
191
+ if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
192
+ this.reconnectTimer = void 0;
193
+ const socket = this.socket;
194
+ this.socket = null;
195
+ this.info = null;
196
+ if (socket && !socket.destroyed)
197
+ await new Promise((resolve8) => {
198
+ socket.once("close", resolve8);
199
+ socket.end();
365
200
  });
366
- }
367
- await this.pendingAgentsFlush;
368
201
  }
369
- /** Write the newest pending agent snapshot to the registry file. */
370
- async writeAgentsSnapshot() {
371
- const agents = this.pendingAgents;
372
- const sessionId = this.currentSessionId;
373
- const owner = this.lastEntry;
374
- if (!agents || !sessionId || !owner || owner.sessionId !== sessionId) return;
375
- this.pendingAgents = null;
376
- this.lastAgentsWriteAt = Date.now();
377
- const status = deriveSessionStatus(agents);
378
- const nowIso = (/* @__PURE__ */ new Date()).toISOString();
379
- await this.atomicUpdate((registry) => {
380
- if (this.currentSessionId !== sessionId || this.lastEntry !== owner) return;
381
- let entry = registry[sessionId];
382
- if (entry && !sameOwner(entry, owner)) return;
383
- if (!entry) {
384
- entry = { ...owner };
385
- registry[sessionId] = entry;
202
+ async ensureConnected(spawnIfMissing) {
203
+ if (this.socket && !this.socket.destroyed && this.info) return;
204
+ if (this.connecting) return this.connecting;
205
+ this.connecting = this.connectWithElection(spawnIfMissing).finally(() => {
206
+ this.connecting = null;
207
+ });
208
+ return this.connecting;
209
+ }
210
+ async connectWithElection(spawnIfMissing) {
211
+ const deadline = Date.now() + (spawnIfMissing ? START_TIMEOUT_MS : CONNECT_TIMEOUT_MS);
212
+ let spawned = false;
213
+ let lastError = new Error("Session Catalog project server unavailable");
214
+ while (Date.now() < deadline) {
215
+ try {
216
+ await this.connectOnce();
217
+ return;
218
+ } catch (error) {
219
+ lastError = error;
220
+ }
221
+ if (!spawnIfMissing) break;
222
+ if (!spawned) {
223
+ this.spawnDetached();
224
+ spawned = true;
386
225
  }
387
- entry.agents = agents;
388
- entry.agentCount = agents.length;
389
- entry.status = status;
390
- entry.lastHeartbeatAt = nowIso;
226
+ await delay(75);
227
+ }
228
+ throw lastError;
229
+ }
230
+ connectOnce() {
231
+ this.socket?.destroy();
232
+ this.socket = null;
233
+ this.info = null;
234
+ this.authToken = void 0;
235
+ this.buffer = "";
236
+ return new Promise((resolve8, reject) => {
237
+ const socket = net.createConnection(this.endpoint);
238
+ this.socket = socket;
239
+ socket.setEncoding("utf8");
240
+ const timer = setTimeout(() => {
241
+ reject(new Error("Session Catalog handshake timed out"));
242
+ socket.destroy();
243
+ }, CONNECT_TIMEOUT_MS);
244
+ timer.unref?.();
245
+ this.connectResolve = () => {
246
+ clearTimeout(timer);
247
+ this.connectResolve = null;
248
+ this.connectReject = null;
249
+ resolve8();
250
+ };
251
+ this.connectReject = (error) => {
252
+ clearTimeout(timer);
253
+ this.connectResolve = null;
254
+ this.connectReject = null;
255
+ reject(error);
256
+ };
257
+ socket.on("data", (chunk) => this.onData(socket, chunk));
258
+ socket.on("error", (error) => {
259
+ if (!this.info) this.connectReject?.(error);
260
+ });
261
+ socket.on("close", () => this.onClose(socket));
391
262
  });
392
263
  }
393
- /**
394
- * Cancel a scheduled trailing agents write (releasing any waiters). Used on
395
- * shutdown so a late timer can't resurrect an entry that markClosing() /
396
- * unregister() is about to finalize. Returns the snapshot that was pending.
397
- */
398
- cancelPendingAgentsFlush() {
399
- if (this.agentsFlushTimer) {
400
- clearTimeout(this.agentsFlushTimer);
401
- this.agentsFlushTimer = null;
264
+ currentAuthToken() {
265
+ if (this.authToken === void 0) {
266
+ try {
267
+ const parsed = JSON.parse(
268
+ fs6.readFileSync(sessionCatalogProjectServerMetadataPath(this.options.projectDir), "utf8")
269
+ );
270
+ if (typeof parsed.authToken === "string" && parsed.authToken)
271
+ this.authToken = parsed.authToken;
272
+ } catch {
273
+ }
402
274
  }
403
- this.pendingAgentsResolve?.();
404
- this.pendingAgentsResolve = null;
405
- this.pendingAgentsFlush = null;
406
- const pending = this.pendingAgents;
407
- this.pendingAgents = null;
408
- return pending;
275
+ return this.authToken;
409
276
  }
410
- /**
411
- * Mark the session as closing. Called during shutdown.
412
- * Stops the heartbeat timer.
413
- */
414
- async markClosing() {
415
- if (this.heartbeatTimer) {
416
- clearInterval(this.heartbeatTimer);
417
- this.heartbeatTimer = null;
277
+ request(message, timeoutMs) {
278
+ const socket = this.socket;
279
+ if (!socket || socket.destroyed)
280
+ return Promise.reject(new Error("Session Catalog connection is unavailable"));
281
+ const id = this.nextId++;
282
+ if (this.pending.size >= MAX_PENDING_REQUESTS) {
283
+ return Promise.reject(
284
+ new Error(`Session Catalog pending request limit reached (${MAX_PENDING_REQUESTS})`)
285
+ );
418
286
  }
419
- if (!this.currentSessionId) return;
420
- const sessionId = this.currentSessionId;
421
- const owner = this.lastEntry;
422
- if (!owner) return;
423
- const pendingAgents = this.cancelPendingAgentsFlush();
424
- await this.atomicUpdate((registry) => {
425
- const entry = registry[sessionId];
426
- if (!entry || !sameOwner(entry, owner)) return;
427
- if (pendingAgents) {
428
- entry.agents = pendingAgents;
429
- entry.agentCount = pendingAgents.length;
430
- }
431
- entry.status = "closing";
432
- entry.lastHeartbeatAt = (/* @__PURE__ */ new Date()).toISOString();
287
+ const encoded = encodeSessionCatalogMessage({
288
+ ...message,
289
+ id,
290
+ authToken: this.currentAuthToken()
291
+ });
292
+ if (encoded.length > SESSION_CATALOG_MAX_FRAME_CHARS)
293
+ return Promise.reject(new Error("Session Catalog request exceeded frame limit"));
294
+ return new Promise((resolve8, reject) => {
295
+ const timer = setTimeout(() => {
296
+ const pending = this.pending.get(id);
297
+ if (!pending) return;
298
+ this.pending.delete(id);
299
+ pending.reject(
300
+ new Error(
301
+ `Session Catalog ${message.type === "request" ? message.op : message.type} timed out`
302
+ )
303
+ );
304
+ }, timeoutMs);
305
+ timer.unref?.();
306
+ this.pending.set(id, { resolve: resolve8, reject, timer });
307
+ socket.write(encoded);
433
308
  });
434
309
  }
435
- /**
436
- * Remove the current session from the registry. Call on clean exit.
437
- */
438
- async unregister() {
439
- if (this.heartbeatTimer) {
440
- clearInterval(this.heartbeatTimer);
441
- this.heartbeatTimer = null;
310
+ onData(socket, chunk) {
311
+ if (socket !== this.socket) return;
312
+ this.buffer += chunk;
313
+ if (this.buffer.length > SESSION_CATALOG_MAX_FRAME_CHARS) {
314
+ socket.destroy(new Error("Session Catalog response exceeded frame limit"));
315
+ return;
442
316
  }
443
- if (!this.currentSessionId) return;
444
- this.cancelPendingAgentsFlush();
445
- const sid = this.currentSessionId;
446
- const owner = this.lastEntry;
447
- this.currentSessionId = null;
448
- this.lastEntry = null;
449
- await this.atomicUpdate((registry) => {
450
- const entry = registry[sid];
451
- if (owner && entry && sameOwner(entry, owner)) {
452
- delete registry[sid];
317
+ while (true) {
318
+ const newline = this.buffer.indexOf("\n");
319
+ if (newline < 0) return;
320
+ const line = this.buffer.slice(0, newline);
321
+ this.buffer = this.buffer.slice(newline + 1);
322
+ if (!line) continue;
323
+ try {
324
+ this.onMessage(JSON.parse(line));
325
+ } catch {
326
+ socket.destroy(new Error("Invalid Session Catalog response"));
327
+ return;
453
328
  }
454
- });
329
+ }
455
330
  }
456
- /**
457
- * List all non-stale sessions. Prunes stale entries automatically.
458
- */
459
- async list() {
460
- const registry = await this.readAndPrune();
461
- return Object.values(registry);
331
+ onMessage(message) {
332
+ if (message.type === "hello") {
333
+ if (message.protocolVersion !== SESSION_CATALOG_PROTOCOL_VERSION) {
334
+ this.connectReject?.(
335
+ new Error(
336
+ `Session Catalog protocol mismatch: client=${SESSION_CATALOG_PROTOCOL_VERSION}, server=${message.protocolVersion}`
337
+ )
338
+ );
339
+ this.socket?.destroy();
340
+ return;
341
+ }
342
+ if (normalize2(message.projectDir) !== normalize2(this.options.projectDir) || normalize2(message.projectRoot) !== normalize2(this.options.projectRoot)) {
343
+ this.connectReject?.(new Error("Session Catalog project identity mismatch"));
344
+ this.socket?.destroy();
345
+ return;
346
+ }
347
+ this.info = message;
348
+ this.connectResolve?.();
349
+ return;
350
+ }
351
+ if (message.type === "event") {
352
+ for (const listener of this.eventListeners) {
353
+ try {
354
+ listener(message.event);
355
+ } catch {
356
+ }
357
+ }
358
+ return;
359
+ }
360
+ const pending = this.pending.get(message.id);
361
+ if (!pending) return;
362
+ this.pending.delete(message.id);
363
+ clearTimeout(pending.timer);
364
+ if (message.ok) pending.resolve(message.result);
365
+ else {
366
+ const error = new Error(message.error);
367
+ if (message.errorName) error.name = message.errorName;
368
+ pending.reject(error);
369
+ }
462
370
  }
463
- /**
464
- * Get a single session entry by ID. Returns undefined if not found or stale.
465
- */
466
- async get(sessionId) {
467
- const registry = await this.readAndPrune();
468
- return registry[sessionId];
371
+ onClose(socket) {
372
+ if (socket !== this.socket) return;
373
+ this.socket = null;
374
+ this.info = null;
375
+ this.authToken = void 0;
376
+ const error = new Error("Session Catalog connection closed");
377
+ this.connectReject?.(error);
378
+ this.connectResolve = null;
379
+ this.connectReject = null;
380
+ for (const pending of this.pending.values()) {
381
+ clearTimeout(pending.timer);
382
+ pending.reject(error);
383
+ }
384
+ this.pending.clear();
385
+ this.scheduleSubscriptionReconnect();
469
386
  }
470
- /**
471
- * List all sessions for a specific project (by slug).
472
- */
473
- async listByProject(projectSlug2) {
474
- const all = await this.list();
475
- return all.filter((e) => e.projectSlug === projectSlug2);
387
+ scheduleSubscriptionReconnect() {
388
+ if (this.explicitlyClosed || this.eventListeners.size === 0 || this.reconnectTimer) return;
389
+ this.reconnectTimer = setTimeout(() => {
390
+ this.reconnectTimer = void 0;
391
+ void this.call("subscribe", {}).catch(() => this.scheduleSubscriptionReconnect());
392
+ }, 250);
393
+ this.reconnectTimer.unref?.();
476
394
  }
477
- /**
478
- * Return the registry file path. Useful for WebUI to watch/read.
479
- */
480
- get registryPath() {
481
- return this.filePath;
395
+ spawnDetached() {
396
+ const url = resolveSessionCatalogProjectServerUrl();
397
+ if (!url) throw new Error("Built Session Catalog project server is unavailable");
398
+ const child = spawn(
399
+ process.execPath,
400
+ [
401
+ fileURLToPath(url),
402
+ "--project-dir",
403
+ this.options.projectDir,
404
+ "--project-root",
405
+ this.options.projectRoot
406
+ ],
407
+ { detached: true, stdio: "ignore", windowsHide: true, env: process.env }
408
+ );
409
+ child.unref();
482
410
  }
483
- // ── Internal ────────────────────────────────────────────────────────────
484
- async heartbeat() {
485
- if (!this.currentSessionId) return;
486
- try {
487
- const sessionId = this.currentSessionId;
488
- const owner = this.lastEntry;
489
- if (!owner || owner.sessionId !== sessionId) return;
490
- const nowIso = (/* @__PURE__ */ new Date()).toISOString();
491
- await this.atomicUpdate((registry) => {
492
- if (this.currentSessionId !== sessionId || this.lastEntry !== owner) return;
493
- const entry = registry[sessionId];
494
- if (entry) {
495
- if (!sameOwner(entry, owner)) return;
496
- entry.lastHeartbeatAt = nowIso;
497
- if (entry.status !== "closing") {
498
- const hasRunning = (entry.agents ?? []).some(
499
- (a) => a.status === "running" || a.status === "streaming"
500
- );
501
- entry.status = hasRunning ? "active" : "idle";
502
- }
503
- return;
411
+ };
412
+ }
413
+ });
414
+
415
+ // src/session-catalog/registry.ts
416
+ var registry_exports = {};
417
+ __export(registry_exports, {
418
+ ProjectSessionRegistry: () => ProjectSessionRegistry,
419
+ getProjectSessionRegistry: () => getProjectSessionRegistry,
420
+ hasProjectSessionRegistry: () => hasProjectSessionRegistry
421
+ });
422
+ import { randomUUID } from "node:crypto";
423
+ import * as fs7 from "node:fs/promises";
424
+ import * as path10 from "node:path";
425
+ function getProjectSessionRegistry(globalRoot) {
426
+ const key = globalRoot !== void 0 ? path10.resolve(globalRoot) : lastRegistryKey;
427
+ if (!key)
428
+ throw new Error("SessionRegistry not initialized. Call getSessionRegistry(globalRoot) first.");
429
+ let registry = registries.get(key);
430
+ if (!registry) {
431
+ if (registries.size >= MAX_GLOBAL_ROOTS) {
432
+ const idle = [...registries.entries()].find(([, candidate]) => !candidate.ownsSession());
433
+ if (!idle)
434
+ throw new Error(`Session Registry global-root limit reached (${MAX_GLOBAL_ROOTS})`);
435
+ registries.delete(idle[0]);
436
+ void idle[1].dispose();
437
+ }
438
+ registry = new ProjectSessionRegistry(key);
439
+ registries.set(key, registry);
440
+ } else {
441
+ registries.delete(key);
442
+ registries.set(key, registry);
443
+ }
444
+ lastRegistryKey = key;
445
+ return registry;
446
+ }
447
+ function hasProjectSessionRegistry(globalRoot) {
448
+ if (globalRoot === void 0) return registries.size > 0;
449
+ return registries.has(path10.resolve(globalRoot));
450
+ }
451
+ var HEARTBEAT_INTERVAL_MS, AGENT_WRITE_THROTTLE_MS, MAX_PROJECT_CLIENTS, MAX_GLOBAL_ROOTS, ProjectSessionRegistry, registries, lastRegistryKey;
452
+ var init_registry = __esm({
453
+ "src/session-catalog/registry.ts"() {
454
+ "use strict";
455
+ init_client();
456
+ init_endpoint();
457
+ HEARTBEAT_INTERVAL_MS = 5e3;
458
+ AGENT_WRITE_THROTTLE_MS = 300;
459
+ MAX_PROJECT_CLIENTS = 128;
460
+ MAX_GLOBAL_ROOTS = 16;
461
+ ProjectSessionRegistry = class {
462
+ constructor(globalRoot) {
463
+ this.globalRoot = globalRoot;
464
+ }
465
+ globalRoot;
466
+ instanceId = randomUUID();
467
+ clients = /* @__PURE__ */ new Map();
468
+ current;
469
+ heartbeatTimer;
470
+ agentRevision = 0;
471
+ pendingAgents;
472
+ agentTimer;
473
+ lastAgentWriteAt = 0;
474
+ bindingKey(projectDir) {
475
+ const resolved = path10.resolve(projectDir);
476
+ return process.platform === "win32" ? resolved.toLowerCase() : resolved;
477
+ }
478
+ async closeBinding(binding) {
479
+ if (this.current?.binding === binding) return;
480
+ const key = this.bindingKey(binding.projectDir);
481
+ if (this.clients.get(key) === binding) this.clients.delete(key);
482
+ await binding.client.close();
483
+ }
484
+ binding(projectSlug2, projectRoot) {
485
+ const projectDir = path10.join(this.globalRoot, "projects", projectSlug2);
486
+ const key = this.bindingKey(projectDir);
487
+ let binding = this.clients.get(key);
488
+ if (!binding) {
489
+ if (this.clients.size >= MAX_PROJECT_CLIENTS) {
490
+ const oldest = [...this.clients.entries()].find(
491
+ ([, candidate]) => candidate !== this.current?.binding
492
+ );
493
+ if (oldest) {
494
+ this.clients.delete(oldest[0]);
495
+ void oldest[1].client.close();
504
496
  }
505
- registry[sessionId] = { ...owner, lastHeartbeatAt: nowIso };
506
- });
507
- } catch {
497
+ }
498
+ binding = {
499
+ projectDir,
500
+ projectRoot: path10.resolve(projectRoot),
501
+ client: new SessionCatalogProjectClient({ projectDir, projectRoot })
502
+ };
503
+ this.clients.set(key, binding);
504
+ } else {
505
+ this.clients.delete(key);
506
+ this.clients.set(key, binding);
508
507
  }
508
+ return binding;
509
509
  }
510
- async readAndPrune() {
510
+ fullEntry(entry) {
511
+ const agents = entry.agents ?? [];
512
+ return {
513
+ ...entry,
514
+ status: agents.some((agent) => agent.status !== "idle") ? "active" : "idle",
515
+ lastHeartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
516
+ agentCount: agents.length,
517
+ agents
518
+ };
519
+ }
520
+ async register(entry) {
521
+ const full = this.fullEntry(entry);
522
+ if (this.current?.entry.sessionId === full.sessionId && this.current.entry.pid === full.pid) {
523
+ this.current.entry = full;
524
+ this.current.credential = await this.current.binding.client.call("heartbeat", {
525
+ credential: this.current.credential,
526
+ status: full.status
527
+ });
528
+ return;
529
+ }
530
+ const nextBinding = this.binding(full.projectSlug, full.projectRoot);
531
+ let nextCredential;
511
532
  try {
512
- const raw = await fs7.readFile(this.filePath, "utf8");
513
- const registry = parseRegistry(raw);
514
- const observed = new Map(
515
- Object.entries(registry).map(([id, entry]) => [
516
- id,
517
- { pid: entry.pid, lastHeartbeatAt: entry.lastHeartbeatAt }
518
- ])
519
- );
520
- const now = Date.now();
521
- let pruned = false;
522
- for (const [id, entry] of Object.entries(registry)) {
523
- const heartbeatAt = Date.parse(entry.lastHeartbeatAt);
524
- if (!Number.isFinite(heartbeatAt)) {
525
- delete registry[id];
526
- pruned = true;
527
- continue;
528
- }
529
- const heartbeatAge = now - heartbeatAt;
530
- const agents = Array.isArray(entry.agents) ? entry.agents : [];
531
- const liveAgents = agents.filter((agent) => {
532
- if (agent.id === "leader") return true;
533
- const lastActivityAt = Date.parse(agent.lastActivityAt);
534
- return Number.isFinite(lastActivityAt) && now - lastActivityAt <= AGENT_STALE_MS;
535
- });
536
- if (liveAgents.length !== agents.length || entry.agentCount !== liveAgents.length) {
537
- entry.agents = liveAgents;
538
- entry.agentCount = liveAgents.length;
539
- pruned = true;
540
- }
541
- if (entry.status === "closing" && heartbeatAge > CLOSING_GRACE_MS) {
542
- if (!pidAlive(entry.pid)) {
543
- delete registry[id];
544
- pruned = true;
545
- }
546
- continue;
547
- }
548
- if (heartbeatAge > PID_CHECK_AFTER_MS && !pidAlive(entry.pid)) {
549
- delete registry[id];
550
- pruned = true;
551
- continue;
552
- }
553
- if (heartbeatAge <= STALE_TIMEOUT_MS) continue;
554
- if (entry.status !== "lost") {
555
- entry.status = "lost";
556
- pruned = true;
557
- }
558
- }
559
- if (pruned) {
560
- await this.atomicUpdate((current) => {
561
- for (const [id, version] of observed) {
562
- const latest = current[id];
563
- if (!latest || latest.pid !== version.pid || latest.lastHeartbeatAt !== version.lastHeartbeatAt) {
564
- continue;
565
- }
566
- const next = registry[id];
567
- if (next) current[id] = next;
568
- else delete current[id];
569
- }
570
- });
571
- }
572
- return registry;
573
- } catch {
574
- return {};
533
+ nextCredential = await nextBinding.client.call("claim_new", {
534
+ entry: full,
535
+ ownerInstanceId: this.instanceId
536
+ });
537
+ } catch (error) {
538
+ await this.closeBinding(nextBinding).catch(() => void 0);
539
+ throw error;
540
+ }
541
+ const previous = this.current;
542
+ this.current = { binding: nextBinding, credential: nextCredential, entry: full };
543
+ this.agentRevision = 0;
544
+ this.cancelAgentTimer();
545
+ this.startHeartbeat();
546
+ if (previous) {
547
+ await previous.binding.client.call("release", { credential: previous.credential }).catch(() => void 0);
548
+ if (previous.binding !== nextBinding) await this.closeBinding(previous.binding);
575
549
  }
576
550
  }
577
- async atomicUpdate(fn, required = false) {
578
- const lockPath = `${this.filePath}.lock`;
579
- const waitBudgetMs = required ? this.ownershipLockWaitMs : OPTIONAL_LOCK_WAIT_MS;
580
- const deadline = Date.now() + waitBudgetMs;
581
- let attempt = 0;
551
+ /** Reserve before transcript hydration; activation swaps ownership only after the writer opened. */
552
+ async reserveResume(target) {
553
+ const binding = this.binding(target.projectSlug, target.projectRoot);
554
+ let reservation;
582
555
  try {
583
- await fs7.mkdir(path9.dirname(this.filePath), { recursive: true });
584
- await this.maybePruneStaleTempFiles();
585
- } catch (err) {
586
- if (required) {
587
- throw new SessionRegistryWriteError(
588
- `Session registry ownership update failed: ${err instanceof Error ? err.message : String(err)}`
589
- );
590
- }
591
- return;
556
+ reservation = await binding.client.call("reserve_resume", {
557
+ targetSessionId: target.sessionId,
558
+ requesterInstanceId: this.instanceId,
559
+ ...this.current ? { currentSessionId: this.current.entry.sessionId } : {}
560
+ });
561
+ } catch (error) {
562
+ await this.closeBinding(binding).catch(() => void 0);
563
+ throw error;
592
564
  }
593
- while (true) {
594
- try {
595
- let lockHandle = await fs7.open(lockPath, "wx").catch(() => null);
596
- if (!lockHandle) {
597
- if (await breakStaleLock(lockPath)) {
598
- lockHandle = await fs7.open(lockPath, "wx").catch(() => null);
599
- }
600
- if (!lockHandle) {
601
- const remainingMs = deadline - Date.now();
602
- if (remainingMs <= 0) break;
603
- const retryDelayMs = Math.min(LOCK_RETRY_MAX_MS, 20 * (attempt + 1));
604
- await new Promise(
605
- (resolve5) => setTimeout(resolve5, Math.min(retryDelayMs, remainingMs))
606
- );
607
- attempt += 1;
608
- continue;
609
- }
565
+ let settled = false;
566
+ return {
567
+ reservation,
568
+ activate: async (registration) => {
569
+ if (settled) throw new Error("Resume reservation is already settled");
570
+ const entry = this.fullEntry(registration);
571
+ const credential = await binding.client.call("activate_reservation", {
572
+ reservation,
573
+ entry
574
+ });
575
+ const previous = this.current;
576
+ this.current = { binding, credential, entry };
577
+ settled = true;
578
+ this.agentRevision = 0;
579
+ this.cancelAgentTimer();
580
+ this.startHeartbeat();
581
+ if (previous) {
582
+ await previous.binding.client.call("release", { credential: previous.credential }).catch(() => void 0);
583
+ if (previous.binding !== binding) await this.closeBinding(previous.binding);
610
584
  }
611
- let stampFailed = false;
585
+ },
586
+ cancel: async () => {
587
+ if (settled) return;
588
+ settled = true;
612
589
  try {
613
- let stamped = false;
614
- for (let stampAttempt = 0; stampAttempt < 3 && !stamped; stampAttempt++) {
615
- try {
616
- await lockHandle.writeFile(lockOwnerStamp());
617
- stamped = true;
618
- } catch {
619
- if (stampAttempt < 2) await new Promise((resolve5) => setTimeout(resolve5, 5));
620
- }
621
- }
622
- if (!stamped) {
623
- stampFailed = true;
624
- throw new Error("failed to stamp session-registry lock owner");
625
- }
626
- const raw = await fs7.readFile(this.filePath, "utf8").catch(() => "{}");
627
- const registry = parseRegistry(raw);
628
- fn(registry);
629
- await this.writeAtomicWithRetry(registry);
630
- return;
590
+ await binding.client.call("cancel_reservation", {
591
+ reservationId: reservation.reservationId,
592
+ requesterInstanceId: this.instanceId
593
+ }).catch(() => void 0);
631
594
  } finally {
632
- await lockHandle.close().catch(() => void 0);
633
- if (stampFailed) {
634
- await fs7.unlink(lockPath).catch(() => void 0);
635
- } else {
636
- await maybeUnlinkOwnedLock(lockPath);
637
- }
638
- }
639
- } catch (err) {
640
- if (err instanceof SessionOwnershipConflictError) throw err;
641
- if (required) {
642
- throw new SessionRegistryWriteError(
643
- `Session registry ownership update failed: ${err instanceof Error ? err.message : String(err)}`
644
- );
595
+ await this.closeBinding(binding).catch(() => void 0);
645
596
  }
646
- return;
647
597
  }
598
+ };
599
+ }
600
+ async updateAgents(agents) {
601
+ if (!this.current) return;
602
+ this.pendingAgents = agents;
603
+ this.current.entry = {
604
+ ...this.current.entry,
605
+ agents,
606
+ agentCount: agents.length,
607
+ status: agents.some((agent) => agent.status !== "idle") ? "active" : "idle",
608
+ lastHeartbeatAt: (/* @__PURE__ */ new Date()).toISOString()
609
+ };
610
+ const elapsed = Date.now() - this.lastAgentWriteAt;
611
+ if (!this.agentTimer && elapsed >= AGENT_WRITE_THROTTLE_MS) {
612
+ await this.flushAgents();
613
+ return;
648
614
  }
649
- if (required) {
650
- throw new SessionRegistryWriteError(
651
- `Session registry ownership update failed: lock remained busy for ${waitBudgetMs}ms at ${lockPath}`
615
+ if (!this.agentTimer) {
616
+ this.agentTimer = setTimeout(
617
+ () => {
618
+ this.agentTimer = void 0;
619
+ void this.flushAgents();
620
+ },
621
+ Math.max(0, AGENT_WRITE_THROTTLE_MS - elapsed)
652
622
  );
623
+ this.agentTimer.unref?.();
653
624
  }
654
625
  }
655
- async writeAtomicLocked(registry) {
656
- await this.writeAtomicFile(registry);
626
+ async flushAgents() {
627
+ const agents = this.pendingAgents;
628
+ const current = this.current;
629
+ if (!agents || !current) return;
630
+ this.pendingAgents = void 0;
631
+ this.lastAgentWriteAt = Date.now();
632
+ const revision = ++this.agentRevision;
633
+ await current.binding.client.call("publish_agents", {
634
+ credential: current.credential,
635
+ revision,
636
+ agents
637
+ });
657
638
  }
658
- /**
659
- * Windows-safe wrapper around {@link writeAtomicLocked}. Retries on the small
660
- * set of transient OS errors that occur when an antivirus scan, an indexing
661
- * service, or a peer process opens the destination file for read at the
662
- * same instant rename/copy tries to land. Retries are short and bounded so
663
- * the heartbeat never wedges; the per-call cap is well below the 5 s
664
- * heartbeat interval.
665
- *
666
- * Errors that look persistent (ENOENT, JSON.parse-style, anything other than
667
- * the well-known transient codes) are rethrown immediately so the original
668
- * failure isn't masked.
669
- */
670
- async writeAtomicWithRetry(registry) {
671
- const transientCodes = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES", "ETXTBSY", "ENOTEMPTY"]);
672
- const maxAttempts = 5;
673
- let lastErr;
674
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
639
+ async markClosing() {
640
+ this.stopHeartbeat();
641
+ this.cancelAgentTimer();
642
+ if (this.current)
643
+ await this.current.binding.client.call("mark_closing", {
644
+ credential: this.current.credential
645
+ });
646
+ }
647
+ async unregister() {
648
+ this.stopHeartbeat();
649
+ this.cancelAgentTimer();
650
+ const current = this.current;
651
+ this.current = void 0;
652
+ if (current) {
675
653
  try {
676
- await this.writeAtomicLocked(registry);
677
- return;
678
- } catch (err) {
679
- lastErr = err;
680
- const code = err?.code;
681
- if (!transientCodes.has(String(code ?? "")) || attempt === maxAttempts) {
682
- throw err;
683
- }
684
- const delayMs = Math.min(80, 5 * attempt);
685
- await new Promise((resolve5) => setTimeout(resolve5, delayMs));
654
+ await current.binding.client.call("release", { credential: current.credential });
655
+ } finally {
656
+ await this.closeBinding(current.binding);
686
657
  }
687
658
  }
688
- throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
689
659
  }
690
- async maybePruneStaleTempFiles() {
691
- if (this.tempPrunePromise) {
692
- await this.tempPrunePromise;
693
- return;
660
+ async list() {
661
+ const projectsDir = path10.join(this.globalRoot, "projects");
662
+ let directories = [];
663
+ try {
664
+ directories = (await fs7.readdir(projectsDir, { withFileTypes: true })).filter((entry) => entry.isDirectory()).slice(0, 1e3).map((entry) => entry.name);
665
+ } catch {
666
+ return [];
667
+ }
668
+ const snapshots = await Promise.all(
669
+ directories.map(async (slug) => {
670
+ const projectDir = path10.join(projectsDir, slug);
671
+ let client;
672
+ try {
673
+ const metadata = JSON.parse(
674
+ await fs7.readFile(sessionCatalogProjectServerMetadataPath(projectDir), "utf8")
675
+ );
676
+ if (typeof metadata.projectRoot !== "string" || !metadata.projectRoot) return [];
677
+ client = new SessionCatalogProjectClient({
678
+ projectDir,
679
+ projectRoot: metadata.projectRoot
680
+ });
681
+ return await client.callExisting("list_live", {});
682
+ } catch {
683
+ return [];
684
+ } finally {
685
+ await client?.close().catch(() => void 0);
686
+ }
687
+ })
688
+ );
689
+ return snapshots.flat();
690
+ }
691
+ async listByProject(projectSlug2) {
692
+ const current = this.current;
693
+ if (current && path10.basename(current.binding.projectDir) === projectSlug2) {
694
+ return current.binding.client.call("list_live", {}).catch(() => []);
694
695
  }
695
- const now = Date.now();
696
- if (now - this.lastTempPruneAt < TEMP_PRUNE_INTERVAL_MS) return;
697
- this.lastTempPruneAt = now;
698
- this.tempPrunePromise = this.pruneStaleTempFiles();
696
+ const projectDir = path10.join(this.globalRoot, "projects", projectSlug2);
697
+ let client;
699
698
  try {
700
- await this.tempPrunePromise;
699
+ const metadata = JSON.parse(
700
+ await fs7.readFile(sessionCatalogProjectServerMetadataPath(projectDir), "utf8")
701
+ );
702
+ if (typeof metadata.projectRoot !== "string") return [];
703
+ client = new SessionCatalogProjectClient({ projectDir, projectRoot: metadata.projectRoot });
704
+ return await client.callExisting("list_live", {}).catch(() => []);
705
+ } catch {
706
+ return [];
701
707
  } finally {
702
- this.tempPrunePromise = null;
708
+ await client?.close().catch(() => void 0);
703
709
  }
704
710
  }
705
- async writeAtomicFile(registry) {
706
- await writeAtomicFile(this.filePath, registry);
711
+ async get(sessionId) {
712
+ return (await this.list()).find((entry) => entry.sessionId === sessionId);
707
713
  }
708
- async pruneStaleTempFiles() {
709
- await pruneStaleTempFiles(this.filePath);
714
+ subscribeProject(projectSlug2, projectRoot, listener) {
715
+ const binding = this.binding(projectSlug2, projectRoot);
716
+ return binding.client.subscribe(listener).then((unsubscribe) => async () => {
717
+ await unsubscribe();
718
+ await this.closeBinding(binding);
719
+ });
720
+ }
721
+ get registryPath() {
722
+ return path10.join(this.globalRoot, "projects");
723
+ }
724
+ async dispose() {
725
+ await this.unregister().catch(() => void 0);
726
+ await Promise.all([...this.clients.values()].map((binding) => binding.client.close()));
727
+ this.clients.clear();
728
+ }
729
+ /** Whether this facade currently owns a live session lease. */
730
+ ownsSession() {
731
+ return this.current !== void 0;
732
+ }
733
+ startHeartbeat() {
734
+ this.stopHeartbeat();
735
+ this.heartbeatTimer = setInterval(() => {
736
+ void this.heartbeat();
737
+ }, HEARTBEAT_INTERVAL_MS);
738
+ this.heartbeatTimer.unref?.();
739
+ }
740
+ stopHeartbeat() {
741
+ if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
742
+ this.heartbeatTimer = void 0;
743
+ }
744
+ cancelAgentTimer() {
745
+ if (this.agentTimer) clearTimeout(this.agentTimer);
746
+ this.agentTimer = void 0;
747
+ this.pendingAgents = void 0;
748
+ }
749
+ async heartbeat() {
750
+ const current = this.current;
751
+ if (!current) return;
752
+ try {
753
+ const credential = await current.binding.client.call("heartbeat", {
754
+ credential: current.credential,
755
+ status: current.entry.status
756
+ });
757
+ if (this.current === current) current.credential = credential;
758
+ } catch {
759
+ }
710
760
  }
711
761
  };
712
- _instance = null;
762
+ registries = /* @__PURE__ */ new Map();
713
763
  }
714
764
  });
715
765
 
@@ -757,7 +807,7 @@ function envFlag(value) {
757
807
  return !/^(0|false|no|off)$/i.test(value.trim());
758
808
  }
759
809
  var COLOR = isColorTty();
760
- var wrap = (open3, close) => (s) => COLOR ? `\x1B[${open3}m${s}\x1B[${close}m` : s;
810
+ var wrap = (open, close) => (s) => COLOR ? `\x1B[${open}m${s}\x1B[${close}m` : s;
761
811
  var color = {
762
812
  reset: wrap("0", "0"),
763
813
  bold: wrap("1", "22"),
@@ -2143,8 +2193,8 @@ var contextManagerTool = createContextManagerTool();
2143
2193
 
2144
2194
  // src/boot.ts
2145
2195
  import * as fs8 from "node:fs/promises";
2146
- import * as os3 from "node:os";
2147
- import * as path10 from "node:path";
2196
+ import * as os4 from "node:os";
2197
+ import * as path11 from "node:path";
2148
2198
 
2149
2199
  // src/security/secret-vault.ts
2150
2200
  import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto";
@@ -2525,8 +2575,8 @@ function checkKeyFilePermissions(keyFile, opts) {
2525
2575
  if (process.platform === "win32") return;
2526
2576
  const warn = opts?.warn ?? ((msg) => console.warn(msg));
2527
2577
  try {
2528
- const stat5 = fs2.statSync(keyFile);
2529
- const actualMode = stat5.mode & 511;
2578
+ const stat4 = fs2.statSync(keyFile);
2579
+ const actualMode = stat4.mode & 511;
2530
2580
  if (actualMode !== KEY_FILE_MODE) {
2531
2581
  void restrictFilePermissions(keyFile, {
2532
2582
  label: "secret-vault",
@@ -3583,8 +3633,8 @@ var IN_PROJECT_DENIED_PATHS = [
3583
3633
  reason: "The other half of the filesystem confinement switch."
3584
3634
  }
3585
3635
  ];
3586
- function deleteNestedPath(target, path11) {
3587
- const segments = path11.split(".");
3636
+ function deleteNestedPath(target, path12) {
3637
+ const segments = path12.split(".");
3588
3638
  const last = segments[segments.length - 1];
3589
3639
  if (last === void 0) return false;
3590
3640
  let cursor = target;
@@ -3641,16 +3691,16 @@ function assertInProjectAllowListComplete() {
3641
3691
  );
3642
3692
  }
3643
3693
  const orphanedPaths = IN_PROJECT_DENIED_PATHS.filter(
3644
- ({ path: path11 }) => !IN_PROJECT_ALLOWED_KEYS.has(path11.split(".")[0] ?? "")
3645
- ).map(({ path: path11 }) => path11);
3694
+ ({ path: path12 }) => !IN_PROJECT_ALLOWED_KEYS.has(path12.split(".")[0] ?? "")
3695
+ ).map(({ path: path12 }) => path12);
3646
3696
  if (orphanedPaths.length > 0) {
3647
3697
  problems.push(
3648
3698
  `IN_PROJECT_DENIED_PATHS entr(ies) whose top-level parent is not allowed: ` + orphanedPaths.join(", ") + ". The parent is already stripped wholesale, so the nested denial is dead \u2014 remove it."
3649
3699
  );
3650
3700
  }
3651
3701
  const malformedPaths = IN_PROJECT_DENIED_PATHS.filter(
3652
- ({ path: path11 }) => path11.split(".").length < 2 || path11.split(".").some((s) => s.length === 0)
3653
- ).map(({ path: path11 }) => path11);
3702
+ ({ path: path12 }) => path12.split(".").length < 2 || path12.split(".").some((s) => s.length === 0)
3703
+ ).map(({ path: path12 }) => path12);
3654
3704
  if (malformedPaths.length > 0) {
3655
3705
  problems.push(
3656
3706
  `IN_PROJECT_DENIED_PATHS entr(ies) are not dotted nested paths: ` + malformedPaths.join(", ") + ". Top-level keys belong in KNOWN_DENIED_IN_PROJECT instead."
@@ -3678,8 +3728,8 @@ function stripUnsafeInProjectFields(inProject, sourcePath, warn = (msg) => conso
3678
3728
  }
3679
3729
  stripped.push(k);
3680
3730
  }
3681
- for (const { path: path11 } of IN_PROJECT_DENIED_PATHS) {
3682
- if (deleteNestedPath(out, path11)) stripped.push(path11);
3731
+ for (const { path: path12 } of IN_PROJECT_DENIED_PATHS) {
3732
+ if (deleteNestedPath(out, path12)) stripped.push(path12);
3683
3733
  }
3684
3734
  if (stripped.length > 0) {
3685
3735
  warn(
@@ -4288,8 +4338,8 @@ var DefaultConfigLoader = class _DefaultConfigLoader {
4288
4338
  const t0 = Date.now();
4289
4339
  let mtimeMs = null;
4290
4340
  try {
4291
- const stat5 = await fs5.stat(file);
4292
- mtimeMs = stat5.mtimeMs;
4341
+ const stat4 = await fs5.stat(file);
4342
+ mtimeMs = stat4.mtimeMs;
4293
4343
  const cached = this.jsonCache.get(file);
4294
4344
  if (cached && cached.mtimeMs === mtimeMs) {
4295
4345
  return structuredClone(cached.value);
@@ -4588,11 +4638,11 @@ async function bootConfig(options = {}) {
4588
4638
  loadSyncConfig = true,
4589
4639
  skipIdentityValidation = false
4590
4640
  } = options;
4591
- const cwd = typeof flags["cwd"] === "string" ? path10.resolve(flags["cwd"]) : process.cwd();
4641
+ const cwd = typeof flags["cwd"] === "string" ? path11.resolve(flags["cwd"]) : process.cwd();
4592
4642
  const pathResolver = new DefaultPathResolver(cwd);
4593
4643
  const projectRoot = pathResolver.projectRoot;
4594
4644
  const projectIdentityRoot = canonicalProjectRoot(projectRoot);
4595
- const userHome = os3.homedir();
4645
+ const userHome = os4.homedir();
4596
4646
  const wpaths = resolveWstackPaths({ projectRoot });
4597
4647
  assertProjectRootOutsideStateDir(projectRoot, wpaths.globalRoot);
4598
4648
  await fs8.mkdir(wpaths.globalRoot, { recursive: true });
@@ -4701,8 +4751,8 @@ async function bootConfig(options = {}) {
4701
4751
  }
4702
4752
  const logger = new DefaultLogger({ level: config.log?.level ?? "info", file: wpaths.logFile });
4703
4753
  try {
4704
- const { getSessionRegistry: getSessionRegistry2 } = await Promise.resolve().then(() => (init_session_registry(), session_registry_exports));
4705
- getSessionRegistry2(wpaths.globalRoot);
4754
+ const { getProjectSessionRegistry: getProjectSessionRegistry2 } = await Promise.resolve().then(() => (init_registry(), registry_exports));
4755
+ getProjectSessionRegistry2(wpaths.globalRoot);
4706
4756
  } catch {
4707
4757
  }
4708
4758
  return {
@@ -4791,7 +4841,7 @@ async function migrateLegacyConfig(wpaths) {
4791
4841
  } catch (err) {
4792
4842
  if (err.code !== "ENOENT") return;
4793
4843
  }
4794
- await fs8.mkdir(path10.dirname(profileFp), { recursive: true });
4844
+ await fs8.mkdir(path11.dirname(profileFp), { recursive: true });
4795
4845
  const profileContent = { ...result.value };
4796
4846
  delete profileContent["activeProfile"];
4797
4847
  await atomicWrite(profileFp, JSON.stringify(profileContent, null, 2), { mode: 384 });
@@ -4803,57 +4853,57 @@ async function migrateLegacyConfig(wpaths) {
4803
4853
  }
4804
4854
  var PROFILE_STATE_PAIRS = [
4805
4855
  {
4806
- globalSrc: (w) => path10.join(w.globalRoot, "statusline.json"),
4856
+ globalSrc: (w) => path11.join(w.globalRoot, "statusline.json"),
4807
4857
  profileDst: (w, n) => w.profileStatuslineConfig(n)
4808
4858
  },
4809
4859
  {
4810
- globalSrc: (w) => path10.join(w.globalRoot, "mode.json"),
4860
+ globalSrc: (w) => path11.join(w.globalRoot, "mode.json"),
4811
4861
  profileDst: (w, n) => w.profileModeConfig(n)
4812
4862
  },
4813
4863
  {
4814
- globalSrc: (w) => path10.join(w.globalRoot, "provider-status.json"),
4864
+ globalSrc: (w) => path11.join(w.globalRoot, "provider-status.json"),
4815
4865
  profileDst: (w, n) => w.profileProviderStatus(n)
4816
4866
  },
4817
4867
  {
4818
- globalSrc: (w) => path10.join(w.globalRoot, "update-cache.json"),
4868
+ globalSrc: (w) => path11.join(w.globalRoot, "update-cache.json"),
4819
4869
  profileDst: (w, n) => w.profileUpdateCache(n)
4820
4870
  },
4821
4871
  {
4822
- globalSrc: (w) => path10.join(w.globalRoot, "memory.md"),
4823
- profileDst: (w, n) => path10.join(w.profilesDir, n, "memory.md")
4872
+ globalSrc: (w) => path11.join(w.globalRoot, "memory.md"),
4873
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "memory.md")
4824
4874
  },
4825
4875
  {
4826
- globalSrc: (w) => path10.join(w.globalRoot, "history"),
4827
- profileDst: (w, n) => path10.join(w.profilesDir, n, "history")
4876
+ globalSrc: (w) => path11.join(w.globalRoot, "history"),
4877
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "history")
4828
4878
  },
4829
4879
  {
4830
- globalSrc: (w) => path10.join(w.globalRoot, "sync.json"),
4831
- profileDst: (w, n) => path10.join(w.profilesDir, n, "sync.json")
4880
+ globalSrc: (w) => path11.join(w.globalRoot, "sync.json"),
4881
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "sync.json")
4832
4882
  },
4833
4883
  {
4834
- globalSrc: (w) => path10.join(w.globalRoot, "sync-state.json"),
4835
- profileDst: (w, n) => path10.join(w.profilesDir, n, "sync-state.json")
4884
+ globalSrc: (w) => path11.join(w.globalRoot, "sync-state.json"),
4885
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "sync-state.json")
4836
4886
  },
4837
4887
  {
4838
- globalSrc: (w) => path10.join(w.globalRoot, "prompt-usage.json"),
4839
- profileDst: (w, n) => path10.join(w.profilesDir, n, "prompt-usage.json")
4888
+ globalSrc: (w) => path11.join(w.globalRoot, "prompt-usage.json"),
4889
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "prompt-usage.json")
4840
4890
  },
4841
4891
  {
4842
- globalSrc: (w) => path10.join(w.globalRoot, "custom-context-modes.json"),
4843
- profileDst: (w, n) => path10.join(w.profilesDir, n, "custom-context-modes.json")
4892
+ globalSrc: (w) => path11.join(w.globalRoot, "custom-context-modes.json"),
4893
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "custom-context-modes.json")
4844
4894
  },
4845
4895
  {
4846
- globalSrc: (w) => path10.join(w.globalRoot, "desktop.json"),
4847
- profileDst: (w, n) => path10.join(w.profilesDir, n, "desktop.json")
4896
+ globalSrc: (w) => path11.join(w.globalRoot, "desktop.json"),
4897
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "desktop.json")
4848
4898
  },
4849
4899
  {
4850
- globalSrc: (w) => path10.join(w.globalRoot, "installed-skills.json"),
4851
- profileDst: (w, n) => path10.join(w.profilesDir, n, "installed-skills.json")
4900
+ globalSrc: (w) => path11.join(w.globalRoot, "installed-skills.json"),
4901
+ profileDst: (w, n) => path11.join(w.profilesDir, n, "installed-skills.json")
4852
4902
  },
4853
4903
  ...["skills", "prompts", "instructions", "design-kits", "desktop", "settings"].map(
4854
4904
  (directory) => ({
4855
- globalSrc: (w) => path10.join(w.globalRoot, directory),
4856
- profileDst: (w, n) => path10.join(w.profilesDir, n, directory)
4905
+ globalSrc: (w) => path11.join(w.globalRoot, directory),
4906
+ profileDst: (w, n) => path11.join(w.profilesDir, n, directory)
4857
4907
  })
4858
4908
  )
4859
4909
  ];
@@ -4868,7 +4918,7 @@ async function migrateProfileFiles(wpaths) {
4868
4918
  } catch {
4869
4919
  }
4870
4920
  try {
4871
- await fs8.mkdir(path10.join(wpaths.profilesDir, profileName), { recursive: true });
4921
+ await fs8.mkdir(path11.join(wpaths.profilesDir, profileName), { recursive: true });
4872
4922
  } catch {
4873
4923
  return;
4874
4924
  }
@@ -4907,9 +4957,9 @@ async function migrateProfileFiles(wpaths) {
4907
4957
  }
4908
4958
  }
4909
4959
  function assertProjectRootOutsideStateDir(projectRoot, globalRoot) {
4910
- const stateNamespace = path10.resolve(globalRoot, "projects");
4911
- const rel = path10.relative(stateNamespace, path10.resolve(projectRoot));
4912
- if (rel.startsWith("..") || path10.isAbsolute(rel)) return;
4960
+ const stateNamespace = path11.resolve(globalRoot, "projects");
4961
+ const rel = path11.relative(stateNamespace, path11.resolve(projectRoot));
4962
+ if (rel.startsWith("..") || path11.isAbsolute(rel)) return;
4913
4963
  throw new Error(
4914
4964
  `Refusing to start: the resolved project root is inside WrongStack's per-project state directory.
4915
4965
  project root: ${projectRoot}
@@ -4932,7 +4982,7 @@ async function writeProjectMeta(paths, projectRoot, projectId) {
4932
4982
  }
4933
4983
  }
4934
4984
  async function registerProjectInManifest(paths, projectRoot, events, workingDir, projectId) {
4935
- const manifestPath = path10.join(paths.globalRoot, "projects.json");
4985
+ const manifestPath = path11.join(paths.globalRoot, "projects.json");
4936
4986
  try {
4937
4987
  const t0 = Date.now();
4938
4988
  const raw = await fs8.readFile(manifestPath, "utf8");
@@ -4974,7 +5024,7 @@ async function registerProjectInManifest(paths, projectRoot, events, workingDir,
4974
5024
  if (workingDir) existing.lastWorkingDir = workingDir;
4975
5025
  } else {
4976
5026
  const slug = paths.projectSlug;
4977
- const name = path10.basename(projectRoot);
5027
+ const name = path11.basename(projectRoot);
4978
5028
  const entry = {
4979
5029
  name,
4980
5030
  root: projectRoot,
@@ -5008,24 +5058,24 @@ async function registerProjectInManifest(paths, projectRoot, events, workingDir,
5008
5058
  }
5009
5059
  }
5010
5060
  async function cleanupStaleProjects(wpaths) {
5011
- const projectsRoot = path10.dirname(wpaths.projectDir);
5061
+ const projectsRoot = path11.dirname(wpaths.projectDir);
5012
5062
  let entries;
5013
5063
  try {
5014
5064
  entries = await fs8.readdir(projectsRoot, { withFileTypes: true });
5015
5065
  } catch {
5016
5066
  return;
5017
5067
  }
5018
- const stateNamespace = path10.resolve(projectsRoot);
5068
+ const stateNamespace = path11.resolve(projectsRoot);
5019
5069
  for (const entry of entries) {
5020
5070
  if (!entry.isDirectory()) continue;
5021
- const projectPath = path10.join(projectsRoot, entry.name);
5022
- const metaPath = path10.join(projectPath, "meta.json");
5071
+ const projectPath = path11.join(projectsRoot, entry.name);
5072
+ const metaPath = path11.join(projectPath, "meta.json");
5023
5073
  try {
5024
5074
  const raw = await fs8.readFile(metaPath, "utf8");
5025
5075
  const meta = JSON.parse(raw);
5026
5076
  if (typeof meta.root !== "string") continue;
5027
- const rel = path10.relative(stateNamespace, path10.resolve(meta.root));
5028
- const nested = rel !== "" && !rel.startsWith("..") && !path10.isAbsolute(rel);
5077
+ const rel = path11.relative(stateNamespace, path11.resolve(meta.root));
5078
+ const nested = rel !== "" && !rel.startsWith("..") && !path11.isAbsolute(rel);
5029
5079
  if (nested) {
5030
5080
  await fs8.rm(projectPath, { recursive: true, force: true });
5031
5081
  continue;