@the-open-engine/zeroshot 6.10.0 → 6.10.2

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 (33) hide show
  1. package/cli/index.js +16 -0
  2. package/cluster-hooks/block-ask-user-question.py +2 -3
  3. package/cluster-hooks/block-dangerous-git.py +2 -3
  4. package/lib/agent-cli-provider/adapters/claude.d.ts.map +1 -1
  5. package/lib/agent-cli-provider/adapters/claude.js +42 -1
  6. package/lib/agent-cli-provider/adapters/claude.js.map +1 -1
  7. package/lib/agent-cli-provider/contract-options.d.ts.map +1 -1
  8. package/lib/agent-cli-provider/contract-options.js +2 -0
  9. package/lib/agent-cli-provider/contract-options.js.map +1 -1
  10. package/lib/agent-cli-provider/types.d.ts +6 -2
  11. package/lib/agent-cli-provider/types.d.ts.map +1 -1
  12. package/lib/agent-cli-provider/types.js.map +1 -1
  13. package/package.json +1 -1
  14. package/src/agent/agent-lifecycle.js +13 -6
  15. package/src/agent/agent-task-executor.js +527 -313
  16. package/src/agent-cli-provider/adapters/claude.ts +46 -1
  17. package/src/agent-cli-provider/contract-options.ts +6 -0
  18. package/src/agent-cli-provider/types.ts +9 -6
  19. package/src/claude-task-runner.js +168 -44
  20. package/src/darwin-keychain-boundary.js +174 -0
  21. package/src/task-spawn-cleanup-ownership.js +82 -0
  22. package/src/worktree-claude-config.js +193 -85
  23. package/src/worktree-tooling-env.js +3 -0
  24. package/task-lib/attachable-watcher.js +93 -267
  25. package/task-lib/command-spec-cleanup.js +219 -0
  26. package/task-lib/commands/clean.js +35 -5
  27. package/task-lib/commands/get-task-id-by-spawn-token.js +10 -0
  28. package/task-lib/commands/kill.js +117 -4
  29. package/task-lib/commands/status.js +1 -0
  30. package/task-lib/runner.js +61 -4
  31. package/task-lib/store.js +68 -6
  32. package/task-lib/watcher-output-runtime.js +284 -0
  33. package/task-lib/watcher.js +124 -257
@@ -0,0 +1,82 @@
1
+ const { randomUUID } = require('node:crypto');
2
+
3
+ const TASK_SPAWN_OWNERSHIP_TOKEN_ENV = 'ZEROSHOT_TASK_SPAWN_OWNERSHIP_TOKEN';
4
+
5
+ const COMMAND_CLEANUP_OWNER = Object.freeze({
6
+ CALLER: 'caller',
7
+ TASK_LIFECYCLE: 'task-lifecycle',
8
+ });
9
+
10
+ function normalizeError(error) {
11
+ return error instanceof Error ? error : new Error(String(error));
12
+ }
13
+
14
+ /**
15
+ * Mark a wrapper failure after the detached task record durably accepted the
16
+ * launch ownership token. Human stdout and process spawn events are not
17
+ * ownership receipts.
18
+ */
19
+ function transferCommandCleanupOwnership(error) {
20
+ const normalized = normalizeError(error);
21
+ normalized.commandCleanupOwner = COMMAND_CLEANUP_OWNER.TASK_LIFECYCLE;
22
+ return normalized;
23
+ }
24
+
25
+ function callerOwnsCommandCleanup(error) {
26
+ return error?.commandCleanupOwner !== COMMAND_CLEANUP_OWNER.TASK_LIFECYCLE;
27
+ }
28
+
29
+ function cleanupCallerOwnedCommand(error, cleanup) {
30
+ if (callerOwnsCommandCleanup(error)) cleanup();
31
+ }
32
+
33
+ function requireTaskIdFromWrapperResult({
34
+ code,
35
+ stdout,
36
+ stderr,
37
+ parseTaskId,
38
+ persistedTaskId = null,
39
+ }) {
40
+ if (code !== 0) {
41
+ throw new Error(`zeroshot task run failed with code ${code}: ${stderr}`);
42
+ }
43
+ if (!persistedTaskId) {
44
+ const printedTaskId = parseTaskId(stdout);
45
+ throw new Error(
46
+ `Detached task ownership receipt was not persisted${
47
+ printedTaskId ? ` for wrapper output ${printedTaskId}` : ''
48
+ }.`
49
+ );
50
+ }
51
+ const printedTaskId = parseTaskId(stdout);
52
+ if (printedTaskId && printedTaskId !== persistedTaskId) {
53
+ throw new Error(
54
+ `Task ownership receipt ${persistedTaskId} did not match wrapper output ${printedTaskId}.`
55
+ );
56
+ }
57
+ return persistedTaskId;
58
+ }
59
+
60
+ function createTaskSpawnOwnershipToken() {
61
+ return randomUUID();
62
+ }
63
+
64
+ function trackTaskWrapperCleanupOwnership(findPersistedTaskId) {
65
+ return (error) => {
66
+ try {
67
+ return findPersistedTaskId() ? transferCommandCleanupOwnership(error) : error;
68
+ } catch {
69
+ return transferCommandCleanupOwnership(error);
70
+ }
71
+ };
72
+ }
73
+
74
+ module.exports = {
75
+ COMMAND_CLEANUP_OWNER,
76
+ TASK_SPAWN_OWNERSHIP_TOKEN_ENV,
77
+ cleanupCallerOwnedCommand,
78
+ callerOwnsCommandCleanup,
79
+ createTaskSpawnOwnershipToken,
80
+ requireTaskIdFromWrapperResult,
81
+ trackTaskWrapperCleanupOwnership,
82
+ };
@@ -3,127 +3,202 @@ const os = require('os');
3
3
  const path = require('path');
