amalgm 0.1.48 → 0.1.50

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.
@@ -8,7 +8,7 @@
8
8
 
9
9
  const crypto = require('crypto');
10
10
  const { textResult, errorResult } = require('../lib/tool-result');
11
- const { loadTasks, saveTasks, readRunLog } = require('./store');
11
+ const { claimTaskRun, loadTasks, saveTasks, readRunLog } = require('./store');
12
12
  const { validateCronExpr } = require('./scheduler');
13
13
  const { normalizeTaskSchedule } = require('./schedule-normalization');
14
14
  const { executeTask, isRunning, getRunning, abortRunning } = require('./executor');
@@ -239,6 +239,7 @@ module.exports = [
239
239
  prompt: { type: 'string' },
240
240
  enabled: { type: 'boolean' },
241
241
  endsAt: { type: 'string' },
242
+ nextRunAt: { type: 'string' },
242
243
  maxConcurrentRuns: { type: 'number' },
243
244
  harness: { type: 'string' },
244
245
  model: { type: 'string' },
@@ -249,7 +250,7 @@ module.exports = [
249
250
  },
250
251
  required: ['task_id'],
251
252
  },
252
- async handler({ task_id, name, description, schedule, prompt, enabled, endsAt, maxConcurrentRuns, harness, model, modelSettings, authMethod, projectPath, chatInput }) {
253
+ async handler({ task_id, name, description, schedule, prompt, enabled, endsAt, nextRunAt, maxConcurrentRuns, harness, model, modelSettings, authMethod, projectPath, chatInput }) {
253
254
  const data = loadTasks();
254
255
  const task = data.tasks.find((t) => t.id === task_id);
255
256
  if (!task) return errorResult(`Task not found: ${task_id}`);
@@ -274,6 +275,7 @@ module.exports = [
274
275
  if (prompt !== undefined) task.prompt = prompt;
275
276
  if (enabled !== undefined) task.enabled = enabled;
276
277
  if (endsAt !== undefined) task.endsAt = endsAt || null;
278
+ if (nextRunAt !== undefined) task.nextRunAt = nextRunAt || null;
277
279
  if (maxConcurrentRuns !== undefined) task.maxConcurrentRuns = maxConcurrentRuns;
278
280
  if (harness !== undefined) task.harness = harness || null;
279
281
  if (model !== undefined) task.model = model || null;
@@ -319,7 +321,7 @@ module.exports = [
319
321
  const normalizedTask = normalizeTaskRecord(task);
320
322
  Object.assign(task, normalizedTask);
321
323
 
322
- saveTasks(data);
324
+ saveTasks(data, { preserveNextRunAt: nextRunAt !== undefined });
323
325
  activeMemory.ensureConstructMemory({
324
326
  type: 'task',
325
327
  id: task.id,
@@ -362,7 +364,13 @@ module.exports = [
362
364
  const task = data.tasks.find((t) => t.id === task_id);
363
365
  if (!task) return errorResult(`Task not found: ${task_id}`);
364
366
  if (isRunning(task_id)) return textResult(`Task "${task.name}" is already running.`);
365
- executeTask(task).catch((err) =>
367
+ const claimed = claimTaskRun(task, {
368
+ now: new Date(),
369
+ scheduledFor: new Date().toISOString(),
370
+ source: 'tasks:run-now',
371
+ });
372
+ if (!claimed) return textResult(`Task "${task.name}" is already running.`);
373
+ executeTask(claimed.task, claimed.run).catch((err) =>
366
374
  console.error(`[AmalgmMCP:RunNow] ${task_id} failed:`, err.message),
367
375
  );
368
376
  return textResult(`Task "${task.name}" triggered. Running in background.`);
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+
9
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-tasks-store-test-'));
10
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
11
+
12
+ const { closeLocalDb } = require('../state/db');
13
+ const {
14
+ appendRunLog,
15
+ claimDueTaskRuns,
16
+ listTaskRuns,
17
+ loadTasks,
18
+ saveTasks,
19
+ updateTaskMeta,
20
+ } = require('../tasks/store');
21
+ const { buildSnapshot } = require('../state/snapshot');
22
+
23
+ test.after(() => {
24
+ closeLocalDb();
25
+ fs.rmSync(tempRoot, { recursive: true, force: true });
26
+ });
27
+
28
+ function cronTask(overrides = {}) {
29
+ return {
30
+ id: 'task-cron',
31
+ name: 'Daily report',
32
+ enabled: true,
33
+ schedule: {
34
+ kind: 'cron',
35
+ expr: '0 9 * * *',
36
+ tz: 'America/Los_Angeles',
37
+ },
38
+ prompt: 'Write the daily report.',
39
+ createdAt: '2026-05-18T15:55:00.000Z',
40
+ lastRunAt: null,
41
+ lastStatus: null,
42
+ ...overrides,
43
+ };
44
+ }
45
+
46
+ test('scheduled tasks are stored in SQLite with a computed next run', () => {
47
+ saveTasks({ version: 2, tasks: [cronTask()] }, { source: 'test:create' });
48
+
49
+ const task = loadTasks().tasks.find((item) => item.id === 'task-cron');
50
+
51
+ assert.ok(task);
52
+ assert.equal(task.nextRunAt, '2026-05-18T16:00:00.000Z');
53
+ });
54
+
55
+ test('scheduled task saves can preserve an explicit next run anchor', () => {
56
+ saveTasks({
57
+ version: 2,
58
+ tasks: [
59
+ cronTask(),
60
+ cronTask({
61
+ id: 'task-explicit-next-run',
62
+ nextRunAt: '2026-05-20T18:42:00.000Z',
63
+ }),
64
+ ],
65
+ }, { source: 'test:preserve-next-run', preserveNextRunAt: true });
66
+
67
+ const task = loadTasks().tasks.find((item) => item.id === 'task-explicit-next-run');
68
+
69
+ assert.ok(task);
70
+ assert.equal(task.nextRunAt, '2026-05-20T18:42:00.000Z');
71
+ });
72
+
73
+ test('due task claims create one durable run receipt', () => {
74
+ const claims = claimDueTaskRuns(new Date('2026-05-18T16:00:30.000Z'), {
75
+ source: 'test:scheduler',
76
+ runnerId: 'test-runner',
77
+ });
78
+
79
+ assert.equal(claims.length, 1);
80
+ assert.equal(claims[0].task.id, 'task-cron');
81
+ assert.equal(claims[0].run.scheduledFor, '2026-05-18T16:00:00.000Z');
82
+ assert.equal(claims[0].run.status, 'running');
83
+
84
+ const duplicateClaims = claimDueTaskRuns(new Date('2026-05-18T16:00:45.000Z'), {
85
+ source: 'test:scheduler',
86
+ runnerId: 'test-runner',
87
+ });
88
+ assert.equal(duplicateClaims.length, 0);
89
+
90
+ const runs = listTaskRuns({ taskId: 'task-cron' });
91
+ assert.equal(runs.length, 1);
92
+ assert.equal(runs[0].status, 'running');
93
+ });
94
+
95
+ test('run receipt updates are replayable local live events', () => {
96
+ const [run] = listTaskRuns({ taskId: 'task-cron' });
97
+ appendRunLog('task-cron', {
98
+ runId: run.id,
99
+ finishedAt: '2026-05-18T16:01:00.000Z',
100
+ status: 'completed',
101
+ durationMs: 30_000,
102
+ });
103
+ updateTaskMeta('task-cron', { lastStatus: 'completed' }, { source: 'test:complete' });
104
+
105
+ const updatedRun = listTaskRuns({ taskId: 'task-cron' })[0];
106
+ assert.equal(updatedRun.status, 'completed');
107
+ assert.equal(updatedRun.finishedAt, '2026-05-18T16:01:00.000Z');
108
+
109
+ const snapshot = buildSnapshot('tasks,task_runs');
110
+ assert.ok(snapshot.seq > 0);
111
+ assert.equal(snapshot.resources.tasks.some((task) => task.id === 'task-cron'), true);
112
+ assert.equal(snapshot.resources.task_runs.some((item) => item.id === run.id), true);
113
+ });
@@ -8,7 +8,7 @@ const { normalizeClaudeMessage, usageRecordsFromClaudeResult, usageFromClaude }
8
8
  const { recordNativeEvent } = require('../recorder');
9
9
  const { toClaudeMcpServers } = require('../tooling/mcp-bundle');
10
10
  const { bundledClaudeBinary } = require('../tooling/native-binaries');
11
- const { syncNativeHarnessConfig } = require('../tooling/native-config');
11
+ const { claudeNativeHookSettings } = require('../tooling/native-config');
12
12
  const { importPackage } = require('../tooling/package-import');
13
13
  const { composeSystemPrompt } = require('../tooling/system-prompt');
14
14
 
@@ -32,15 +32,18 @@ class ClaudeAdapter {
32
32
  }
33
33
 
34
34
  options(contract, extra = {}) {
35
- syncNativeHarnessConfig(contract);
36
35
  const systemPrompt = composeSystemPrompt(contract);
37
36
  const pathToClaudeCodeExecutable = process.env.CLAUDE_CODE_BINARY || bundledClaudeBinary();
37
+ const settings = {
38
+ ...(claudeNativeHookSettings({ cwd: contract.cwd }) || {}),
39
+ ...(contract.fastMode ? { fastMode: true, fastModePerSessionOptIn: true } : {}),
40
+ };
38
41
  return {
39
42
  cwd: contract.cwd,
40
43
  env: runtimeEnv(contract),
41
44
  model: contract.cliModel,
42
45
  ...(contract.reasoningEffort ? { effort: contract.reasoningEffort } : {}),
43
- ...(contract.fastMode ? { settings: { fastMode: true, fastModePerSessionOptIn: true } } : {}),
46
+ ...(Object.keys(settings).length > 0 ? { settings } : {}),
44
47
  ...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
45
48
  ...(systemPrompt ? { systemPrompt: { type: 'preset', preset: 'claude_code', append: systemPrompt } } : {}),
46
49
  mcpServers: toClaudeMcpServers(contract),
@@ -49,7 +52,7 @@ class ClaudeAdapter {
49
52
  ...(process.env.CHAT_CORE_DEBUG_CLAUDE === '1'
50
53
  ? { debug: true, debugFile: path.join(contract.auth.runtimeHome || process.cwd(), 'claude-debug.log') }
51
54
  : {}),
52
- settingSources: ['user', 'project', 'local'],
55
+ settingSources: [],
53
56
  strictMcpConfig: false,
54
57
  ...extra,
55
58
  };
@@ -263,6 +263,10 @@ function hookTrustBlocks(toml) {
263
263
  return blocks;
264
264
  }
265
265
 
266
+ function hookTrustToml(toml) {
267
+ return hookTrustBlocks(toml).map((block) => block.lines.join('\n')).join('\n\n');
268
+ }
269
+
266
270
  function mirrorCodexHookTrust(toml, sourceDir, runtimeHome) {
267
271
  if (!sourceDir || !runtimeHome) return toml;
268
272
  const sourceHookPrefix = `${path.join(sourceDir, 'hooks.json')}:`;
@@ -298,7 +302,7 @@ function generatedMcpSectionNames(contract) {
298
302
  function buildCodexConfig(contract, existingConfig, syncInfo) {
299
303
  const mcpToml = toCodexMcpToml(contract);
300
304
  const topLevelKeys = [
301
- ...(contract.authMethod === 'provider_auth' ? [] : ['model_provider']),
305
+ 'model_provider',
302
306
  ...(contract.authMethod === 'amalgm' ? ['model_context_window', 'model_auto_compact_token_limit'] : []),
303
307
  ];
304
308
  let config = removeTopLevelKeys(existingConfig, topLevelKeys);
@@ -324,6 +328,8 @@ function buildCodexConfig(contract, existingConfig, syncInfo) {
324
328
  ].join('\n')
325
329
  : contract.authMethod === 'byok'
326
330
  ? 'model_provider = "openai"'
331
+ : contract.authMethod === 'provider_auth'
332
+ ? 'model_provider = "openai"'
327
333
  : '';
328
334
  return [
329
335
  generated.trimEnd(),
@@ -337,18 +343,19 @@ function writeConfig(contract) {
337
343
  if (!home) return;
338
344
  fs.mkdirSync(home, { recursive: true });
339
345
  const syncInfo = syncCodexNativeConfig(home);
346
+ const nativeHookTrust = hookTrustToml(readTextFile(syncInfo?.sourceConfigPath));
340
347
  const configPath = path.join(home, 'config.toml');
341
348
  if (contract.authMethod === 'provider_auth') {
342
- const sourceAuth = path.join(os.homedir(), '.codex', 'auth.json');
349
+ const sourceAuth = path.join(syncInfo?.sourceDir || path.join(os.homedir(), '.codex'), 'auth.json');
343
350
  const targetAuth = path.join(home, 'auth.json');
344
351
  if (fs.existsSync(sourceAuth)) {
345
352
  fs.copyFileSync(sourceAuth, targetAuth);
346
353
  fs.chmodSync(targetAuth, 0o600);
347
354
  }
348
- fs.writeFileSync(configPath, buildCodexConfig(contract, readTextFile(configPath), syncInfo), { mode: 0o600 });
355
+ fs.writeFileSync(configPath, buildCodexConfig(contract, nativeHookTrust, syncInfo), { mode: 0o600 });
349
356
  return;
350
357
  }
351
- fs.writeFileSync(configPath, buildCodexConfig(contract, readTextFile(configPath), syncInfo), { mode: 0o600 });
358
+ fs.writeFileSync(configPath, buildCodexConfig(contract, nativeHookTrust, syncInfo), { mode: 0o600 });
352
359
  fs.writeFileSync(path.join(home, 'auth.json'), JSON.stringify({
353
360
  auth_mode: 'apikey',
354
361
  OPENAI_API_KEY: contract.auth.tokenRef,
@@ -488,6 +495,7 @@ module.exports = {
488
495
  __private: {
489
496
  buildCodexConfig,
490
497
  ensureFeatureBoolean,
498
+ hookTrustToml,
491
499
  mirrorCodexHookTrust,
492
500
  modelWindowConfigLines,
493
501
  removeTomlSections,
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const fs = require('node:fs');
5
+ const os = require('node:os');
6
+ const path = require('node:path');
7
+ const test = require('node:test');
8
+ const { ClaudeAdapter } = require('../adapters/claude');
9
+ const { __private: codexPrivate } = require('../adapters/codex');
10
+ const {
11
+ claudeNativeHookSettings,
12
+ syncCodexNativeConfig,
13
+ } = require('../tooling/native-config');
14
+
15
+ function withNativeHome(fn) {
16
+ const previousNativeHome = process.env.AMALGM_NATIVE_HOME;
17
+ const previousHome = process.env.HOME;
18
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-native-config-'));
19
+ process.env.AMALGM_NATIVE_HOME = root;
20
+ process.env.HOME = root;
21
+ try {
22
+ return fn(root);
23
+ } finally {
24
+ if (previousNativeHome === undefined) delete process.env.AMALGM_NATIVE_HOME;
25
+ else process.env.AMALGM_NATIVE_HOME = previousNativeHome;
26
+ if (previousHome === undefined) delete process.env.HOME;
27
+ else process.env.HOME = previousHome;
28
+ fs.rmSync(root, { recursive: true, force: true });
29
+ }
30
+ }
31
+
32
+ test('codex native sync copies hook support without bulk runtime state', () => {
33
+ withNativeHome((home) => {
34
+ const source = path.join(home, '.codex');
35
+ fs.mkdirSync(path.join(source, 'sessions'), { recursive: true });
36
+ fs.mkdirSync(path.join(source, 'worktrees'), { recursive: true });
37
+ fs.mkdirSync(path.join(source, 'plugins'), { recursive: true });
38
+ fs.mkdirSync(path.join(source, 'supermemory'), { recursive: true });
39
+ fs.writeFileSync(path.join(source, 'hooks.json'), '{"hooks":{}}');
40
+ fs.writeFileSync(path.join(source, 'supermemory.json'), '{"projectContainerTag":"test"}');
41
+ fs.writeFileSync(path.join(source, 'supermemory', 'recall.js'), 'console.log("recall")');
42
+ fs.writeFileSync(path.join(source, 'sessions', 'huge.jsonl'), 'nope');
43
+ fs.writeFileSync(path.join(source, 'worktrees', 'tree'), 'nope');
44
+ fs.writeFileSync(path.join(source, 'plugins', 'cache'), 'nope');
45
+
46
+ const runtimeHome = path.join(home, 'runtime-home');
47
+ fs.mkdirSync(path.join(runtimeHome, 'sessions'), { recursive: true });
48
+ fs.writeFileSync(path.join(runtimeHome, 'sessions', 'legacy.jsonl'), 'old');
49
+
50
+ const result = syncCodexNativeConfig(runtimeHome);
51
+
52
+ assert.equal(result.sourceDir, source);
53
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'hooks.json')), true);
54
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'supermemory.json')), true);
55
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'supermemory', 'recall.js')), true);
56
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'sessions')), false);
57
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'worktrees')), false);
58
+ assert.equal(fs.existsSync(path.join(runtimeHome, 'plugins')), false);
59
+ assert.equal(fs.existsSync(path.join(runtimeHome, '.codex', 'hooks.json')), true);
60
+ });
61
+ });
62
+
63
+ test('codex provider config keeps hooks but drops native model and mcp config', () => {
64
+ withNativeHome((home) => {
65
+ const source = path.join(home, '.codex');
66
+ fs.mkdirSync(source, { recursive: true });
67
+ const hooksPath = path.join(source, 'hooks.json');
68
+ fs.writeFileSync(hooksPath, '{"hooks":{"UserPromptSubmit":[]}}');
69
+ fs.writeFileSync(path.join(source, 'auth.json'), '{"auth_mode":"chatgpt"}');
70
+ fs.writeFileSync(path.join(source, 'config.toml'), [
71
+ 'model_provider = "native-provider"',
72
+ '',
73
+ '[mcp_servers.native]',
74
+ 'command = "native-mcp"',
75
+ '',
76
+ `[hooks.state.${JSON.stringify(`${hooksPath}:UserPromptSubmit:0`)}]`,
77
+ 'trusted = true',
78
+ '',
79
+ ].join('\n'));
80
+
81
+ const runtimeHome = path.join(home, 'runtime-home');
82
+ codexPrivate.writeConfig({
83
+ authMethod: 'provider_auth',
84
+ auth: { runtimeHome },
85
+ mcpServers: [],
86
+ });
87
+
88
+ const config = fs.readFileSync(path.join(runtimeHome, 'config.toml'), 'utf8');
89
+ assert.match(config, /model_provider = "openai"/);
90
+ assert.match(config, /codex_hooks = true/);
91
+ assert.doesNotMatch(config, /native-provider/);
92
+ assert.doesNotMatch(config, /mcp_servers\.native/);
93
+ assert.equal(config.includes(path.join(runtimeHome, 'hooks.json')), true);
94
+ });
95
+ });
96
+
97
+ test('claude extracts native hooks without enabling full filesystem settings', () => {
98
+ withNativeHome((home) => {
99
+ const source = path.join(home, '.claude');
100
+ fs.mkdirSync(source, { recursive: true });
101
+ fs.writeFileSync(path.join(source, 'settings.json'), JSON.stringify({
102
+ permissions: { allow: ['Bash(*)'] },
103
+ hooks: {
104
+ UserPromptSubmit: [{
105
+ hooks: [{ type: 'command', command: 'echo recall', timeout: 1 }],
106
+ }],
107
+ },
108
+ }));
109
+
110
+ const hookSettings = claudeNativeHookSettings({ cwd: home });
111
+ assert.deepEqual(Object.keys(hookSettings.hooks), ['UserPromptSubmit']);
112
+ assert.equal(hookSettings.permissions, undefined);
113
+
114
+ const adapter = new ClaudeAdapter();
115
+ const options = adapter.options({
116
+ authMethod: 'provider_auth',
117
+ auth: { runtimeHome: null },
118
+ cwd: home,
119
+ cliModel: 'anthropic/claude-opus-4.7',
120
+ mcpServers: [],
121
+ });
122
+
123
+ assert.deepEqual(options.settingSources, []);
124
+ assert.equal(options.settings.permissions, undefined);
125
+ assert.equal(options.settings.hooks.UserPromptSubmit[0].hooks[0].command, 'echo recall');
126
+ });
127
+ });
@@ -9,12 +9,18 @@ const EXCLUDED_DIR_NAMES = new Set([
9
9
  '.npm',
10
10
  '.tmp',
11
11
  'cache',
12
+ 'generated_images',
12
13
  'log',
13
14
  'logs',
15
+ 'memories',
16
+ 'node_modules',
17
+ 'plugins',
14
18
  'projects',
15
19
  'sessions',
16
20
  'shell_snapshots',
21
+ 'skills',
17
22
  'tmp',
23
+ 'worktrees',
18
24
  ]);
19
25
 
20
26
  const EXCLUDED_FILE_PATTERNS = [
@@ -66,8 +72,8 @@ function copyFileIfPresent(source, target) {
66
72
  return true;
67
73
  }
68
74
 
69
- function copyIntoHomeAlias(runtimeHome, dotDirName, sourceDir) {
70
- if (!runtimeHome || !dotDirName || !sourceDir || !exists(sourceDir)) return;
75
+ function ensureHomeAlias(runtimeHome, dotDirName) {
76
+ if (!runtimeHome || !dotDirName) return;
71
77
  const alias = path.join(runtimeHome, dotDirName);
72
78
  try {
73
79
  const stat = fs.lstatSync(alias);
@@ -75,9 +81,6 @@ function copyIntoHomeAlias(runtimeHome, dotDirName, sourceDir) {
75
81
  const target = fs.readlinkSync(alias);
76
82
  if (target === '.' || path.resolve(runtimeHome, target) === path.resolve(runtimeHome)) return;
77
83
  fs.rmSync(alias, { recursive: true, force: true });
78
- } else if (stat.isDirectory()) {
79
- copyConfigTree(sourceDir, alias);
80
- return;
81
84
  } else {
82
85
  fs.rmSync(alias, { recursive: true, force: true });
83
86
  }
@@ -85,34 +88,138 @@ function copyIntoHomeAlias(runtimeHome, dotDirName, sourceDir) {
85
88
  try {
86
89
  fs.symlinkSync('.', alias, 'dir');
87
90
  } catch {
88
- copyConfigTree(sourceDir, alias);
91
+ fs.mkdirSync(alias, { recursive: true });
92
+ }
93
+ }
94
+
95
+ function nativeHome() {
96
+ return process.env.AMALGM_NATIVE_HOME || os.homedir();
97
+ }
98
+
99
+ function pruneLegacyCodexRuntimeHome(runtimeHome) {
100
+ for (const name of EXCLUDED_DIR_NAMES) {
101
+ try {
102
+ fs.rmSync(path.join(runtimeHome, name), { recursive: true, force: true });
103
+ } catch {}
89
104
  }
90
105
  }
91
106
 
107
+ function copyDirBounded(sourceDir, targetDir, options = {}) {
108
+ if (!sourceDir || !targetDir || !exists(sourceDir)) return { copied: false, files: 0, bytes: 0, truncated: false };
109
+ const maxFiles = Number(options.maxFiles || 200);
110
+ const maxBytes = Number(options.maxBytes || 10 * 1024 * 1024);
111
+ const state = { copied: false, files: 0, bytes: 0, truncated: false };
112
+ const root = path.resolve(sourceDir);
113
+
114
+ function walk(source, target) {
115
+ if (state.truncated || !shouldCopyConfigPath(root, source)) return;
116
+ let stat;
117
+ try {
118
+ stat = fs.lstatSync(source);
119
+ } catch {
120
+ return;
121
+ }
122
+ if (stat.isSymbolicLink()) return;
123
+ if (stat.isDirectory()) {
124
+ fs.mkdirSync(target, { recursive: true });
125
+ for (const entry of fs.readdirSync(source)) {
126
+ walk(path.join(source, entry), path.join(target, entry));
127
+ if (state.truncated) break;
128
+ }
129
+ return;
130
+ }
131
+ if (!stat.isFile()) return;
132
+ if (state.files + 1 > maxFiles || state.bytes + stat.size > maxBytes) {
133
+ state.truncated = true;
134
+ return;
135
+ }
136
+ fs.mkdirSync(path.dirname(target), { recursive: true });
137
+ fs.copyFileSync(source, target);
138
+ state.files += 1;
139
+ state.bytes += stat.size;
140
+ state.copied = true;
141
+ }
142
+
143
+ walk(sourceDir, targetDir);
144
+ return state;
145
+ }
146
+
92
147
  function syncCodexNativeConfig(runtimeHome) {
93
148
  if (!runtimeHome) return null;
94
- const nativeHome = os.homedir();
95
- const sourceDir = path.join(nativeHome, '.codex');
149
+ const sourceDir = path.join(nativeHome(), '.codex');
96
150
  fs.mkdirSync(runtimeHome, { recursive: true });
97
- const copied = copyConfigTree(sourceDir, runtimeHome);
98
- copyIntoHomeAlias(runtimeHome, '.codex', sourceDir);
99
- copyConfigTree(path.join(nativeHome, '.codex-supermemory'), path.join(runtimeHome, '.codex-supermemory'));
100
- copyFileIfPresent(path.join(nativeHome, '.codex-supermemory.log'), path.join(runtimeHome, '.codex-supermemory.log'));
101
- return copied ? { sourceDir, runtimeHome } : null;
151
+ if (!exists(sourceDir)) return null;
152
+
153
+ pruneLegacyCodexRuntimeHome(runtimeHome);
154
+ const copiedFiles = [
155
+ copyFileIfPresent(path.join(sourceDir, 'hooks.json'), path.join(runtimeHome, 'hooks.json')),
156
+ copyFileIfPresent(path.join(sourceDir, 'supermemory.json'), path.join(runtimeHome, 'supermemory.json')),
157
+ ].filter(Boolean).length;
158
+ const supermemory = copyDirBounded(path.join(sourceDir, 'supermemory'), path.join(runtimeHome, 'supermemory'));
159
+ copyDirBounded(path.join(nativeHome(), '.codex-supermemory'), path.join(runtimeHome, '.codex-supermemory'));
160
+ ensureHomeAlias(runtimeHome, '.codex');
161
+
162
+ return {
163
+ sourceDir,
164
+ runtimeHome,
165
+ sourceConfigPath: path.join(sourceDir, 'config.toml'),
166
+ copied: copiedFiles + (supermemory.copied ? 1 : 0) > 0,
167
+ truncated: supermemory.truncated,
168
+ };
102
169
  }
103
170
 
104
171
  function syncClaudeNativeConfig(runtimeHome) {
105
172
  if (!runtimeHome) return null;
106
- const nativeHome = os.homedir();
107
- const sourceDir = path.join(nativeHome, '.claude');
173
+ const sourceDir = path.join(nativeHome(), '.claude');
108
174
  fs.mkdirSync(runtimeHome, { recursive: true });
109
175
  const copied = copyConfigTree(sourceDir, runtimeHome);
110
- copyIntoHomeAlias(runtimeHome, '.claude', sourceDir);
111
- copyFileIfPresent(path.join(nativeHome, '.claude.json'), path.join(runtimeHome, '.claude.json'));
112
- copyConfigTree(path.join(nativeHome, '.config', 'claude'), path.join(runtimeHome, '.config', 'claude'));
176
+ ensureHomeAlias(runtimeHome, '.claude');
177
+ copyFileIfPresent(path.join(nativeHome(), '.claude.json'), path.join(runtimeHome, '.claude.json'));
178
+ copyConfigTree(path.join(nativeHome(), '.config', 'claude'), path.join(runtimeHome, '.config', 'claude'));
113
179
  return copied ? { sourceDir, runtimeHome } : null;
114
180
  }
115
181
 
182
+ function readJsonFile(file) {
183
+ try {
184
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function cloneJson(value) {
191
+ if (value == null) return value;
192
+ try {
193
+ return JSON.parse(JSON.stringify(value));
194
+ } catch {
195
+ return null;
196
+ }
197
+ }
198
+
199
+ function mergeHookMaps(target, hooks) {
200
+ if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks)) return;
201
+ for (const [event, matchers] of Object.entries(hooks)) {
202
+ if (!Array.isArray(matchers)) continue;
203
+ const cloned = cloneJson(matchers);
204
+ if (!Array.isArray(cloned)) continue;
205
+ target[event] = [...(Array.isArray(target[event]) ? target[event] : []), ...cloned];
206
+ }
207
+ }
208
+
209
+ function claudeNativeHookSettings(options = {}) {
210
+ const sources = [
211
+ path.join(nativeHome(), '.claude', 'settings.json'),
212
+ options.cwd ? path.join(options.cwd, '.claude', 'settings.json') : null,
213
+ options.cwd ? path.join(options.cwd, '.claude', 'settings.local.json') : null,
214
+ ].filter(Boolean);
215
+ const hooks = {};
216
+ for (const source of sources) {
217
+ const data = readJsonFile(source);
218
+ mergeHookMaps(hooks, data?.hooks);
219
+ }
220
+ return Object.keys(hooks).length > 0 ? { hooks } : null;
221
+ }
222
+
116
223
  function syncNativeHarnessConfig(contract) {
117
224
  const runtimeHome = contract?.auth?.runtimeHome;
118
225
  if (!runtimeHome) return null;
@@ -123,10 +230,14 @@ function syncNativeHarnessConfig(contract) {
123
230
 
124
231
  module.exports = {
125
232
  __private: {
233
+ claudeNativeHookSettings,
126
234
  copyConfigTree,
235
+ copyDirBounded,
127
236
  copyFileIfPresent,
237
+ ensureHomeAlias,
128
238
  shouldCopyConfigPath,
129
239
  },
240
+ claudeNativeHookSettings,
130
241
  syncClaudeNativeConfig,
131
242
  syncCodexNativeConfig,
132
243
  syncNativeHarnessConfig,
@@ -274,8 +274,21 @@ function readJson(file, fallback = null) {
274
274
  }
275
275
  }
276
276
 
277
+ function isProcessRunning(pid) {
278
+ if (!Number.isInteger(pid) || pid <= 0) return false;
279
+ try {
280
+ process.kill(pid, 0);
281
+ return true;
282
+ } catch {
283
+ return false;
284
+ }
285
+ }
286
+
277
287
  function runtimeStateOwnedHere(previous) {
278
288
  if (!previous || typeof previous !== 'object' || Object.keys(previous).length === 0) return true;
289
+ const previousPids = [previous.pid, previous.gateway_pid, previous.supervisor_pid]
290
+ .filter((pid) => Number.isInteger(pid) && pid > 0);
291
+ if (previousPids.length > 0 && previousPids.every((pid) => !isProcessRunning(pid))) return true;
279
292
  return previous.pid === process.pid
280
293
  || previous.gateway_pid === process.pid
281
294
  || previous.pid === process.ppid