@covibes/zeroshot 5.2.1 → 5.3.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 (62) hide show
  1. package/CHANGELOG.md +174 -189
  2. package/README.md +199 -248
  3. package/cli/commands/providers.js +150 -0
  4. package/cli/index.js +214 -58
  5. package/cli/lib/first-run.js +40 -3
  6. package/cluster-templates/base-templates/debug-workflow.json +24 -78
  7. package/cluster-templates/base-templates/full-workflow.json +44 -145
  8. package/cluster-templates/base-templates/single-worker.json +23 -15
  9. package/cluster-templates/base-templates/worker-validator.json +47 -34
  10. package/cluster-templates/conductor-bootstrap.json +7 -5
  11. package/lib/docker-config.js +6 -1
  12. package/lib/provider-detection.js +59 -0
  13. package/lib/provider-names.js +56 -0
  14. package/lib/settings.js +191 -6
  15. package/lib/stream-json-parser.js +4 -238
  16. package/package.json +21 -5
  17. package/scripts/validate-templates.js +100 -0
  18. package/src/agent/agent-config.js +37 -13
  19. package/src/agent/agent-context-builder.js +64 -2
  20. package/src/agent/agent-hook-executor.js +82 -9
  21. package/src/agent/agent-lifecycle.js +53 -14
  22. package/src/agent/agent-task-executor.js +196 -194
  23. package/src/agent/output-extraction.js +200 -0
  24. package/src/agent/output-reformatter.js +175 -0
  25. package/src/agent/schema-utils.js +111 -0
  26. package/src/agent-wrapper.js +102 -30
  27. package/src/agents/git-pusher-agent.json +1 -1
  28. package/src/claude-task-runner.js +80 -30
  29. package/src/config-router.js +13 -13
  30. package/src/config-validator.js +231 -10
  31. package/src/github.js +36 -0
  32. package/src/isolation-manager.js +243 -154
  33. package/src/ledger.js +28 -6
  34. package/src/orchestrator.js +391 -96
  35. package/src/preflight.js +85 -82
  36. package/src/providers/anthropic/cli-builder.js +45 -0
  37. package/src/providers/anthropic/index.js +134 -0
  38. package/src/providers/anthropic/models.js +23 -0
  39. package/src/providers/anthropic/output-parser.js +159 -0
  40. package/src/providers/base-provider.js +181 -0
  41. package/src/providers/capabilities.js +51 -0
  42. package/src/providers/google/cli-builder.js +55 -0
  43. package/src/providers/google/index.js +116 -0
  44. package/src/providers/google/models.js +24 -0
  45. package/src/providers/google/output-parser.js +92 -0
  46. package/src/providers/index.js +75 -0
  47. package/src/providers/openai/cli-builder.js +122 -0
  48. package/src/providers/openai/index.js +135 -0
  49. package/src/providers/openai/models.js +21 -0
  50. package/src/providers/openai/output-parser.js +129 -0
  51. package/src/sub-cluster-wrapper.js +18 -3
  52. package/src/task-runner.js +8 -6
  53. package/src/tui/layout.js +20 -3
  54. package/task-lib/attachable-watcher.js +80 -78
  55. package/task-lib/claude-recovery.js +119 -0
  56. package/task-lib/commands/list.js +1 -1
  57. package/task-lib/commands/resume.js +3 -2
  58. package/task-lib/commands/run.js +12 -3
  59. package/task-lib/runner.js +59 -38
  60. package/task-lib/scheduler.js +2 -2
  61. package/task-lib/store.js +43 -30
  62. package/task-lib/watcher.js +81 -62
@@ -3,18 +3,21 @@
3
3
  *
4
4
  * Handles:
5
5
  * - Container creation with workspace mounts
6
- * - Credential injection for Claude CLI
6
+ * - Credential injection for provider CLIs
7
7
  * - Command execution inside containers
8
8
  * - Container cleanup on stop/kill
9
9
  */
10
10
 
11
11
  const { spawn, execSync } = require('child_process');
12
12
  const { Worker } = require('worker_threads');
13
+ const crypto = require('crypto');
13
14
  const path = require('path');
14
15
  const os = require('os');