4
4
 
5
5
  const { resolveWorktreeRoot } = require('./worktree-tooling-env');
6
- const { provisionClaudeCredentials } = require('./claude-credentials');
7
6
 
8
7
  const CLAUDE_DIRNAME = '.claude';
9
- const SETTINGS_BASENAME = 'settings.json';
10
8
  const MCP_BASENAME = '.mcp.json';
9
+ const SETTINGS_BASENAME = 'settings.json';
10
+ const OVERLAY_PREFIX = 'zeroshot-claude-settings-';
11
+ const CLAUDE_SETTINGS_ENV = 'ZEROSHOT_CLAUDE_SETTINGS_FILE';
12
+ const CLAUDE_MCP_CONFIG_ENV = 'ZEROSHOT_CLAUDE_MCP_CONFIG_FILE';
13
+ const ASK_USER_HOOK = 'block-ask-user-question.py';
14
+ const DANGEROUS_GIT_HOOK = 'block-dangerous-git.py';
11
15
 
12
- function isPlainObject(value) {
13
- return value && typeof value === 'object' && !Array.isArray(value);
14
- }
15
16
 
16
- function readJsonIfExists(filePath) {
17
- if (!fs.existsSync(filePath)) {
18
- return null;
17
+ function readSettings(settingsPath) {
18
+ if (!fs.existsSync(settingsPath)) {
19
+ return {};
19
20
  }
20
21
 
21
22
  try {
22
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
23
- } catch {
24
- return null;
23
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
24
+ } catch (error) {
25
+ throw new Error(`Could not parse Claude settings overlay ${settingsPath}: ${error.message}`);
25
26
  }
26
27
  }
27
28
 
28
- function mergeJson(baseValue, overrideValue) {
29
- if (Array.isArray(baseValue) && Array.isArray(overrideValue)) {
30
- const seen = new Set();
31
- const merged = [];
32
-
33
- for (const entry of [...baseValue, ...overrideValue]) {
34
- const key = JSON.stringify(entry);
35
- if (seen.has(key)) {
36
- continue;
37
- }
38
- seen.add(key);
39
- merged.push(entry);
40
- }
29
+ function writeSettings(settingsPath, settings) {
30
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2), { mode: 0o600 });
31
+ }
41
32
 
42
- return merged;
33
+ function requireTargetClaudeDir(targetClaudeDir) {
34
+ if (typeof targetClaudeDir !== 'string' || !targetClaudeDir) {
35
+ throw new Error('Claude safety hooks require an explicit per-run settings directory.');
36
+ }
37
+ if (!isClaudeSettingsOverlayDirectory(targetClaudeDir)) {
38
+ throw new Error(
39
+ `Claude safety hooks require a Zeroshot-owned Claude settings overlay: ${targetClaudeDir}`
40
+ );
43
41
  }
42
+ return targetClaudeDir;
43
+ }
44
44
 
45
- if (isPlainObject(baseValue) && isPlainObject(overrideValue)) {
46
- const merged = { ...baseValue };
47
- for (const [key, value] of Object.entries(overrideValue)) {
48
- if (Object.prototype.hasOwnProperty.call(merged, key)) {
49
- merged[key] = mergeJson(merged[key], value);
50
- } else {
51
- merged[key] = value;
52
- }
53
- }
54
- return merged;
45
+ function copyHookScript(targetClaudeDir, hookScriptName) {
46
+ const hooksDir = path.join(targetClaudeDir, 'hooks');
47
+ fs.mkdirSync(hooksDir, { recursive: true });
48
+
49
+ const sourcePath = path.join(__dirname, '..', 'cluster-hooks', hookScriptName);
50
+ if (!fs.existsSync(sourcePath)) {
51
+ throw new Error(
52
+ `Claude safety hook ${hookScriptName} is missing from the Zeroshot installation.`
53
+ );
55
54
  }
56
55
 
57
- return overrideValue === undefined ? baseValue : overrideValue;
56
+ const destinationPath = path.join(hooksDir, hookScriptName);
57
+ fs.copyFileSync(sourcePath, destinationPath);
58
+ fs.chmodSync(destinationPath, 0o755);
59
+ return destinationPath;
58
60
  }
59
61
 
60
- function ensureDir(dirPath) {
61
- fs.mkdirSync(dirPath, { recursive: true });
62
+ function ensurePreToolUseHooks(settings) {
63
+ settings.hooks ||= {};
64
+ settings.hooks.PreToolUse ||= [];
65
+ return settings.hooks.PreToolUse;
62
66
  }
63
67
 
64
- function resolveRepoClaudeConfig(worktreeRoot) {
65
- const configDir = path.join(worktreeRoot, CLAUDE_DIRNAME);
66
- const settingsPath = path.join(configDir, SETTINGS_BASENAME);
67
- const mcpPath = path.join(configDir, MCP_BASENAME);
68
+ function ensureAskUserQuestionHook(targetClaudeDir) {
69
+ const overlayDir = requireTargetClaudeDir(targetClaudeDir);
70
+
71
+ const hookScriptPath = copyHookScript(overlayDir, ASK_USER_HOOK);
72
+ const settingsPath = path.join(overlayDir, SETTINGS_BASENAME);
73
+ const settings = readSettings(settingsPath);
74
+ const hooks = ensurePreToolUseHooks(settings);
75
+ const hasHook = hooks.some(
76
+ (entry) =>
77
+ entry.matcher === 'AskUserQuestion' ||
78
+ entry.hooks?.some((hook) => hook.command?.includes(ASK_USER_HOOK))
79
+ );
80
+
81
+ if (!hasHook) {
82
+ hooks.push({
83
+ matcher: 'AskUserQuestion',
84
+ hooks: [{ type: 'command', command: hookScriptPath }],
85
+ });
86
+ writeSettings(settingsPath, settings);
87
+ }
88
+
89
+ }
68
90
 
69
- if (!fs.existsSync(settingsPath) && !fs.existsSync(mcpPath)) {
70
- return null;
91
+ function ensureDangerousGitHook(targetClaudeDir) {
92
+ const overlayDir = requireTargetClaudeDir(targetClaudeDir);
93
+
94
+ const hookScriptPath = copyHookScript(overlayDir, DANGEROUS_GIT_HOOK);
95
+ const settingsPath = path.join(overlayDir, SETTINGS_BASENAME);
96
+ const settings = readSettings(settingsPath);
97
+ const hooks = ensurePreToolUseHooks(settings);
98
+ const hasHook = hooks.some(
99
+ (entry) =>
100
+ entry.matcher === 'Bash' &&
101
+ entry.hooks?.some((hook) => hook.command?.includes(DANGEROUS_GIT_HOOK))
102
+ );
103
+
104
+ if (!hasHook) {
105
+ hooks.push({
106
+ matcher: 'Bash',
107
+ hooks: [{ type: 'command', command: hookScriptPath }],
108
+ });
109
+ writeSettings(settingsPath, settings);
71
110
  }
72
111
 
73
- return {
74
- configDir,
75
- settingsPath,
76
- mcpPath,
77
- };
78
112
  }
79
113
 
80
- function prepareClaudeConfigDir(options = {}) {
81
- const worktreeRoot = resolveWorktreeRoot(options.worktreePath || options.cwd);
82
- if (!worktreeRoot) {
83
- return null;
114
+ function prepareClaudeSettingsOverlay(options = {}) {
115
+ const overlayDir = fs.mkdtempSync(path.join(os.tmpdir(), OVERLAY_PREFIX));
116
+ fs.chmodSync(overlayDir, 0o700);
117
+ try {
118
+ ensureAskUserQuestionHook(overlayDir);
119
+ if (options.includeDangerousGit) {
120
+ ensureDangerousGitHook(overlayDir);
121
+ }
122
+ return path.join(overlayDir, SETTINGS_BASENAME);
123
+ } catch (error) {
124
+ fs.rmSync(overlayDir, { recursive: true, force: true });
125
+ throw error;
84
126
  }
127
+ }
85
128
 
86
- const repoConfig = resolveRepoClaudeConfig(worktreeRoot);
87
- if (!repoConfig) {
88
- return null;
129
+ function cleanupClaudeSettingsOverlay(settingsPath) {
130
+ if (!isCanonicalClaudeSettingsOverlayPath(settingsPath)) {
131
+ return false;
89
132
  }
90
133
 
91
- const sourceDir =
92
- options.sourceDir || process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), CLAUDE_DIRNAME);
93
- const tempRoot = path.join(os.tmpdir(), 'zeroshot-claude-configs');
94
- ensureDir(tempRoot);
134
+ const overlayDir = path.dirname(settingsPath);
135
+ if (!fs.existsSync(overlayDir)) {
136
+ return true;
137
+ }
138
+ if (!isClaudeSettingsOverlayPath(settingsPath)) {
139
+ return false;
140
+ }
95
141
 
96
- const overlayDir = fs.mkdtempSync(path.join(tempRoot, 'config-'));
97
- ensureDir(path.join(overlayDir, 'hooks'));
98
- ensureDir(path.join(overlayDir, 'projects'));
142
+ fs.rmSync(overlayDir, { recursive: true, force: true });
143
+ return true;
144
+ }
99
145
 
100
- provisionClaudeCredentials({ sourceDir, destDir: overlayDir });
146
+ function isCanonicalClaudeSettingsOverlayPath(settingsPath) {
147
+ if (typeof settingsPath !== 'string' || !settingsPath) {
148
+ return false;
149
+ }
150
+ const resolvedSettingsPath = path.resolve(settingsPath);
151
+ const overlayDir = path.dirname(resolvedSettingsPath);
152
+ return (
153
+ resolvedSettingsPath === settingsPath &&
154
+ path.basename(resolvedSettingsPath) === SETTINGS_BASENAME &&
155
+ path.dirname(overlayDir) === path.resolve(os.tmpdir()) &&
156
+ path.basename(overlayDir).startsWith(OVERLAY_PREFIX)
157
+ );
158
+ }
101
159
 
102
- const sourceSettings = readJsonIfExists(path.join(sourceDir, SETTINGS_BASENAME)) || {};
103
- const repoSettings = readJsonIfExists(repoConfig.settingsPath) || {};
104
- const mergedSettings = mergeJson(sourceSettings, repoSettings);
105
- if (Object.keys(mergedSettings).length > 0) {
106
- fs.writeFileSync(
107
- path.join(overlayDir, SETTINGS_BASENAME),
108
- JSON.stringify(mergedSettings, null, 2)
109
- );
160
+ function isClaudeSettingsOverlayPath(settingsPath, platform = process.platform) {
161
+ if (!isCanonicalClaudeSettingsOverlayPath(settingsPath)) {
162
+ return false;
110
163
  }
111
164
 
112
- const sourceMcp = readJsonIfExists(path.join(sourceDir, MCP_BASENAME)) || {};
113
- const repoMcp = readJsonIfExists(repoConfig.mcpPath) || {};
114
- const mergedMcp = mergeJson(sourceMcp, repoMcp);
115
- if (Object.keys(mergedMcp).length > 0) {
116
- fs.writeFileSync(path.join(overlayDir, MCP_BASENAME), JSON.stringify(mergedMcp, null, 2));
165
+ const resolvedSettingsPath = path.resolve(settingsPath);
166
+ const overlayDir = path.dirname(resolvedSettingsPath);
167
+
168
+ try {
169
+ const stat = fs.lstatSync(overlayDir);
170
+ const ownedByProcess =
171
+ typeof process.getuid !== 'function' || stat.uid === process.getuid();
172
+ const privateMode = platform === 'win32' || (stat.mode & 0o777) === 0o700;
173
+ return stat.isDirectory() && !stat.isSymbolicLink() && ownedByProcess && privateMode;
174
+ } catch {
175
+ return false;
117
176
  }
177
+ }
118
178
 
119
- return overlayDir;
179
+ function isCanonicalClaudeSettingsOverlayDirectory(overlayDir) {
180
+ if (
181
+ typeof overlayDir !== 'string' ||
182
+ !overlayDir ||
183
+ path.resolve(overlayDir) !== overlayDir
184
+ ) {
185
+ return false;
186
+ }
187
+ return isCanonicalClaudeSettingsOverlayPath(path.join(overlayDir, SETTINGS_BASENAME));
188
+ }
189
+
190
+ function isClaudeSettingsOverlayDirectory(overlayDir) {
191
+ if (typeof overlayDir !== 'string' || !overlayDir) {
192
+ return false;
193
+ }
194
+ return isClaudeSettingsOverlayPath(path.join(overlayDir, SETTINGS_BASENAME));
120
195
  }
121
196
 
122
197
  /**
123
- * Resolve the repo's `.mcp.json` path (the same MCP-server source Claude consumes via
124
- * prepareClaudeConfigDir) for a given worktree/cwd, or null if none exists. Reused by providers
125
- * that consume MCP servers through a CLI flag instead of the Claude config-dir overlay (e.g.
126
- * Copilot's `--additional-mcp-config`), so both providers share one MCP config surface.
198
+ * Resolve the repo's MCP config for a given worktree/cwd. The project-root
199
+ * `.mcp.json` is Claude's current convention. `.claude/.mcp.json` remains a
200
+ * deliberate compatibility fallback for repositories created by Zeroshot's
201
+ * earlier worktree integration.
127
202
  */
128
203
  function resolveRepoMcpConfigPath(options = {}) {
129
204
  const worktreeRoot = resolveWorktreeRoot(options.worktreePath || options.cwd);
@@ -131,11 +206,44 @@ function resolveRepoMcpConfigPath(options = {}) {
131
206
  return null;
132
207
  }
133
208
 
134
- const mcpPath = path.join(worktreeRoot, CLAUDE_DIRNAME, MCP_BASENAME);
135
- return fs.existsSync(mcpPath) ? mcpPath : null;
209
+ const candidates = [
210
+ path.join(worktreeRoot, MCP_BASENAME),
211
+ path.join(worktreeRoot, CLAUDE_DIRNAME, MCP_BASENAME),
212
+ ];
213
+ return candidates.find((candidate) => fs.existsSync(candidate)) || null;
214
+ }
215
+
216
+ function resolveContainerMcpConfigPath(options = {}) {
217
+ const worktreeRoot = resolveWorktreeRoot(options.worktreePath || options.cwd);
218
+ const hostConfigPath = resolveRepoMcpConfigPath(options);
219
+ if (!worktreeRoot || !hostConfigPath) {
220
+ return null;
221
+ }
222
+
223
+ const relativePath = path.relative(worktreeRoot, hostConfigPath);
224
+ if (
225
+ !relativePath ||
226
+ path.isAbsolute(relativePath) ||
227
+ relativePath === '..' ||
228
+ relativePath.startsWith(`..${path.sep}`)
229
+ ) {
230
+ throw new Error(`Repository MCP config is outside the mounted workspace: ${hostConfigPath}`);
231
+ }
232
+
233
+ return path.posix.join('/workspace', relativePath.split(path.sep).join('/'));
136
234
  }
137
235
 
138
236
  module.exports = {
139
- prepareClaudeConfigDir,
237
+ CLAUDE_MCP_CONFIG_ENV,
238
+ CLAUDE_SETTINGS_ENV,
239
+ cleanupClaudeSettingsOverlay,
240
+ ensureAskUserQuestionHook,
241
+ ensureDangerousGitHook,
242
+ isCanonicalClaudeSettingsOverlayDirectory,
243
+ isCanonicalClaudeSettingsOverlayPath,
244
+ isClaudeSettingsOverlayDirectory,
245
+ isClaudeSettingsOverlayPath,
246
+ prepareClaudeSettingsOverlay,
247
+ resolveContainerMcpConfigPath,
140
248
  resolveRepoMcpConfigPath,
141
249
  };
@@ -6,6 +6,9 @@ const DEFAULT_TOOL_BIN_RELATIVE_PATHS = ['.zeroshot/bin', '.worktree-tool-bin'];
6
6
  const FALLBACK_BIN_PREFIX = '.worktree-tool-bin.';
7
7
 
8
8
  function pathKeyForEnv(env) {
9
+ if (Object.prototype.hasOwnProperty.call(env, 'PATH')) {
10
+ return 'PATH';
11
+ }
9
12
  return Object.keys(env).find((key) => key.toUpperCase() === 'PATH') || 'PATH';
10
13
  }
11
14