@cleocode/adapters 2026.9.19 → 2026.9.21

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 (71) hide show
  1. package/dist/cant-context.d.ts +7 -5
  2. package/dist/cant-context.d.ts.map +1 -1
  3. package/dist/index.js +748 -594
  4. package/dist/index.js.map +3 -3
  5. package/dist/providers/claude-code/adapter.d.ts.map +1 -1
  6. package/dist/providers/claude-code/hooks.d.ts +18 -2
  7. package/dist/providers/claude-code/hooks.d.ts.map +1 -1
  8. package/dist/providers/claude-code/install.d.ts.map +1 -1
  9. package/dist/providers/claude-code/paths.d.ts +96 -0
  10. package/dist/providers/claude-code/paths.d.ts.map +1 -1
  11. package/dist/providers/claude-code/spawn.d.ts +2 -2
  12. package/dist/providers/claude-code/spawn.d.ts.map +1 -1
  13. package/dist/providers/claude-sdk/spawn.d.ts +1 -1
  14. package/dist/providers/codex/adapter.d.ts.map +1 -1
  15. package/dist/providers/codex/spawn.d.ts +1 -1
  16. package/dist/providers/codex/spawn.d.ts.map +1 -1
  17. package/dist/providers/cursor/install.d.ts +11 -22
  18. package/dist/providers/cursor/install.d.ts.map +1 -1
  19. package/dist/providers/gemini-cli/adapter.d.ts.map +1 -1
  20. package/dist/providers/gemini-cli/spawn.d.ts +1 -1
  21. package/dist/providers/gemini-cli/spawn.d.ts.map +1 -1
  22. package/dist/providers/kimi/adapter.d.ts.map +1 -1
  23. package/dist/providers/kimi/spawn.d.ts.map +1 -1
  24. package/dist/providers/opencode/adapter.d.ts.map +1 -1
  25. package/dist/providers/opencode/spawn.d.ts +3 -2
  26. package/dist/providers/opencode/spawn.d.ts.map +1 -1
  27. package/dist/providers/pi/adapter.d.ts +1 -1
  28. package/dist/providers/pi/adapter.d.ts.map +1 -1
  29. package/dist/providers/pi/spawn.d.ts +3 -3
  30. package/dist/providers/pi/spawn.d.ts.map +1 -1
  31. package/dist/providers/shared/agent-spawn-wrapper.d.ts +5 -0
  32. package/dist/providers/shared/agent-spawn-wrapper.d.ts.map +1 -1
  33. package/dist/providers/shared/cli-spawn.d.ts +28 -0
  34. package/dist/providers/shared/cli-spawn.d.ts.map +1 -0
  35. package/dist/providers/shared/credentials.d.ts +2 -2
  36. package/dist/providers/shared/credentials.d.ts.map +1 -1
  37. package/dist/providers/shared/prompt-file.d.ts +32 -0
  38. package/dist/providers/shared/prompt-file.d.ts.map +1 -0
  39. package/package.json +5 -5
  40. package/src/__tests__/agent-spawn-wrapper-win32.test.ts +42 -0
  41. package/src/__tests__/cant-context.test.ts +89 -37
  42. package/src/__tests__/cli-spawn-win32.test.ts +105 -0
  43. package/src/__tests__/spawn-prompt-file.test.ts +37 -0
  44. package/src/cant-context.ts +11 -15
  45. package/src/providers/claude-code/__tests__/adapter.test.ts +1 -0
  46. package/src/providers/claude-code/__tests__/hooks-install.test.ts +63 -2
  47. package/src/providers/claude-code/adapter.ts +6 -11
  48. package/src/providers/claude-code/hooks.ts +60 -89
  49. package/src/providers/claude-code/install.ts +70 -96
  50. package/src/providers/claude-code/paths.ts +155 -0
  51. package/src/providers/claude-code/spawn.ts +11 -25
  52. package/src/providers/claude-sdk/spawn.ts +1 -1
  53. package/src/providers/codex/adapter.ts +6 -11
  54. package/src/providers/codex/spawn.ts +17 -31
  55. package/src/providers/cursor/__tests__/adapter.test.ts +7 -0
  56. package/src/providers/cursor/__tests__/hooks-install.test.ts +36 -0
  57. package/src/providers/cursor/install.ts +86 -133
  58. package/src/providers/gemini-cli/adapter.ts +6 -11
  59. package/src/providers/gemini-cli/spawn.ts +13 -18
  60. package/src/providers/kimi/adapter.ts +6 -11
  61. package/src/providers/kimi/spawn.ts +3 -11
  62. package/src/providers/opencode/__tests__/spawn-prompt-delivery.test.ts +123 -0
  63. package/src/providers/opencode/adapter.ts +6 -11
  64. package/src/providers/opencode/spawn.ts +23 -25
  65. package/src/providers/pi/adapter.ts +8 -18
  66. package/src/providers/pi/spawn.ts +15 -33
  67. package/src/providers/shared/__tests__/credentials-cleo-home.test.ts +42 -0
  68. package/src/providers/shared/agent-spawn-wrapper.ts +22 -2
  69. package/src/providers/shared/cli-spawn.ts +41 -0
  70. package/src/providers/shared/credentials.ts +6 -5
  71. package/src/providers/shared/prompt-file.ts +44 -0