15
16
  const fs = require('fs');
16
17
  const { loadSettings } = require('../lib/settings');
18
+ const { normalizeProviderName } = require('../lib/provider-names');
17
19
  const { resolveMounts, resolveEnvs, expandEnvPatterns } = require('../lib/docker-config');
20
+ const { getProvider } = require('./providers');
18
21
 
19
22
  /**
20
23
  * Escape a string for safe use in shell commands
@@ -28,6 +31,19 @@ function escapeShell(str) {
28
31
  return `'${str.replace(/'/g, "'\\''")}'`;
29
32
  }
30
33
 
34
+ function expandHomePath(value) {
35
+ if (!value) return value;
36
+ if (value === '~') return os.homedir();
37
+ return value.replace(/^~(?=\/|$)/, os.homedir());
38
+ }
39
+
40
+ function pathContains(base, target) {
41
+ const resolvedBase = path.resolve(base);
42
+ const resolvedTarget = path.resolve(target);
43
+ if (resolvedBase === resolvedTarget) return true;
44
+ return resolvedTarget.startsWith(resolvedBase + path.sep);
45
+ }
46
+
31
47
  const DEFAULT_IMAGE = 'zeroshot-cluster-base';
32
48
 
33
49
  class IsolationManager {
@@ -68,6 +84,7 @@ class IsolationManager {
68
84
  * @param {boolean} [config.reuseExistingWorkspace=false] - If true, reuse existing isolated workspace (for resume)
69
85
  * @param {Array<string|object>} [config.mounts] - Override default mounts (preset names or {host, container, readonly})
70
86
  * @param {boolean} [config.noMounts=false] - Disable all credential mounts
87
+ * @param {string} [config.provider] - Provider name for credential warnings
71
88
  * @returns {Promise<string>} Container ID
72
89
  */
73
90
  async createContainer(clusterId, config) {
@@ -117,6 +134,9 @@ class IsolationManager {
117
134
 
118
135
  // Resolve container home directory EARLY - needed for Claude config mount and hooks
119
136
  const settings = loadSettings();
137
+ const providerName = normalizeProviderName(
138
+ config.provider || settings.defaultProvider || 'claude'
139
+ );
120
140
  const containerHome = config.containerHome || settings.dockerContainerHome || '/root';
121
141
 
122
142
  // Create fresh Claude config dir for this cluster (avoids permission issues from host)
@@ -145,6 +165,8 @@ class IsolationManager {
145
165
  `${clusterConfigDir}:${containerHome}/.claude`,
146
166
  ];
147
167
 
168
+ const mountedHosts = [];
169
+
148
170
  // Add configurable credential mounts
149
171
  // Priority: CLI config > env var > settings > defaults
150
172
  if (!config.noMounts) {
@@ -168,9 +190,18 @@ class IsolationManager {
168
190
 
169
191
  // Resolve presets to actual mount specs (containerHome already resolved above)
170
192
  const mounts = resolveMounts(mountConfig, { containerHome });
193
+ const claudeContainerPath = path.posix.join(containerHome, '.claude');
171
194
 
172
195
  for (const mount of mounts) {
173
- const hostPath = mount.host.replace(/^~/, os.homedir());
196
+ if (mount.container === claudeContainerPath) {
197
+ console.warn(
198
+ `[IsolationManager] Skipping mount for ${mount.host} -> ${mount.container} ` +
199
+ '(Claude config is managed by zeroshot).'
200
+ );
201
+ continue;
202
+ }
203
+
204
+ const hostPath = expandHomePath(mount.host);
174
205
 
175
206
  // Check path exists and is mountable
176
207
  try {
@@ -188,12 +219,11 @@ class IsolationManager {
188
219
  ? `${hostPath}:${mount.container}:ro`
189
220
  : `${hostPath}:${mount.container}`;
190
221
  args.push('-v', mountSpec);
222
+ mountedHosts.push(hostPath);
191
223
  }
192
224
 
193
225
  // Pass env vars based on enabled presets
194
- const envSpecs = expandEnvPatterns(
195
- resolveEnvs(mountConfig, settings.dockerEnvPassthrough)
196
- );
226
+ const envSpecs = expandEnvPatterns(resolveEnvs(mountConfig, settings.dockerEnvPassthrough));
197
227
  for (const spec of envSpecs) {
198
228
  if (spec.forced) {
199
229
  // Forced value - always pass with specified value
@@ -205,15 +235,30 @@ class IsolationManager {
205
235
  }
206
236
  }
207
237
 
238
+ // Warn when provider credentials are likely missing
239
+ if (providerName !== 'claude') {
240
+ const provider = getProvider(providerName);
241
+ const credentialPaths = provider.getCredentialPaths ? provider.getCredentialPaths() : [];
242
+ const expandedCreds = credentialPaths.map((cred) => expandHomePath(cred));
243
+ const hasCredentialMount = mountedHosts.some((hostPath) =>
244
+ expandedCreds.some(
245
+ (credPath) => pathContains(hostPath, credPath) || pathContains(credPath, hostPath)
246
+ )
247
+ );
248
+
249
+ if (!hasCredentialMount && expandedCreds.length > 0) {
250
+ const exampleHost = credentialPaths[0];
251
+ const exampleContainer = exampleHost.replace(/^~(?=\/|$)/, containerHome);
252
+ const mountNote = config.noMounts ? 'Credential mounts are disabled. ' : '';
253
+ console.warn(
254
+ `[IsolationManager] ⚠️ ${mountNote}No credential mounts found for ${provider.displayName}. ` +
255
+ `Add one with --mount ${exampleHost}:${exampleContainer}:ro`
256
+ );
257
+ }
258
+ }
259
+
208
260
  // Finish docker args
209
- args.push(
210
- '-w',
211
- '/workspace',
212
- image,
213
- 'tail',
214
- '-f',
215
- '/dev/null'
216
- );
261
+ args.push('-w', '/workspace', image, 'tail', '-f', '/dev/null');
217
262
 
218
263
  return new Promise((resolve, reject) => {
219
264
  const proc = spawn('docker', args, { stdio: ['pipe', 'pipe', 'pipe'] });
@@ -240,116 +285,7 @@ class IsolationManager {
240
285
  try {
241
286
  console.log(`[IsolationManager] Checking for package.json in ${workDir}...`);
242
287
  if (fs.existsSync(path.join(workDir, 'package.json'))) {
243
- // Check if node_modules already exists in container (pre-baked or previous run)
244
- const checkResult = await this.execInContainer(
245
- clusterId,
246
- ['sh', '-c', 'test -d node_modules && test -f node_modules/.package-lock.json && echo "exists"'],
247
- {}
248
- );
249
-
250
- if (checkResult.code === 0 && checkResult.stdout.trim() === 'exists') {
251
- console.log(`[IsolationManager] ✓ Dependencies already installed (skipping npm install)`);
252
- } else {
253
- // Check if npm is available in container
254
- const npmCheck = await this.execInContainer(clusterId, ['which', 'npm'], {});
255
- if (npmCheck.code !== 0) {
256
- console.log(`[IsolationManager] npm not available in container, skipping dependency install`);
257
- } else {
258
- // Issue #20: Try to use pre-baked dependencies first
259
- // Check if pre-baked deps exist and can satisfy project requirements
260
- const preBakeCheck = await this.execInContainer(
261
- clusterId,
262
- ['sh', '-c', 'test -d /pre-baked-deps/node_modules && echo "exists"'],
263
- {}
264
- );
265
-
266
- if (preBakeCheck.code === 0 && preBakeCheck.stdout.trim() === 'exists') {
267
- console.log(`[IsolationManager] Checking if pre-baked deps satisfy requirements...`);
268
-
269
- // Copy pre-baked deps, then run npm install to add any missing
270
- // This is faster than full npm install: copy is ~2s, npm install adds ~5-10s for missing
271
- const copyResult = await this.execInContainer(
272
- clusterId,
273
- ['sh', '-c', 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true'],
274
- {}
275
- );
276
-
277
- if (copyResult.code === 0) {
278
- console.log(`[IsolationManager] ✓ Copied pre-baked dependencies`);
279
-
280
- // Run npm install to add any missing deps (much faster with pre-baked base)
281
- const installResult = await this.execInContainer(
282
- clusterId,
283
- ['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline'],
284
- {}
285
- );
286
-
287
- if (installResult.code === 0) {
288
- console.log(`[IsolationManager] ✓ Dependencies installed (pre-baked + incremental)`);
289
- } else {
290
- // Fallback: full install (pre-baked copy may have caused issues)
291
- console.warn(`[IsolationManager] Incremental install failed, falling back to full install`);
292
- await this.execInContainer(
293
- clusterId,
294
- ['sh', '-c', 'rm -rf node_modules && npm_config_engine_strict=false npm install --no-audit --no-fund'],
295
- {}
296
- );
297
- console.log(`[IsolationManager] ✓ Dependencies installed (full fallback)`);
298
- }
299
- }
300
- } else {
301
- // No pre-baked deps, full npm install with retries
302
- console.log(`[IsolationManager] Installing npm dependencies in container...`);
303
-
304
- // Retry npm install with exponential backoff (network issues are common)
305
- const maxRetries = 3;
306
- const baseDelay = 2000; // 2 seconds
307
- let installResult = null;
308
-
309
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
310
- try {
311
- installResult = await this.execInContainer(
312
- clusterId,
313
- ['sh', '-c', 'npm_config_engine_strict=false npm install --no-audit --no-fund'],
314
- {}
315
- );
316
-
317
- if (installResult.code === 0) {
318
- console.log(`[IsolationManager] ✓ Dependencies installed`);
319
- break; // Success - exit retry loop
320
- }
321
-
322
- // Failed - retry if not last attempt
323
- // Use stderr if available, otherwise stdout (npm writes some errors to stdout)
324
- const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
325
- if (attempt < maxRetries) {
326
- const delay = baseDelay * Math.pow(2, attempt - 1);
327
- console.warn(
328
- `[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
329
- );
330
- console.warn(`[IsolationManager] Error: ${errorOutput}`);
331
- await new Promise((_resolve) => setTimeout(_resolve, delay));
332
- } else {
333
- console.warn(
334
- `[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
335
- );
336
- }
337
- } catch (execErr) {
338
- if (attempt < maxRetries) {
339
- const delay = baseDelay * Math.pow(2, attempt - 1);
340
- console.warn(
341
- `[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
342
- );
343
- console.warn(`[IsolationManager] Error: ${execErr.message}`);
344
- await new Promise((_resolve) => setTimeout(_resolve, delay));
345
- } else {
346
- throw execErr; // Re-throw on last attempt
347
- }
348
- }
349
- }
350
- }
351
- }
352
- }
288
+ await this._installDependenciesWithRetry(clusterId);
353
289
  }
354
290
  } catch (err) {
355
291
  console.warn(
@@ -369,6 +305,81 @@ class IsolationManager {
369
305
  });
370
306
  }
371
307
 
308
+ async _installDependenciesWithRetry(clusterId) {
309
+ console.log(`[IsolationManager] Installing npm dependencies in container...`);
310
+
311
+ const maxRetries = 3;
312
+ const baseDelay = 2000; // 2 seconds
313
+ const installCommand = [
314
+ 'sh',
315
+ '-c',
316
+ [
317
+ 'if [ -d node_modules ] && [ -f node_modules/.package-lock.json ]; then',
318
+ 'echo "__deps_present__";',
319
+ 'exit 0;',
320
+ 'fi;',
321
+ 'if ! command -v npm >/dev/null 2>&1; then',
322
+ 'echo "__npm_missing__";',
323
+ 'exit 127;',
324
+ 'fi;',
325
+ 'if [ -d /pre-baked-deps/node_modules ]; then',
326
+ 'cp -rn /pre-baked-deps/node_modules . 2>/dev/null || true;',
327
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund --prefer-offline;',
328
+ 'install_code=$?;',
329
+ 'if [ $install_code -ne 0 ]; then',
330
+ 'rm -rf node_modules;',
331
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
332
+ 'fi;',
333
+ 'else',
334
+ 'npm_config_engine_strict=false npm install --no-audit --no-fund;',
335
+ 'fi',
336
+ ].join(' '),
337
+ ];
338
+
339
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
340
+ try {
341
+ const installResult = await this.execInContainer(clusterId, installCommand, {});
342
+ const stdout = installResult.stdout || '';
343
+
344
+ if (installResult.code === 0) {
345
+ if (stdout.includes('__deps_present__')) {
346
+ console.log(
347
+ `[IsolationManager] ✓ Dependencies already installed (skipping npm install)`
348
+ );
349
+ } else {
350
+ console.log(`[IsolationManager] ✓ Dependencies installed`);
351
+ }
352
+ return;
353
+ }
354
+
355
+ const errorOutput = (installResult.stderr || installResult.stdout || '').slice(0, 500);
356
+ if (attempt < maxRetries) {
357
+ const delay = baseDelay * Math.pow(2, attempt - 1);
358
+ console.warn(
359
+ `[IsolationManager] ⚠️ npm install failed (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
360
+ );
361
+ console.warn(`[IsolationManager] Error: ${errorOutput}`);
362
+ await new Promise((resolve) => setTimeout(resolve, delay));
363
+ } else {
364
+ console.warn(
365
+ `[IsolationManager] ⚠️ npm install failed after ${maxRetries} attempts (non-fatal): ${errorOutput}`
366
+ );
367
+ }
368
+ } catch (execErr) {
369
+ if (attempt < maxRetries) {
370
+ const delay = baseDelay * Math.pow(2, attempt - 1);
371
+ console.warn(
372
+ `[IsolationManager] ⚠️ npm install execution error (attempt ${attempt}/${maxRetries}), retrying in ${delay}ms...`
373
+ );
374
+ console.warn(`[IsolationManager] Error: ${execErr.message}`);
375
+ await new Promise((resolve) => setTimeout(resolve, delay));
376
+ } else {
377
+ throw execErr;
378
+ }
379
+ }
380
+ }
381
+ }
382
+
372
383
  /**
373
384
  * Execute a command inside the container
374
385
  * @param {string} clusterId - Cluster ID
@@ -542,7 +553,9 @@ class IsolationManager {
542
553
  const isolatedInfo = this.isolatedDirs.get(clusterId);
543
554
 
544
555
  if (preserveWorkspace) {
545
- console.log(`[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`);
556
+ console.log(
557
+ `[IsolationManager] Preserving isolated workspace at ${isolatedInfo.path} for resume`
558
+ );
546
559
  // Don't delete - but DON'T remove from Map either, resume() needs it
547
560
  } else {
548
561
  console.log(`[IsolationManager] Cleaning up isolated dir at ${isolatedInfo.path}`);
@@ -896,7 +909,10 @@ class IsolationManager {
896
909
  ],
897
910
  },
898
911
  };
899
- fs.writeFileSync(path.join(configDir, 'settings.json'), JSON.stringify(clusterSettings, null, 2));
912
+ fs.writeFileSync(
913
+ path.join(configDir, 'settings.json'),
914
+ JSON.stringify(clusterSettings, null, 2)
915
+ );
900
916
 
901
917
  // Track for cleanup
902
918
  this.clusterConfigDirs = this.clusterConfigDirs || new Map();
@@ -990,9 +1006,12 @@ class IsolationManager {
990
1006
  */
991
1007
  _isContainerRunning(containerId) {
992
1008
  try {
993
- const result = execSync(`docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`, {
994
- encoding: 'utf8',
995
- });
1009
+ const result = execSync(
1010
+ `docker inspect -f '{{.State.Running}}' ${escapeShell(containerId)} 2>/dev/null`,
1011
+ {
1012
+ encoding: 'utf8',
1013
+ }
1014
+ );
996
1015
  return result.trim() === 'true';
997
1016
  } catch {
998
1017
  return false;
@@ -1017,7 +1036,8 @@ class IsolationManager {
1017
1036
  */
1018
1037
  static isDockerAvailable() {
1019
1038
  try {
1020
- execSync('docker --version', { encoding: 'utf8', stdio: 'pipe' });
1039
+ // Require both CLI binary and a reachable daemon.
1040
+ execSync('docker info', { encoding: 'utf8', stdio: 'pipe' });
1021
1041
  return true;
1022
1042
  } catch {
1023
1043
  return false;
@@ -1146,14 +1166,16 @@ class IsolationManager {
1146
1166
 
1147
1167
  /**
1148
1168
  * Create worktree-based isolation for a cluster (lightweight alternative to Docker)
1149
- * Creates a git worktree at /tmp/zeroshot-worktrees/{clusterId}
1169
+ * Creates a git worktree at {os.tmpdir()}/zeroshot-worktrees/{clusterId}
1150
1170
  * @param {string} clusterId - Cluster ID
1151
1171
  * @param {string} workDir - Original working directory (must be a git repo)
1152
1172
  * @returns {{ path: string, branch: string, repoRoot: string }}
1153
1173
  */
1154
1174
  createWorktreeIsolation(clusterId, workDir) {
1155
1175
  if (!this._isGitRepo(workDir)) {
1156
- throw new Error(`Worktree isolation requires a git repository. ${workDir} is not a git repo.`);
1176
+ throw new Error(
1177
+ `Worktree isolation requires a git repository. ${workDir} is not a git repo.`
1178
+ );
1157
1179
  }
1158
1180
 
1159
1181
  const worktreeInfo = this.createWorktree(clusterId, workDir);
@@ -1196,7 +1218,8 @@ class IsolationManager {
1196
1218
  }
1197
1219
 
1198
1220
  // Create branch name from cluster ID (e.g., cluster-cosmic-meteor-87 -> zeroshot/cosmic-meteor-87)
1199
- const branchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1221
+ const baseBranchName = `zeroshot/${clusterId.replace(/^cluster-/, '')}`;
1222
+ let branchName = baseBranchName;
1200
1223
 
1201
1224
  // Worktree path in tmp
1202
1225
  const worktreePath = path.join(os.tmpdir(), 'zeroshot-worktrees', clusterId);
@@ -1207,34 +1230,73 @@ class IsolationManager {
1207
1230
  fs.mkdirSync(parentDir, { recursive: true });
1208
1231
  }
1209
1232
 
1210
- // Remove existing worktree if it exists (cleanup from previous run)
1233
+ // Best-effort cleanup of stale worktree metadata and directory.
1234
+ // IMPORTANT: If a previous run deleted the directory without deregistering the worktree,
1235
+ // git may keep the branch "checked out" and block deletion/reuse.
1211
1236
  try {
1212
- execSync(`git worktree remove --force "${worktreePath}" 2>/dev/null`, {
1237
+ execSync(`git worktree remove --force ${escapeShell(worktreePath)}`, {
1213
1238
  cwd: repoRoot,
1214
1239
  encoding: 'utf8',
1215
1240
  stdio: 'pipe',
1216
1241
  });
1217
1242
  } catch {
1218
- // Ignore - worktree doesn't exist
1243
+ // ignore
1219
1244
  }
1220
-
1221
- // Delete the branch if it exists (from previous run)
1222
1245
  try {
1223
- execSync(`git branch -D "${branchName}" 2>/dev/null`, {
1224
- cwd: repoRoot,
1225
- encoding: 'utf8',
1226
- stdio: 'pipe',
1227
- });
1246
+ execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1228
1247
  } catch {
1229
- // Ignore - branch doesn't exist
1248
+ // ignore
1249
+ }
1250
+ try {
1251
+ fs.rmSync(worktreePath, { recursive: true, force: true });
1252
+ } catch {
1253
+ // ignore
1230
1254
  }
1231
1255
 
1232
- // Create worktree with new branch based on HEAD
1233
- execSync(`git worktree add -b "${branchName}" "${worktreePath}" HEAD`, {
1234
- cwd: repoRoot,
1235
- encoding: 'utf8',
1236
- stdio: 'pipe',
1237
- });
1256
+ // Create worktree with new branch based on HEAD (retry on branch collision/in-use)
1257
+ for (let attempt = 0; attempt < 10; attempt++) {
1258
+ // Best-effort delete if branch exists and is not in use by another worktree.
1259
+ try {
1260
+ execSync(`git branch -D ${escapeShell(branchName)}`, {
1261
+ cwd: repoRoot,
1262
+ encoding: 'utf8',
1263
+ stdio: 'pipe',
1264
+ });
1265
+ } catch {
1266
+ // ignore
1267
+ }
1268
+
1269
+ try {
1270
+ execSync(
1271
+ `git worktree add -b ${escapeShell(branchName)} ${escapeShell(worktreePath)} HEAD`,
1272
+ {
1273
+ cwd: repoRoot,
1274
+ encoding: 'utf8',
1275
+ stdio: 'pipe',
1276
+ }
1277
+ );
1278
+ break;
1279
+ } catch (err) {
1280
+ const stderr = (
1281
+ err && (err.stderr || err.message) ? String(err.stderr || err.message) : ''
1282
+ ).toLowerCase();
1283
+ const isBranchCollision =
1284
+ stderr.includes('already exists') ||
1285
+ stderr.includes('cannot delete branch') ||
1286
+ stderr.includes('checked out');
1287
+
1288
+ if (attempt < 9 && isBranchCollision) {
1289
+ branchName = `${baseBranchName}-${crypto.randomBytes(3).toString('hex')}`;
1290
+ try {
1291
+ execSync('git worktree prune', { cwd: repoRoot, encoding: 'utf8', stdio: 'pipe' });
1292
+ } catch {
1293
+ // ignore
1294
+ }
1295
+ continue;
1296
+ }
1297
+ throw err;
1298
+ }
1299
+ }
1238
1300
 
1239
1301
  return {
1240
1302
  path: worktreePath,
@@ -1250,19 +1312,46 @@ class IsolationManager {
1250
1312
  * @param {boolean} [options.deleteBranch=false] - Also delete the branch
1251
1313
  */
1252
1314
  removeWorktree(worktreeInfo, _options = {}) {
1315
+ // Remove the worktree (prefer git so metadata is cleaned up).
1253
1316
  try {
1254
- // Remove the worktree
1255
- execSync(`git worktree remove --force "${worktreeInfo.path}" 2>/dev/null`, {
1317
+ execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
1256
1318
  cwd: worktreeInfo.repoRoot,
1257
1319
  encoding: 'utf8',
1258
1320
  stdio: 'pipe',
1259
1321
  });
1260
1322
  } catch {
1261
- // Fallback: manually remove directory if worktree command fails
1323
+ // If git worktree metadata is stale, prune and retry once.
1262
1324
  try {
1263
- fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
1325
+ execSync('git worktree prune', {
1326
+ cwd: worktreeInfo.repoRoot,
1327
+ encoding: 'utf8',
1328
+ stdio: 'pipe',
1329
+ });
1330
+ } catch {
1331
+ // ignore
1332
+ }
1333
+ try {
1334
+ execSync(`git worktree remove --force ${escapeShell(worktreeInfo.path)}`, {
1335
+ cwd: worktreeInfo.repoRoot,
1336
+ encoding: 'utf8',
1337
+ stdio: 'pipe',
1338
+ });
1264
1339
  } catch {
1265
- // Ignore
1340
+ // Last resort: delete directory, then prune stale worktree entries.
1341
+ try {
1342
+ fs.rmSync(worktreeInfo.path, { recursive: true, force: true });
1343
+ } catch {
1344
+ // ignore
1345
+ }
1346
+ try {
1347
+ execSync('git worktree prune', {
1348
+ cwd: worktreeInfo.repoRoot,
1349
+ encoding: 'utf8',
1350
+ stdio: 'pipe',
1351
+ });
1352
+ } catch {
1353
+ // ignore
1354
+ }
1266
1355
  }
1267
1356
  }
1268
1357
 
package/src/ledger.js CHANGED
@@ -19,6 +19,7 @@ class Ledger extends EventEmitter {
19
19
  this.cache = new Map(); // LRU cache for queries
20
20
  this.cacheLimit = 1000;
21
21
  this._closed = false; // Track closed state to prevent write-after-close
22
+ this._lastTimestamp = 0;
22
23
  this._initSchema();
23
24
  }
24
25
 
@@ -52,6 +53,7 @@ class Ledger extends EventEmitter {
52
53
  `);
53
54
 
54
55
  this._prepareStatements();
56
+ this._loadLastTimestamp();
55
57
  }
56
58
 
57
59
  _prepareStatements() {
@@ -69,6 +71,13 @@ class Ledger extends EventEmitter {
69
71
  };
70
72
  }
71
73
 
74
+ _loadLastTimestamp() {
75
+ const row = this.db.prepare('SELECT MAX(timestamp) AS max_timestamp FROM messages').get();
76
+ if (row && Number.isFinite(row.max_timestamp)) {
77
+ this._lastTimestamp = row.max_timestamp;
78
+ }
79
+ }
80
+
72
81
  /**
73
82
  * Append a message to the ledger
74
83
  * @param {Object} message - Message object
@@ -83,7 +92,10 @@ class Ledger extends EventEmitter {
83
92
  }
84
93
 
85
94
  const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
86
- const timestamp = message.timestamp || Date.now();
95
+ const baseTimestamp = Math.max(Date.now(), this._lastTimestamp + 1);
96
+ const requestedTimestamp = typeof message.timestamp === 'number' ? message.timestamp : null;
97
+ const timestamp =
98
+ requestedTimestamp !== null ? Math.max(requestedTimestamp, baseTimestamp) : baseTimestamp;
87
99
 
88
100
  const record = {
89
101
  id,
@@ -113,6 +125,8 @@ class Ledger extends EventEmitter {
113
125
  // Invalidate cache
114
126
  this.cache.clear();
115
127
 
128
+ this._lastTimestamp = Math.max(this._lastTimestamp, timestamp);
129
+
116
130
  // Emit event for subscriptions
117
131
  const fullMessage = this._deserializeMessage(record);
118
132
  this.emit('message', fullMessage);
@@ -149,13 +163,13 @@ class Ledger extends EventEmitter {
149
163
  // Create transaction function - all inserts happen atomically
150
164
  const insertMany = this.db.transaction((msgs) => {
151
165
  const results = [];
152
- const baseTimestamp = Date.now();
166
+ const baseTimestamp = Math.max(Date.now(), this._lastTimestamp + 1);
153
167
 
154
168
  for (let i = 0; i < msgs.length; i++) {
155
169
  const message = msgs[i];
156
170
  const id = message.id || `msg_${crypto.randomBytes(16).toString('hex')}`;
157
171
  // Use incrementing timestamps to preserve order within batch
158
- const timestamp = message.timestamp || (baseTimestamp + i);
172
+ const timestamp = baseTimestamp + i;
159
173
 
160
174
  const record = {
161
175
  id,
@@ -184,16 +198,18 @@ class Ledger extends EventEmitter {
184
198
  results.push(this._deserializeMessage(record));
185
199
  }
186
200
 
187
- return results;
201
+ return { results, baseTimestamp };
188
202
  });
189
203
 
190
204
  try {
191
205
  // Execute transaction (atomic - all or nothing)
192
- const appendedMessages = insertMany(messages);
206
+ const { results: appendedMessages, baseTimestamp } = insertMany(messages);
193
207
 
194
208
  // Invalidate cache
195
209
  this.cache.clear();
196
210
 
211
+ this._lastTimestamp = Math.max(this._lastTimestamp, baseTimestamp + messages.length - 1);
212
+
197
213
  // Emit events for subscriptions AFTER transaction commits
198
214
  // This ensures listeners see consistent state
199
215
  for (const fullMessage of appendedMessages) {
@@ -248,7 +264,13 @@ class Ledger extends EventEmitter {
248
264
  params.push(typeof until === 'number' ? until : new Date(until).getTime());
249
265
  }
250
266
 
251
- let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY timestamp ASC`;
267
+ // Defend against prototype pollution affecting default query ordering.
268
+ // Only treat `criteria.order` as set if it's an own property.
269
+ const orderValue = Object.prototype.hasOwnProperty.call(criteria, 'order')
270
+ ? criteria.order
271
+ : undefined;
272
+ const direction = String(orderValue ?? 'asc').toLowerCase() === 'desc' ? 'DESC' : 'ASC';
273
+ let sql = `SELECT * FROM messages WHERE ${conditions.join(' AND ')} ORDER BY timestamp ${direction}`;
252
274
 
253
275
  if (limit) {
254
276
  sql += ` LIMIT ?`;