@@ -16,11 +16,18 @@
16
16
  * @epic T134
17
17
  */
18
18
 
19
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
19
+ import { existsSync } from 'node:fs';
20
20
  import { readdir, readFile } from 'node:fs/promises';
21
- import { homedir } from 'node:os';
22
21
  import { join } from 'node:path';
22
+ import { updateJsonConfigFile } from '@cleocode/caamp';
23
23
  import type { AdapterHookProvider } from '@cleocode/contracts';
24
+ import {
25
+ appendHookEntry,
26
+ claudeSettingsPath,
27
+ hasCleoHook,
28
+ hookMap,
29
+ removeCleoHookEntries,
30
+ } from './paths.js';
24
31
 
25
32
  /** CAAMP provider identifier for Claude Code. */
26
33
  const PROVIDER_ID = 'claude-code' as const;
@@ -133,56 +140,23 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
133
140
  this.projectDir = projectDir;
134
141
  this.registered = true;
135
142
 
136
- // Write CLEO hook entries to ~/.claude/settings.json (idempotent)
137
- try {
138
- const home = homedir();
139
- const settingsPath = join(home, '.claude', 'settings.json');
140
-
141
- let settings: Record<string, unknown> = {};
142
- if (existsSync(settingsPath)) {
143
- try {
144
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
145
- } catch {
146
- // Start fresh if settings.json is corrupt
147
- }
148
- }
149
-
150
- const hooks = (settings.hooks ?? {}) as Record<string, unknown[]>;
151
-
152
- // Check if CLEO hooks already registered (look for our marker comment in commands)
153
- const alreadyRegistered = Object.values(hooks).some(
154
- (entries) =>
155
- Array.isArray(entries) &&
156
- entries.some(
157
- (e) =>
158
- typeof e === 'object' &&
159
- e !== null &&
160
- Array.isArray((e as Record<string, unknown>).hooks) &&
161
- ((e as Record<string, unknown>).hooks as Array<Record<string, string>>).some(
162
- (h) => typeof h.command === 'string' && h.command.includes('# cleo-hook'),
163
- ),
164
- ),
165
- );
166
-
167
- if (alreadyRegistered) {
168
- return; // Already wired — idempotent
143
+ // Write CLEO hook entries to settings.json (idempotent). T12385: locked,
144
+ // atomic, and a malformed file is reported and left untouched — never
145
+ // replaced with an object holding only CLEO's entries.
146
+ await this.updateSettings((settings) => {
147
+ const hooks = hookMap(settings);
148
+ if (Object.values(hooks).some((entries) => hasCleoHook(entries))) {
149
+ return false; // Already wired — idempotent
169
150
  }
170
151
 
171
152
  // Register Stop hook → triggers cleo session end (LLM extraction, reflector, consolidation)
172
- if (!hooks.Stop) hooks.Stop = [];
173
- (hooks.Stop as unknown[]).push({
153
+ appendHookEntry(hooks, 'Stop', {
174
154
  matcher: '',
175
- hooks: [
176
- {
177
- type: 'command',
178
- command: `cleo session end --quiet # cleo-hook`,
179
- },
180
- ],
155
+ hooks: [{ type: 'command', command: `cleo session end --quiet # cleo-hook` }],
181
156
  });
182
157
 
183
158
  // Register PostToolUse hook → brain observation for file writes + NEXUS post-check (T625)
184
- if (!hooks.PostToolUse) hooks.PostToolUse = [];
185
- (hooks.PostToolUse as unknown[]).push({
159
+ appendHookEntry(hooks, 'PostToolUse', {
186
160
  matcher: 'Write|Edit',
187
161
  hooks: [
188
162
  {
@@ -197,20 +171,16 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
197
171
  },
198
172
  ],
199
173
  });
200
-
201
- settings.hooks = hooks;
202
- mkdirSync(join(home, '.claude'), { recursive: true });
203
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
204
- } catch {
205
- // Settings write failure is non-fatal — hooks can be registered manually
206
- }
174
+ return true;
175
+ });
207
176
  }
208
177
 
209
178
  /**
210
179
  * Unregister native hooks.
211
180
  *
212
- * Removes CLEO hook entries from `~/.claude/settings.json` by filtering out
213
- * entries containing the `# cleo-hook` marker.
181
+ * Removes CLEO hook entries from Claude Code's `settings.json` by filtering
182
+ * out entries containing the `# cleo-hook` marker. Same locking, atomicity
183
+ * and parse-error rules as {@link registerNativeHooks}.
214
184
  *
215
185
  * @task T164 @task T555
216
186
  */
@@ -218,42 +188,43 @@ export class ClaudeCodeHookProvider implements AdapterHookProvider {
218
188
  this.registered = false;
219
189
  this.projectDir = null;
220
190
 
221
- try {
222
- const home = homedir();
223
- const settingsPath = join(home, '.claude', 'settings.json');
224
- if (!existsSync(settingsPath)) return;
225
-
226
- const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')) as Record<string, unknown>;
227
- const hooks = settings.hooks as Record<string, unknown[]> | undefined;
228
- if (!hooks) return;
229
-
230
- // Filter out entries with the cleo-hook marker
231
- let changed = false;
232
- for (const [event, entries] of Object.entries(hooks)) {
233
- if (!Array.isArray(entries)) continue;
234
- const filtered = entries.filter(
235
- (e) =>
236
- !(
237
- typeof e === 'object' &&
238
- e !== null &&
239
- Array.isArray((e as Record<string, unknown>).hooks) &&
240
- ((e as Record<string, unknown>).hooks as Array<Record<string, string>>).some(
241
- (h) => typeof h.command === 'string' && h.command.includes('# cleo-hook'),
242
- )
243
- ),
244
- );
245
- if (filtered.length !== entries.length) {
246
- hooks[event] = filtered;
247
- changed = true;
248
- }
249
- }
191
+ if (!existsSync(claudeSettingsPath())) return;
192
+ await this.updateSettings((settings) => {
193
+ if (settings.hooks === undefined) return false;
194
+ return removeCleoHookEntries(hookMap(settings));
195
+ });
196
+ }
250
197
 
251
- if (changed) {
252
- settings.hooks = hooks;
253
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
254
- }
255
- } catch {
256
- // Cleanup failure is non-fatal
198
+ /** The last settings.json failure, or `null` when the last update succeeded. */
199
+ private settingsError: string | null = null;
200
+
201
+ /**
202
+ * Why the last settings.json update did not happen, or `null`.
203
+ *
204
+ * @remarks
205
+ * Hook registration stays non-fatal for adapter initialisation, so a
206
+ * refused write (malformed settings.json, lock timeout) is surfaced here and
207
+ * on stderr instead of being swallowed (T12385).
208
+ *
209
+ * @returns The failure message, or `null`
210
+ */
211
+ getSettingsError(): string | null {
212
+ return this.settingsError;
213
+ }
214
+
215
+ /** Apply `mutate` to settings.json via CAAMP's locked, atomic JSON writer. */
216
+ private async updateSettings(
217
+ mutate: (settings: Record<string, unknown>) => boolean,
218
+ ): Promise<void> {
219
+ const settingsPath = claudeSettingsPath();
220
+ try {
221
+ await updateJsonConfigFile(settingsPath, mutate);
222
+ this.settingsError = null;
223
+ } catch (err) {
224
+ this.settingsError = err instanceof Error ? err.message : String(err);
225
+ process.stderr.write(
226
+ `[cleo:claude-code] hooks not written to ${settingsPath}: ${this.settingsError}\n`,
227
+ );
257
228
  }
258
229
  }
259
230
 
@@ -10,23 +10,23 @@
10
10
  * @task T5240
11
11
  */
12
12
 
13
- import {
14
- copyFileSync,
15
- existsSync,
16
- mkdirSync,
17
- readdirSync,
18
- readFileSync,
19
- writeFileSync,
20
- } from 'node:fs';
13
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
21
14
  import { homedir } from 'node:os';
22
15
  import { dirname, join } from 'node:path';
23
16
  import { fileURLToPath } from 'node:url';
24
- import { ensureProviderInstructionFile } from '@cleocode/caamp';
17
+ import { ensureProviderInstructionFile, updateJsonConfigFile } from '@cleocode/caamp';
25
18
  import type { AdapterInstallProvider, InstallOptions, InstallResult } from '@cleocode/contracts';
26
19
  import {
27
20
  type InstallHookTemplatesResult,
28
21
  installProviderHookTemplates,
29
22
  } from '../shared/hook-template-installer.js';
23
+ import {
24
+ appendHookEntry,
25
+ claudeSettingsPath,
26
+ hasCleoHook,
27
+ hookMap,
28
+ isPlainObject,
29
+ } from './paths.js';
30
30
 
31
31
  /** Resolve Claude Code's config directory, honoring the documented CI override. */
32
32
  function getClaudeConfigDir(): string {
@@ -80,21 +80,37 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
80
80
  details.commands = commandsInstalled;
81
81
  }
82
82
 
83
+ // T12385: settings.json writes are locked + atomic, and a malformed file
84
+ // aborts the write and is reported here — it is never reset to `{}`.
85
+ const settingsErrors: string[] = [];
86
+
83
87
  // Step 3: Register plugin in ~/.claude/settings.json
84
- const pluginResult = this.registerPlugin();
85
- if (pluginResult) {
86
- details.plugin = pluginResult;
88
+ try {
89
+ const pluginResult = await this.registerPlugin();
90
+ if (pluginResult) {
91
+ details.plugin = pluginResult;
92
+ }
93
+ } catch (err) {
94
+ settingsErrors.push(err instanceof Error ? err.message : String(err));
87
95
  }
88
96
 
89
97
  // Step 4 (T1013): Install PreCompact hook templates + wire the handler
90
98
  // command into ~/.claude/settings.json's `PreCompact` event.
91
- const hookResult = this.installHookTemplates();
92
- if (hookResult) {
93
- details.hookTemplates = hookResult;
99
+ try {
100
+ const hookResult = await this.installHookTemplates();
101
+ if (hookResult) {
102
+ details.hookTemplates = hookResult;
103
+ }
104
+ } catch (err) {
105
+ settingsErrors.push(err instanceof Error ? err.message : String(err));
106
+ }
107
+
108
+ if (settingsErrors.length > 0) {
109
+ details.settingsErrors = settingsErrors;
94
110
  }
95
111
 
96
112
  return {
97
- success: true,
113
+ success: settingsErrors.length === 0,
98
114
  installedAt,
99
115
  instructionFileUpdated,
100
116
  details,
@@ -115,7 +131,7 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
115
131
  */
116
132
  async isInstalled(): Promise<boolean> {
117
133
  // Check ~/.claude/settings.json for plugin registration
118
- const settingsPath = join(getClaudeConfigDir(), 'settings.json');
134
+ const settingsPath = claudeSettingsPath();
119
135
  if (existsSync(settingsPath)) {
120
136
  try {
121
137
  const settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
@@ -179,38 +195,28 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
179
195
  *
180
196
  * @returns Description of what was registered, or null if no change needed
181
197
  */
182
- private registerPlugin(): string | null {
183
- const claudeDir = getClaudeConfigDir();
184
- const settingsPath = join(claudeDir, 'settings.json');
185
-
186
- let settings: Record<string, unknown> = {};
187
- if (existsSync(settingsPath)) {
188
- try {
189
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
190
- } catch {
191
- // Start fresh
192
- }
193
- }
194
-
195
- const enabledPlugins = (settings.enabledPlugins as Record<string, boolean>) ?? {};
198
+ private async registerPlugin(): Promise<string | null> {
196
199
  const pluginKey = 'cleo@cleocode';
200
+ const wrote = await updateJsonConfigFile(claudeSettingsPath(), (settings) => {
201
+ const enabledPlugins = settings.enabledPlugins ?? {};
202
+ if (!isPlainObject(enabledPlugins)) {
203
+ throw new Error('settings.json "enabledPlugins" is not an object; not modifying it');
204
+ }
205
+ if (enabledPlugins[pluginKey] === true) {
206
+ return false;
207
+ }
197
208
 
198
- if (enabledPlugins[pluginKey] === true) {
199
- return null;
200
- }
201
-
202
- // Disable old claude-mem if present
203
- if (enabledPlugins['claude-mem@thedotmack'] === true) {
204
- enabledPlugins['claude-mem@thedotmack'] = false;
205
- }
206
-
207
- enabledPlugins[pluginKey] = true;
208
- settings.enabledPlugins = enabledPlugins;
209
+ // Disable old claude-mem if present
210
+ if (enabledPlugins['claude-mem@thedotmack'] === true) {
211
+ enabledPlugins['claude-mem@thedotmack'] = false;
212
+ }
209
213
 
210
- mkdirSync(claudeDir, { recursive: true });
211
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
214
+ enabledPlugins[pluginKey] = true;
215
+ settings.enabledPlugins = enabledPlugins;
216
+ return true;
217
+ });
212
218
 
213
- return `Enabled ${pluginKey} in ~/.claude/settings.json`;
219
+ return wrote ? `Enabled ${pluginKey} in ~/.claude/settings.json` : null;
214
220
  }
215
221
 
216
222
  /**
@@ -233,10 +239,10 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
233
239
  *
234
240
  * @task T1013
235
241
  */
236
- private installHookTemplates(): {
242
+ private async installHookTemplates(): Promise<{
237
243
  templates: InstallHookTemplatesResult;
238
244
  settingsEntryAdded: boolean;
239
- } | null {
245
+ } | null> {
240
246
  const claudeDir = getClaudeConfigDir();
241
247
  const hooksDir = join(claudeDir, 'hooks');
242
248
 
@@ -254,7 +260,7 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
254
260
  }
255
261
 
256
262
  // 2. Wire the PreCompact event in ~/.claude/settings.json.
257
- const settingsEntryAdded = this.registerPreCompactHook(
263
+ const settingsEntryAdded = await this.registerPreCompactHook(
258
264
  join(hooksDir, 'precompact-safestop.sh'),
259
265
  );
260
266
 
@@ -279,55 +285,23 @@ export class ClaudeCodeInstallProvider implements AdapterInstallProvider {
279
285
  *
280
286
  * @task T1013
281
287
  */
282
- private registerPreCompactHook(shimPath: string): boolean {
283
- const claudeDir = getClaudeConfigDir();
284
- const settingsPath = join(claudeDir, 'settings.json');
285
-
286
- let settings: Record<string, unknown> = {};
287
- if (existsSync(settingsPath)) {
288
- try {
289
- settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
290
- } catch {
291
- // Start fresh on corrupt settings — safer than aborting install.
288
+ private async registerPreCompactHook(shimPath: string): Promise<boolean> {
289
+ return updateJsonConfigFile(claudeSettingsPath(), (settings) => {
290
+ const hooks = hookMap(settings);
291
+ if (hasCleoHook(hooks.PreCompact, 'precompact-safestop.sh')) {
292
+ return false;
292
293
  }
293
- }
294
-
295
- const hooks = (settings.hooks as Record<string, unknown[]> | undefined) ?? {};
296
- const preCompactEntries = (hooks.PreCompact as unknown[] | undefined) ?? [];
297
-
298
- const alreadyWired = preCompactEntries.some(
299
- (entry) =>
300
- typeof entry === 'object' &&
301
- entry !== null &&
302
- Array.isArray((entry as Record<string, unknown>).hooks) &&
303
- ((entry as Record<string, unknown>).hooks as Array<Record<string, unknown>>).some(
304
- (h) =>
305
- typeof h.command === 'string' &&
306
- (h.command as string).includes('# cleo-hook') &&
307
- (h.command as string).includes('precompact-safestop.sh'),
308
- ),
309
- );
310
-
311
- if (alreadyWired) {
312
- return false;
313
- }
314
-
315
- preCompactEntries.push({
316
- matcher: '',
317
- hooks: [
318
- {
319
- type: 'command',
320
- command: `"${shimPath}" # cleo-hook`,
321
- timeout: 30,
322
- },
323
- ],
294
+ appendHookEntry(hooks, 'PreCompact', {
295
+ matcher: '',
296
+ hooks: [
297
+ {
298
+ type: 'command',
299
+ command: `"${shimPath}" # cleo-hook`,
300
+ timeout: 30,
301
+ },
302
+ ],
303
+ });
304
+ return true;
324
305
  });
325
-
326
- hooks.PreCompact = preCompactEntries;
327
- settings.hooks = hooks;
328
-
329
- mkdirSync(claudeDir, { recursive: true });
330
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
331
- return true;
332
306
  }
333
307
  }
@@ -44,3 +44,158 @@ export class ClaudeCodePathProvider implements AdapterPathProvider {
44
44
  return process.env['CLAUDE_MEM_DB'] ?? join(homedir(), '.claude-mem', 'claude-mem.db');
45
45
  }
46
46
  }
47
+
48
+ /**
49
+ * Resolve Claude Code's `settings.json`, honouring `CLAUDE_SETTINGS` and
50
+ * `CLAUDE_HOME`.
51
+ *
52
+ * @remarks
53
+ * The single resolver for every adapter write to the settings file (T12385).
54
+ * Hook registration previously hard-coded `~/.claude/settings.json` while the
55
+ * installer honoured `CLAUDE_HOME`, so the two wrote different files whenever
56
+ * the override was set.
57
+ *
58
+ * @returns Absolute path of the settings file
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * const settingsPath = claudeSettingsPath();
63
+ * ```
64
+ */
65
+ export function claudeSettingsPath(): string {
66
+ return (
67
+ new ClaudeCodePathProvider().getSettingsPath() ?? join(homedir(), '.claude', 'settings.json')
68
+ );
69
+ }
70
+
71
+ /** Marker CLEO puts in every hook command it owns. */
72
+ const CLEO_HOOK_MARKER = '# cleo-hook';
73
+
74
+ /**
75
+ * Whether a parsed settings value is a plain JSON object.
76
+ *
77
+ * @param value - Any parsed JSON value
78
+ * @returns `true` for a non-null, non-array object
79
+ *
80
+ * @example
81
+ * ```typescript
82
+ * isPlainObject(settings.enabledPlugins);
83
+ * ```
84
+ */
85
+ export function isPlainObject(value: unknown): value is Record<string, unknown> {
86
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
87
+ }
88
+
89
+ /**
90
+ * Whether a settings.json hook entry carries a CLEO-owned command.
91
+ *
92
+ * @param entry - One element of a `hooks.<Event>` array
93
+ * @param commandIncludes - Extra substring the CLEO command must contain
94
+ * @returns `true` when one of the entry's commands has the `# cleo-hook` marker
95
+ *
96
+ * @example
97
+ * ```typescript
98
+ * isCleoHookEntry({ hooks: [{ type: "command", command: "x # cleo-hook" }] }); // true
99
+ * ```
100
+ */
101
+ export function isCleoHookEntry(entry: unknown, commandIncludes?: string): boolean {
102
+ if (!isPlainObject(entry) || !Array.isArray(entry.hooks)) return false;
103
+ return entry.hooks.some(
104
+ (hook) =>
105
+ isPlainObject(hook) &&
106
+ typeof hook.command === 'string' &&
107
+ hook.command.includes(CLEO_HOOK_MARKER) &&
108
+ (commandIncludes === undefined || hook.command.includes(commandIncludes)),
109
+ );
110
+ }
111
+
112
+ /**
113
+ * Whether a `hooks.<Event>` value holds a CLEO-owned entry.
114
+ *
115
+ * @param entries - The event's value (non-arrays hold none)
116
+ * @param commandIncludes - Extra substring the CLEO command must contain
117
+ * @returns `true` when a CLEO entry is present
118
+ *
119
+ * @example
120
+ * ```typescript
121
+ * hasCleoHook(hookMap(settings).Stop);
122
+ * ```
123
+ */
124
+ export function hasCleoHook(entries: unknown, commandIncludes?: string): boolean {
125
+ return Array.isArray(entries) && entries.some((e) => isCleoHookEntry(e, commandIncludes));
126
+ }
127
+
128
+ /**
129
+ * Return the settings object's `hooks` map, creating an empty one if absent.
130
+ *
131
+ * @param settings - Parsed settings.json
132
+ * @returns The live `hooks` object (mutations apply to `settings`)
133
+ * @throws Error when `hooks` exists but is not an object — the caller's write
134
+ * is aborted and the file left untouched
135
+ *
136
+ * @example
137
+ * ```typescript
138
+ * const hooks = hookMap(settings);
139
+ * ```
140
+ */
141
+ export function hookMap(settings: Record<string, unknown>): Record<string, unknown> {
142
+ const existing = settings.hooks;
143
+ if (existing === undefined) {
144
+ const created: Record<string, unknown> = {};
145
+ settings.hooks = created;
146
+ return created;
147
+ }
148
+ if (!isPlainObject(existing)) {
149
+ throw new Error('settings.json "hooks" is not an object; not modifying it');
150
+ }
151
+ return existing;
152
+ }
153
+
154
+ /**
155
+ * Append a hook entry to `hooks[event]`, preserving existing entries.
156
+ *
157
+ * @param hooks - The settings `hooks` map
158
+ * @param event - Native event name (e.g. `Stop`, `PreCompact`)
159
+ * @param entry - Entry to append
160
+ * @throws Error when `hooks[event]` exists but is not an array
161
+ *
162
+ * @example
163
+ * ```typescript
164
+ * appendHookEntry(hookMap(settings), "Stop", { matcher: "", hooks: [] });
165
+ * ```
166
+ */
167
+ export function appendHookEntry(
168
+ hooks: Record<string, unknown>,
169
+ event: string,
170
+ entry: Record<string, unknown>,
171
+ ): void {
172
+ const existing = hooks[event];
173
+ if (existing !== undefined && !Array.isArray(existing)) {
174
+ throw new Error(`settings.json "hooks.${event}" is not an array; not modifying it`);
175
+ }
176
+ hooks[event] = [...(existing ?? []), entry];
177
+ }
178
+
179
+ /**
180
+ * Remove every CLEO-owned entry from every event array in `hooks`.
181
+ *
182
+ * @param hooks - The settings `hooks` map
183
+ * @returns Whether anything was removed
184
+ *
185
+ * @example
186
+ * ```typescript
187
+ * const changed = removeCleoHookEntries(hookMap(settings));
188
+ * ```
189
+ */
190
+ export function removeCleoHookEntries(hooks: Record<string, unknown>): boolean {
191
+ let changed = false;
192
+ for (const [event, entries] of Object.entries(hooks)) {
193
+ if (!Array.isArray(entries)) continue;
194
+ const kept = entries.filter((e) => !isCleoHookEntry(e));
195
+ if (kept.length !== entries.length) {
196
+ hooks[event] = kept;
197
+ changed = true;
198
+ }
199
+ }
200
+ return changed;
201
+ }
@@ -13,9 +13,7 @@
13
13
  * @task T11998 — per-session scope/pgid suite containment
14
14
  */
15
15
 
16
- import { exec, spawn as nodeSpawn } from 'node:child_process';
17
- import { unlink, writeFile } from 'node:fs/promises';
18
- import { promisify } from 'node:util';
16
+ import { spawn as nodeSpawn } from 'node:child_process';
19
17
  import type {
20
18
  AdapterSpawnProvider,
21
19
  AgentSuiteOwnership,
@@ -23,11 +21,11 @@ import type {
23
21
  SpawnResult,
24
22
  } from '@cleocode/contracts';
25
23
  import { getErrorMessage } from '@cleocode/contracts';
24
+ import { findOnPath } from '@cleocode/paths';
26
25
  import { buildAgentSpawnArgs } from '../shared/agent-spawn-wrapper.js';
26
+ import { removeSpawnPromptFile, writeSpawnPromptFile } from '../shared/prompt-file.js';
27
27
  import { reapAgentSuite } from './suite-reaper.js';
28
28
 
29
- const execAsync = promisify(exec);
30
-
31
29
  /** Internal tracking entry for a spawned process. */
32
30
  interface TrackedProcess {
33
31
  pid: number;
@@ -48,7 +46,7 @@ interface TrackedProcess {
48
46
  * @remarks
49
47
  * The provider uses `--allow-insecure --no-upgrade-check` flags to
50
48
  * ensure the Claude CLI starts without interactive prompts. Prompts are
51
- * written to temporary files under `/tmp/` and cleaned up after the
49
+ * written to temporary files under a private temp directory (`os.tmpdir()`) and cleaned up after the
52
50
  * child process exits. Processes are tracked by instance ID in an
53
51
  * in-memory map and verified via `kill(pid, 0)` liveness checks.
54
52
  */
@@ -59,15 +57,10 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
59
57
  /**
60
58
  * Check if the Claude CLI is available in PATH.
61
59
  *
62
- * @returns true if `claude` is found via `which`
60
+ * @returns true if `claude` is found on PATH (PATHEXT-aware on Windows)
63
61
  */
64
62
  async canSpawn(): Promise<boolean> {
65
- try {
66
- await execAsync('which claude');
67
- return true;
68
- } catch {
69
- return false;
70
- }
63
+ return findOnPath('claude') !== null;
71
64
  }
72
65
 
73
66
  /**
@@ -103,8 +96,7 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
103
96
  // CANT enrichment unavailable — use raw prompt
104
97
  }
105
98
 
106
- tmpFile = `/tmp/claude-spawn-${instanceId}.txt`;
107
- await writeFile(tmpFile, enrichedPrompt, 'utf-8');
99
+ tmpFile = await writeSpawnPromptFile('claude-spawn', enrichedPrompt);
108
100
 
109
101
  // --print: non-interactive batch mode (process prompt, output response, exit)
110
102
  // --dangerously-skip-permissions: allow all tool calls without human approval
@@ -131,6 +123,8 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
131
123
  const spawnOpts: Parameters<typeof nodeSpawn>[2] = {
132
124
  detached: !isSystemd,
133
125
  stdio: ['ignore', 'pipe', 'pipe'],
126
+ // win32 `.cmd` shim routed through cmd.exe: keep its quoting (T12618).
127
+ ...(spawnBuild.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}),
134
128
  };
135
129
 
136
130
  if (context.workingDirectory) {
@@ -177,11 +171,7 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
177
171
  const capturedTmpFile = tmpFile;
178
172
  child.on('exit', async () => {
179
173
  this.processMap.delete(instanceId);
180
- try {
181
- await unlink(capturedTmpFile);
182
- } catch {
183
- // Ignore cleanup errors
184
- }
174
+ await removeSpawnPromptFile(capturedTmpFile);
185
175
  });
186
176
 
187
177
  return {
@@ -199,11 +189,7 @@ export class ClaudeCodeSpawnProvider implements AdapterSpawnProvider {
199
189
  console.error(`[ClaudeCodeSpawnProvider] Failed to spawn: ${getErrorMessage(error)}`);
200
190
 
201
191
  if (tmpFile) {
202
- try {
203
- await unlink(tmpFile);
204
- } catch {
205
- // Ignore cleanup errors
206
- }
192
+ await removeSpawnPromptFile(tmpFile);
207
193
  }
208
194
 
209
195
  return {