@magclaw/cli-core 0.1.32 → 0.1.34

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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +464 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magclaw/cli-core",
3
- "version": "0.1.32",
3
+ "version": "0.1.34",
4
4
  "description": "Shared local MagClaw CLI implementation used by daemon and computer packages.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/cli.js CHANGED
@@ -2,8 +2,8 @@ import http from 'node:http';
2
2
  import https from 'node:https';
3
3
  import crypto from 'node:crypto';
4
4
  import { spawn, spawnSync } from 'node:child_process';
5
- import { existsSync, readFileSync } from 'node:fs';
6
- import { chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, readlink, realpath, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
6
+ import { chmod, copyFile, cp, lstat, mkdir, open, readFile, readdir, readlink, realpath, rename, rm, stat, symlink, unlink, writeFile } from 'node:fs/promises';
7
7
  import os from 'node:os';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
@@ -489,6 +489,38 @@ export async function activeComputerLock(env = process.env) {
489
489
  return activeLockFile(paths.lockFile, { scope: 'computer' });
490
490
  }
491
491
 
492
+ function readJsonFileSync(file, fallback = {}) {
493
+ if (!existsSync(file)) return fallback;
494
+ try {
495
+ return JSON.parse(readFileSync(file, 'utf8'));
496
+ } catch {
497
+ return fallback;
498
+ }
499
+ }
500
+
501
+ function writeJsonFileSync(file, value) {
502
+ mkdirSync(path.dirname(file), { recursive: true });
503
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
504
+ }
505
+
506
+ function readServiceStateSync(profile = DEFAULT_PROFILE, env = process.env) {
507
+ const paths = profilePaths(profile, env);
508
+ return readJsonFileSync(paths.service, {});
509
+ }
510
+
511
+ function activeDaemonLockSync(profile = DEFAULT_PROFILE, env = process.env) {
512
+ const paths = profilePaths(profile, env);
513
+ const lock = readJsonFileSync(paths.lockFile, null);
514
+ if (lock?.pid && pidIsRunning(lock.pid)) {
515
+ return {
516
+ profile: paths.profile,
517
+ ...lock,
518
+ lockFile: paths.lockFile,
519
+ };
520
+ }
521
+ return null;
522
+ }
523
+
492
524
  async function writeLockFile(file, lock) {
493
525
  const handle = await open(file, 'wx');
494
526
  try {
@@ -730,7 +762,7 @@ function renderComputerHelp(subcommand = '') {
730
762
  ' --dry-run Preview upgrade actions',
731
763
  ' --channel <name> latest | alpha | pinned:<semver>',
732
764
  ' --target-version <semver> Explicit target version',
733
- ' --force Accepted for Slock parity; currently maps to the normal upgrade path',
765
+ ' --force Accepted for MagClaw compatibility; currently maps to the normal upgrade path',
734
766
  ],
735
767
  };
736
768
  if (command && usage[command]) return `${usage[command].join('\n')}\n`;
@@ -873,10 +905,24 @@ function backgroundServiceModeForPlatform(platform = process.platform) {
873
905
  return 'foreground';
874
906
  }
875
907
 
908
+ function normalizeBackgroundServiceMode(value = '') {
909
+ const mode = String(value || '').trim().toLowerCase();
910
+ if (['container', 'k8s', 'kubernetes', 'pod'].includes(mode)) return 'container';
911
+ if (['launchd', 'systemd', 'schtasks', 'foreground'].includes(mode)) return mode;
912
+ return '';
913
+ }
914
+
915
+ function requestedBackgroundServiceMode(env = process.env, platform = process.platform) {
916
+ return normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
917
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE)
918
+ || backgroundServiceModeForPlatform(platform);
919
+ }
920
+
876
921
  export function serviceStatePatchForDaemonRun(service = {}, env = process.env, platform = process.platform) {
877
922
  if (daemonRunLaunchedByBackgroundService(env)) {
923
+ const serviceMode = normalizeBackgroundServiceMode(service.mode);
878
924
  return {
879
- mode: service.mode || backgroundServiceModeForPlatform(platform),
925
+ mode: serviceMode && serviceMode !== 'foreground' ? serviceMode : requestedBackgroundServiceMode(env, platform),
880
926
  background: true,
881
927
  };
882
928
  }
@@ -1309,6 +1355,7 @@ async function writeCliShimFile(file, content, { force = false } = {}) {
1309
1355
  ...status,
1310
1356
  changed: true,
1311
1357
  written: true,
1358
+ upToDate: true,
1312
1359
  reason: force && status.exists ? 'forced' : status.reason,
1313
1360
  previousHash: status.currentHash,
1314
1361
  currentHash: status.expectedHash,
@@ -2152,32 +2199,152 @@ async function globalSkillRoots() {
2152
2199
  return roots;
2153
2200
  }
2154
2201
 
2155
- async function syncGlobalSkillsIntoAgentHome(codexHome, workspace) {
2202
+ function pathIsWithinResolvedRoots(resolvedPath, roots = []) {
2203
+ const cleanPath = path.resolve(resolvedPath);
2204
+ return roots.some((root) => cleanPath === root || cleanPath.startsWith(`${root}${path.sep}`));
2205
+ }
2206
+
2207
+ async function resolvedRoots(roots = []) {
2208
+ const resolved = [];
2209
+ for (const root of roots) {
2210
+ const logical = path.resolve(root);
2211
+ if (!resolved.includes(logical)) resolved.push(logical);
2212
+ const physical = await realpath(root).catch(() => logical);
2213
+ if (!resolved.includes(physical)) resolved.push(physical);
2214
+ }
2215
+ return resolved;
2216
+ }
2217
+
2218
+ async function ensureWorkspaceSkillsDir(workspace, codexHome = '', agent = {}) {
2219
+ const workspaceSkills = path.join(workspace, 'skills');
2220
+ const legacyGeneratedSkills = codexHome ? path.join(codexHome, 'skills') : '';
2221
+ await mkdir(path.dirname(workspaceSkills), { recursive: true });
2222
+ try {
2223
+ const existing = await lstat(workspaceSkills);
2224
+ if (existing.isSymbolicLink()) {
2225
+ const current = await readlink(workspaceSkills);
2226
+ const resolved = path.resolve(path.dirname(workspaceSkills), current);
2227
+ if (legacyGeneratedSkills && resolved === path.resolve(legacyGeneratedSkills)) {
2228
+ await unlink(workspaceSkills);
2229
+ await mkdir(workspaceSkills, { recursive: true });
2230
+ logInfo('skills', `Repaired workspace skills directory for agent ${agent.id || 'unknown'}.`);
2231
+ } else {
2232
+ logWarning('skills', `Workspace skills path for agent ${agent.id || 'unknown'} is a custom symlink; leaving it untouched.`);
2233
+ }
2234
+ } else if (!existing.isDirectory()) {
2235
+ logWarning('skills', `Workspace skills path for agent ${agent.id || 'unknown'} is not a directory; leaving it untouched.`);
2236
+ return null;
2237
+ }
2238
+ } catch (error) {
2239
+ if (error.code !== 'ENOENT') throw error;
2240
+ await mkdir(workspaceSkills, { recursive: true });
2241
+ }
2242
+ return workspaceSkills;
2243
+ }
2244
+
2245
+ async function migrateLegacyAgentSkills(codexHome, workspaceSkills, globalResolvedRoots, agent = {}) {
2246
+ const codexSkillsRoot = path.join(codexHome, 'skills');
2247
+ if (!workspaceSkills || !existsSync(codexSkillsRoot)) return;
2248
+ const entries = await readdir(codexSkillsRoot, { withFileTypes: true }).catch(() => []);
2249
+ for (const entry of entries) {
2250
+ if (entry.name.startsWith('.') || entry.name === '.system') continue;
2251
+ const source = path.join(codexSkillsRoot, entry.name);
2252
+ const target = path.join(workspaceSkills, entry.name);
2253
+ if (existsSync(target)) continue;
2254
+ const sourceInfo = await lstat(source).catch(() => null);
2255
+ if (!sourceInfo) continue;
2256
+ try {
2257
+ if (sourceInfo.isSymbolicLink()) {
2258
+ const current = await readlink(source);
2259
+ const resolved = path.resolve(path.dirname(source), current);
2260
+ if (pathIsWithinResolvedRoots(resolved, globalResolvedRoots)) continue;
2261
+ const realTarget = await realpath(resolved).catch(() => resolved);
2262
+ if (pathIsWithinResolvedRoots(realTarget, globalResolvedRoots)) continue;
2263
+ const targetInfo = await stat(realTarget).catch(() => null);
2264
+ if (!targetInfo) continue;
2265
+ await symlink(realTarget, target, targetInfo.isDirectory() ? 'dir' : 'file');
2266
+ await unlink(source);
2267
+ } else {
2268
+ await rename(source, target);
2269
+ }
2270
+ logInfo('skills', `Migrated legacy local skill ${entry.name} for agent ${agent.id || 'unknown'}.`);
2271
+ } catch (error) {
2272
+ logWarning('skills', `Could not migrate legacy local skill ${entry.name} for agent ${agent.id || 'unknown'}: ${error.message}`);
2273
+ }
2274
+ }
2275
+ }
2276
+
2277
+ async function linkRuntimeSkillEntry(source, target, agent = {}) {
2278
+ const linked = await linkPathEntry(source, target);
2279
+ if (linked) return true;
2280
+ const existing = await lstat(target).catch(() => null);
2281
+ if (existing && !existing.isSymbolicLink() && path.resolve(source) !== path.resolve(target)) {
2282
+ logWarning('skills', `Could not link skill ${path.basename(target)} for agent ${agent.id || 'unknown'} because the runtime path is not a symlink.`);
2283
+ }
2284
+ return false;
2285
+ }
2286
+
2287
+ async function linkSkillRootEntries(sourceRoot, targetRoot, agent = {}, { includeSystem = false } = {}) {
2288
+ const desired = new Set();
2289
+ if (!sourceRoot || !existsSync(sourceRoot)) return desired;
2290
+ const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(() => []);
2291
+ for (const entry of entries) {
2292
+ if (entry.name.startsWith('.') && !(includeSystem && entry.name === '.system')) continue;
2293
+ const source = path.join(sourceRoot, entry.name);
2294
+ if (includeSystem && entry.name === '.system' && (entry.isDirectory() || entry.isSymbolicLink())) {
2295
+ desired.add(entry.name);
2296
+ const targetSystemRoot = path.join(targetRoot, '.system');
2297
+ await mkdir(targetSystemRoot, { recursive: true });
2298
+ const systemEntries = await readdir(source, { withFileTypes: true }).catch(() => []);
2299
+ for (const systemEntry of systemEntries) {
2300
+ if (!systemEntry.isDirectory() && !systemEntry.isSymbolicLink() && !systemEntry.isFile()) continue;
2301
+ const systemSource = path.join(source, systemEntry.name);
2302
+ const systemTarget = path.join(targetSystemRoot, systemEntry.name);
2303
+ await linkRuntimeSkillEntry(systemSource, systemTarget, agent).catch((error) => {
2304
+ logWarning('skills', `Could not link system skill ${systemEntry.name} for agent ${agent.id || 'unknown'}: ${error.message}`);
2305
+ });
2306
+ }
2307
+ continue;
2308
+ }
2309
+ if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) continue;
2310
+ desired.add(entry.name);
2311
+ const target = path.join(targetRoot, entry.name);
2312
+ await linkRuntimeSkillEntry(source, target, agent).catch((error) => {
2313
+ logWarning('skills', `Could not link skill ${entry.name} for agent ${agent.id || 'unknown'}: ${error.message}`);
2314
+ });
2315
+ }
2316
+ return desired;
2317
+ }
2318
+
2319
+ async function pruneGeneratedSkillLinks(targetSkillsRoot, desiredNames, agent = {}) {
2320
+ const entries = await readdir(targetSkillsRoot, { withFileTypes: true }).catch(() => []);
2321
+ for (const entry of entries) {
2322
+ if (entry.name.startsWith('.') && entry.name !== '.system') continue;
2323
+ if (desiredNames.has(entry.name)) continue;
2324
+ const target = path.join(targetSkillsRoot, entry.name);
2325
+ const existing = await lstat(target).catch(() => null);
2326
+ if (!existing) continue;
2327
+ if (existing.isSymbolicLink()) {
2328
+ await unlink(target);
2329
+ } else if (entry.name !== '.system') {
2330
+ logWarning('skills', `Stale runtime skill ${entry.name} for agent ${agent.id || 'unknown'} was not removed because it is not a symlink.`);
2331
+ }
2332
+ }
2333
+ }
2334
+
2335
+ async function syncGlobalSkillsIntoAgentHome(codexHome, workspace, agent = {}) {
2156
2336
  const targetSkillsRoot = path.join(codexHome, 'skills');
2157
2337
  await mkdir(targetSkillsRoot, { recursive: true });
2158
2338
  const roots = await globalSkillRoots();
2339
+ const globalResolvedRoots = await resolvedRoots(roots);
2340
+ const workspaceSkills = await ensureWorkspaceSkillsDir(workspace, codexHome, agent);
2341
+ await migrateLegacyAgentSkills(codexHome, workspaceSkills, globalResolvedRoots, agent);
2342
+ const desiredNames = new Set();
2159
2343
  for (const sourceSkillsRoot of [...roots].reverse()) {
2160
- const entries = await readdir(sourceSkillsRoot, { withFileTypes: true }).catch(() => []);
2161
- for (const entry of entries) {
2162
- const source = path.join(sourceSkillsRoot, entry.name);
2163
- if (entry.name === '.system' && (entry.isDirectory() || entry.isSymbolicLink())) {
2164
- const targetSystemRoot = path.join(targetSkillsRoot, '.system');
2165
- await mkdir(targetSystemRoot, { recursive: true });
2166
- const systemEntries = await readdir(source, { withFileTypes: true }).catch(() => []);
2167
- for (const systemEntry of systemEntries) {
2168
- const systemSource = path.join(source, systemEntry.name);
2169
- const systemTarget = path.join(targetSystemRoot, systemEntry.name);
2170
- await linkPathEntry(systemSource, systemTarget).catch(() => {});
2171
- }
2172
- continue;
2173
- }
2174
- if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) continue;
2175
- await linkPathEntry(source, path.join(targetSkillsRoot, entry.name)).catch(() => {});
2176
- }
2344
+ for (const name of await linkSkillRootEntries(sourceSkillsRoot, targetSkillsRoot, agent, { includeSystem: true })) desiredNames.add(name);
2177
2345
  }
2178
-
2179
- const workspaceSkillsLink = path.join(workspace, 'skills');
2180
- await linkPathEntry(targetSkillsRoot, workspaceSkillsLink).catch(() => {});
2346
+ for (const name of await linkSkillRootEntries(workspaceSkills, targetSkillsRoot, agent)) desiredNames.add(name);
2347
+ await pruneGeneratedSkillLinks(targetSkillsRoot, desiredNames, agent);
2181
2348
  }
2182
2349
 
2183
2350
  function firstFrontmatterValue(content, keys) {
@@ -2230,11 +2397,12 @@ function shortenSkillPath(absPath, { agentRoot = '', codexHome = '' } = {}) {
2230
2397
  async function parseSkillFile(filePath, scope, context = {}) {
2231
2398
  const content = await readFile(filePath, 'utf8').catch(() => '');
2232
2399
  const resolvedFilePath = await realpath(filePath).catch(() => filePath);
2400
+ const logicalFilePath = path.resolve(filePath);
2233
2401
  const name = firstFrontmatterValue(content, ['name', 'title']) || skillNameFromPath(filePath);
2234
2402
  const description = firstFrontmatterValue(content, ['description', 'summary', 'short_description', 'short-description'])
2235
2403
  || firstMarkdownParagraph(content)
2236
2404
  || 'No description provided.';
2237
- const shortPath = shortenSkillPath(resolvedFilePath, context);
2405
+ const shortPath = shortenSkillPath(logicalFilePath, context);
2238
2406
  return {
2239
2407
  id: `${scope}:${shortPath}`,
2240
2408
  name,
@@ -2325,15 +2493,6 @@ async function findPluginSkillFiles(root, { maxEntries = 400 } = {}) {
2325
2493
  return found;
2326
2494
  }
2327
2495
 
2328
- async function resolvedRoots(paths) {
2329
- const roots = [];
2330
- for (const item of paths) {
2331
- if (!item || !existsSync(item)) continue;
2332
- roots.push(await realpath(item).catch(() => path.resolve(item)));
2333
- }
2334
- return roots;
2335
- }
2336
-
2337
2496
  function uniqueSkills(items) {
2338
2497
  const seen = new Set();
2339
2498
  return items.filter((item) => {
@@ -2365,6 +2524,35 @@ function daemonSkillTools() {
2365
2524
  ];
2366
2525
  }
2367
2526
 
2527
+ async function listDaemonAgentSkills({ agent, agentDir, workspace, codexHome = '' }) {
2528
+ const context = { agentRoot: agentDir, codexHome };
2529
+ const roots = await globalSkillRoots();
2530
+ const globalSkills = [];
2531
+ for (const root of roots) globalSkills.push(...await scanSkillsDir(root, 'global', context));
2532
+ const agentRoots = [
2533
+ path.join(workspace, 'skills'),
2534
+ path.join(agentDir, '.codex', 'skills'),
2535
+ path.join(agentDir, '.agents', 'skills'),
2536
+ ];
2537
+ const agentSkills = [];
2538
+ for (const root of agentRoots) agentSkills.push(...await scanSkillsDir(root, 'agent', context));
2539
+ const pluginFiles = await findPluginSkillFiles(path.join(SOURCE_CODEX_HOME, 'plugins', 'cache'));
2540
+ const pluginSkills = [];
2541
+ for (const file of pluginFiles) pluginSkills.push(await parseSkillFile(file, 'plugin', context));
2542
+ return {
2543
+ agent: {
2544
+ id: agent.id,
2545
+ name: agent.name || agent.id,
2546
+ codexHome: codexHome || undefined,
2547
+ workspacePath: workspace,
2548
+ },
2549
+ global: uniqueSkills(globalSkills),
2550
+ workspace: uniqueSkills(agentSkills),
2551
+ plugin: uniqueSkills(pluginSkills),
2552
+ tools: daemonSkillTools(),
2553
+ };
2554
+ }
2555
+
2368
2556
  const DAEMON_PROGRESSIVE_DISCLOSURE_SECTION = [
2369
2557
  '## 渐进式披露',
2370
2558
  '- 其他 Agent 默认只会先读取本文件;不要假设它们已经看到 `notes/` 或 `workspace/` 中的详细文件。',
@@ -2649,7 +2837,7 @@ class CodexAgentSession {
2649
2837
  codexHome: this.codexHome(),
2650
2838
  runtimeKind: 'codex',
2651
2839
  });
2652
- await syncGlobalSkillsIntoAgentHome(this.codexHome(), this.workspace());
2840
+ await syncGlobalSkillsIntoAgentHome(this.codexHome(), this.workspace(), this.agent);
2653
2841
  await writeFile(path.join(this.codexHome(), 'config.toml'), [
2654
2842
  'wire_api = "responses"',
2655
2843
  '',
@@ -2667,8 +2855,8 @@ class CodexAgentSession {
2667
2855
  '',
2668
2856
  'This workspace is isolated for a MagClaw cloud-connected agent.',
2669
2857
  'Do not assume files from the user localhost MagClaw instance are present here.',
2670
- 'Global Codex skills are linked into `./skills` for read-only reuse when available.',
2671
2858
  'Agent-specific skills can be installed under `./skills/<skill-name>/SKILL.md`; this path belongs to this agent only.',
2859
+ 'Runtime-generated adapter directories are not skill install targets.',
2672
2860
  '',
2673
2861
  ].join('\n'));
2674
2862
  }
@@ -2683,40 +2871,12 @@ class CodexAgentSession {
2683
2871
 
2684
2872
  async listSkills() {
2685
2873
  await this.prepare();
2686
- const context = {
2687
- agentRoot: this.agentDir(),
2874
+ return listDaemonAgentSkills({
2875
+ agent: this.agent,
2876
+ agentDir: this.agentDir(),
2877
+ workspace: this.workspace(),
2688
2878
  codexHome: this.codexHome(),
2689
- };
2690
- const roots = await globalSkillRoots();
2691
- const globalSkills = [];
2692
- for (const root of roots) globalSkills.push(...await scanSkillsDir(root, 'global', context));
2693
- const globalResolvedRoots = await resolvedRoots(roots);
2694
- const agentRoots = [
2695
- path.join(this.codexHome(), 'skills'),
2696
- path.join(this.agentDir(), '.codex', 'skills'),
2697
- path.join(this.agentDir(), '.agents', 'skills'),
2698
- ];
2699
- const agentSkills = [];
2700
- for (const root of agentRoots) agentSkills.push(...await scanSkillsDir(root, 'agent', context));
2701
- const workspaceSkills = agentSkills.filter((skill) => {
2702
- const resolved = path.resolve(skill.absolutePath);
2703
- return !globalResolvedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`));
2704
2879
  });
2705
- const pluginFiles = await findPluginSkillFiles(path.join(SOURCE_CODEX_HOME, 'plugins', 'cache'));
2706
- const pluginSkills = [];
2707
- for (const file of pluginFiles) pluginSkills.push(await parseSkillFile(file, 'plugin', context));
2708
- return {
2709
- agent: {
2710
- id: this.agent.id,
2711
- name: this.agent.name || this.agent.id,
2712
- codexHome: this.codexHome(),
2713
- workspacePath: this.workspace(),
2714
- },
2715
- global: uniqueSkills(globalSkills),
2716
- workspace: uniqueSkills(workspaceSkills),
2717
- plugin: uniqueSkills(pluginSkills),
2718
- tools: daemonSkillTools(),
2719
- };
2720
2880
  }
2721
2881
 
2722
2882
  sendStatus(status, activity = null) {
@@ -3347,6 +3507,7 @@ class ClaudeAgentSession {
3347
3507
 
3348
3508
  async prepare() {
3349
3509
  await mkdir(this.workspace(), { recursive: true });
3510
+ await ensureWorkspaceSkillsDir(this.workspace(), path.join(this.agentDir(), 'codex-home'), this.agent);
3350
3511
  await ensureDaemonAgentWorkspaceRoot(this.agentDir(), this.agent);
3351
3512
  await prepareRuntimeHooks({
3352
3513
  agentDir: this.agentDir(),
@@ -3357,6 +3518,8 @@ class ClaudeAgentSession {
3357
3518
  '# MagClaw Remote Claude Agent Workspace',
3358
3519
  '',
3359
3520
  'This workspace is isolated for a MagClaw cloud-connected Claude Code agent.',
3521
+ 'Agent-specific skills can be installed under `./skills/<skill-name>/SKILL.md`; this path belongs to this agent only.',
3522
+ 'Runtime-generated adapter directories are not skill install targets.',
3360
3523
  '',
3361
3524
  ].join('\n'));
3362
3525
  }
@@ -3369,6 +3532,15 @@ class ClaudeAgentSession {
3369
3532
  return readDaemonAgentWorkspaceFile(this.agentDir(), this.agent, relPath);
3370
3533
  }
3371
3534
 
3535
+ async listSkills() {
3536
+ await this.prepare();
3537
+ return listDaemonAgentSkills({
3538
+ agent: this.agent,
3539
+ agentDir: this.agentDir(),
3540
+ workspace: this.workspace(),
3541
+ });
3542
+ }
3543
+
3372
3544
  sendStatus(status, activity = null) {
3373
3545
  this.status = status;
3374
3546
  this.onStatusChange(this, status);
@@ -4724,8 +4896,12 @@ async function writeLauncher(profile, env = process.env) {
4724
4896
  || previousService.packageBin
4725
4897
  || packageBinForPackageName(packageName),
4726
4898
  ).trim() || packageBinForPackageName(packageName);
4899
+ const envServiceMode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
4900
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE);
4901
+ const previousMode = normalizeBackgroundServiceMode(previousService.mode);
4902
+ const serviceMode = envServiceMode || (previousMode && previousMode !== 'foreground' ? previousMode : backgroundServiceModeForPlatform(process.platform));
4727
4903
  const service = await writeServiceState(paths.profile, {
4728
- mode: process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : process.platform === 'win32' ? 'schtasks' : 'foreground',
4904
+ mode: serviceMode,
4729
4905
  background: true,
4730
4906
  launcher,
4731
4907
  packageSpec,
@@ -4808,6 +4984,72 @@ async function writeLauncher(profile, env = process.env) {
4808
4984
  return launcher;
4809
4985
  }
4810
4986
 
4987
+ async function writeContainerSupervisor(profile, launcher, env = process.env) {
4988
+ const paths = profilePaths(profile, env);
4989
+ await mkdir(paths.runDir, { recursive: true });
4990
+ await mkdir(paths.logDir, { recursive: true });
4991
+ const supervisor = path.join(paths.runDir, 'container-supervisor.js');
4992
+ const restartSec = Math.max(1, Math.min(60, Number(env.MAGCLAW_DAEMON_CONTAINER_RESTART_SEC || 3) || 3));
4993
+ const code = [
4994
+ '#!/usr/bin/env node',
4995
+ "const { spawn } = require('node:child_process');",
4996
+ "const fs = require('node:fs');",
4997
+ "const path = require('node:path');",
4998
+ `const launcher = ${JSON.stringify(launcher)};`,
4999
+ `const serviceFile = ${JSON.stringify(paths.service)};`,
5000
+ `const logDir = ${JSON.stringify(paths.logDir)};`,
5001
+ `const restartMs = ${JSON.stringify(restartSec * 1000)};`,
5002
+ 'let child = null;',
5003
+ 'let stopping = false;',
5004
+ 'function readService() {',
5005
+ " try { return JSON.parse(fs.readFileSync(serviceFile, 'utf8')); } catch { return {}; }",
5006
+ '}',
5007
+ 'function shouldStop() {',
5008
+ ' const service = readService();',
5009
+ ' return Boolean(service.remoteClosed || service.containerSupervisorDisabled);',
5010
+ '}',
5011
+ 'function openLog(name) {',
5012
+ ' fs.mkdirSync(logDir, { recursive: true });',
5013
+ " return fs.openSync(path.join(logDir, name), 'a');",
5014
+ '}',
5015
+ 'function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }',
5016
+ 'function stop(signal) {',
5017
+ ' stopping = true;',
5018
+ " if (child && child.exitCode === null && child.signalCode === null) child.kill(signal || 'SIGTERM');",
5019
+ ' setTimeout(() => process.exit(0), 5000).unref?.();',
5020
+ '}',
5021
+ "process.once('SIGINT', () => stop('SIGINT'));",
5022
+ "process.once('SIGTERM', () => stop('SIGTERM'));",
5023
+ '(async () => {',
5024
+ ' while (!stopping) {',
5025
+ ' if (shouldStop()) break;',
5026
+ " const out = openLog('daemon.log');",
5027
+ " const err = openLog('daemon.err.log');",
5028
+ ' child = spawn(process.execPath, [launcher], {',
5029
+ " stdio: ['ignore', out, err],",
5030
+ ' env: {',
5031
+ ' ...process.env,',
5032
+ " MAGCLAW_DAEMON_SERVICE_MODE: 'container',",
5033
+ ' },',
5034
+ ' });',
5035
+ " await new Promise((resolve) => child.once('exit', resolve));",
5036
+ ' fs.closeSync(out);',
5037
+ ' fs.closeSync(err);',
5038
+ ' child = null;',
5039
+ ' if (stopping || shouldStop()) break;',
5040
+ ' await sleep(restartMs);',
5041
+ ' }',
5042
+ '})().catch((error) => {',
5043
+ " console.error(`[magclaw-container-supervisor] ${error && error.stack ? error.stack : error}`);",
5044
+ ' process.exit(1);',
5045
+ '});',
5046
+ '',
5047
+ ].join('\n');
5048
+ await writeFile(supervisor, code);
5049
+ await chmod(supervisor, 0o755).catch(() => {});
5050
+ return supervisor;
5051
+ }
5052
+
4811
5053
  function launchAgentLabel(profile) {
4812
5054
  return `ai.magclaw.daemon.${safeProfileName(profile)}`;
4813
5055
  }
@@ -4823,6 +5065,61 @@ async function ensureExecutable(file) {
4823
5065
  await chmod(file, 0o755).catch(() => {});
4824
5066
  }
4825
5067
 
5068
+ async function startContainerBackground(profile, env = process.env) {
5069
+ const paths = profilePaths(profile, env);
5070
+ await mkdir(paths.logDir, { recursive: true });
5071
+ const launcher = await writeLauncher(paths.profile, { ...env, MAGCLAW_DAEMON_SERVICE_MODE: 'container' });
5072
+ await ensureExecutable(launcher);
5073
+ const supervisor = await writeContainerSupervisor(paths.profile, launcher, env);
5074
+ await ensureExecutable(supervisor);
5075
+ await writeServiceState(paths.profile, {
5076
+ mode: 'container',
5077
+ background: true,
5078
+ launcher,
5079
+ containerSupervisor: supervisor,
5080
+ containerSupervisorDisabled: false,
5081
+ }, env);
5082
+ const current = backgroundServiceStatus(paths.profile, env);
5083
+ if (current.active) {
5084
+ return {
5085
+ ok: true,
5086
+ mode: 'container',
5087
+ active: true,
5088
+ alreadyRunning: true,
5089
+ pid: current.pid || null,
5090
+ supervisorPid: current.supervisorPid || null,
5091
+ file: supervisor,
5092
+ launcher,
5093
+ status: current.status || 'running',
5094
+ };
5095
+ }
5096
+ const child = spawn(process.execPath, [supervisor], {
5097
+ cwd: process.cwd(),
5098
+ detached: true,
5099
+ stdio: 'ignore',
5100
+ env: {
5101
+ ...env,
5102
+ MAGCLAW_DAEMON_SERVICE_MODE: 'container',
5103
+ },
5104
+ });
5105
+ child.unref();
5106
+ await writeServiceState(paths.profile, {
5107
+ mode: 'container',
5108
+ background: true,
5109
+ launcher,
5110
+ containerSupervisor: supervisor,
5111
+ containerSupervisorPid: child.pid || null,
5112
+ }, env);
5113
+ return {
5114
+ ok: true,
5115
+ mode: 'container',
5116
+ active: true,
5117
+ supervisorPid: child.pid || null,
5118
+ file: supervisor,
5119
+ launcher,
5120
+ };
5121
+ }
5122
+
4826
5123
  async function startMacBackground(profile, env = process.env) {
4827
5124
  const paths = profilePaths(profile, env);
4828
5125
  const label = launchAgentLabel(paths.profile);
@@ -4933,6 +5230,11 @@ async function startWindowsBackground(profile, env = process.env) {
4933
5230
  }
4934
5231
 
4935
5232
  async function startBackground(profile, env = process.env) {
5233
+ const service = await readServiceState(profile, env);
5234
+ const mode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5235
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE)
5236
+ || normalizeBackgroundServiceMode(service.mode);
5237
+ if (mode === 'container') return startContainerBackground(profile, env);
4936
5238
  if (process.platform === 'darwin') return startMacBackground(profile, env);
4937
5239
  if (process.platform === 'linux') return startLinuxBackground(profile, env);
4938
5240
  if (process.platform === 'win32') return startWindowsBackground(profile, env);
@@ -4960,6 +5262,25 @@ export function parseLaunchdPrintStatus(result = {}) {
4960
5262
 
4961
5263
  function backgroundServiceStatus(profile, env = process.env) {
4962
5264
  const paths = profilePaths(profile, env);
5265
+ const serviceState = readServiceStateSync(paths.profile, env);
5266
+ const requestedMode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5267
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE);
5268
+ if (requestedMode === 'container' || normalizeBackgroundServiceMode(serviceState.mode) === 'container') {
5269
+ const lock = activeDaemonLockSync(paths.profile, env);
5270
+ const supervisorPid = Number(serviceState.containerSupervisorPid || 0);
5271
+ const supervisorRunning = Number.isInteger(supervisorPid) && supervisorPid > 0 && pidIsRunning(supervisorPid);
5272
+ const daemonRunning = Boolean(lock?.pid);
5273
+ return {
5274
+ mode: 'container',
5275
+ active: daemonRunning || supervisorRunning,
5276
+ pid: lock?.pid || null,
5277
+ supervisorPid: supervisorRunning ? supervisorPid : null,
5278
+ file: serviceState.containerSupervisor || '',
5279
+ launcher: serviceState.launcher || '',
5280
+ status: daemonRunning ? 'running' : supervisorRunning ? 'supervising' : 'inactive',
5281
+ error: '',
5282
+ };
5283
+ }
4963
5284
  if (process.platform === 'darwin') {
4964
5285
  const label = launchAgentLabel(paths.profile);
4965
5286
  const result = spawnSync('launchctl', ['print', `gui/${process.getuid()}/${label}`], { encoding: 'utf8' });
@@ -5080,9 +5401,77 @@ async function stopActiveDaemon(profile, env = process.env) {
5080
5401
  return { ok: stopped, running: !stopped, pid, signal };
5081
5402
  }
5082
5403
 
5404
+ function waitForPidExitSync(pid, timeoutMs = 2000) {
5405
+ const deadline = Date.now() + timeoutMs;
5406
+ while (Date.now() < deadline) {
5407
+ if (!pidIsRunning(pid)) return true;
5408
+ sleepSync(100);
5409
+ }
5410
+ return !pidIsRunning(pid);
5411
+ }
5412
+
5413
+ function stopPidSync(pid, { allowCurrent = false, timeoutMs = 2000 } = {}) {
5414
+ const value = Number(pid);
5415
+ if (!Number.isInteger(value) || value <= 0) return { ok: true, running: false };
5416
+ if (!allowCurrent && value === process.pid) {
5417
+ return { ok: true, running: true, pid: value, skippedCurrent: true };
5418
+ }
5419
+ try {
5420
+ process.kill(value, 'SIGTERM');
5421
+ } catch (error) {
5422
+ if (error?.code === 'ESRCH') return { ok: true, running: false, pid: value, stale: true };
5423
+ return { ok: false, running: true, pid: value, error: error.message };
5424
+ }
5425
+ if (waitForPidExitSync(value, timeoutMs)) return { ok: true, running: false, pid: value, signal: 'SIGTERM' };
5426
+ try {
5427
+ process.kill(value, 'SIGKILL');
5428
+ } catch (error) {
5429
+ if (error?.code === 'ESRCH') return { ok: true, running: false, pid: value, signal: 'SIGKILL' };
5430
+ return { ok: false, running: true, pid: value, signal: 'SIGKILL', error: error.message };
5431
+ }
5432
+ const stopped = waitForPidExitSync(value, 1000);
5433
+ return { ok: stopped, running: !stopped, pid: value, signal: 'SIGKILL' };
5434
+ }
5435
+
5436
+ function stopContainerBackground(profile, env = process.env, options = {}) {
5437
+ const paths = profilePaths(profile, env);
5438
+ const state = readServiceStateSync(paths.profile, env);
5439
+ const lock = activeDaemonLockSync(paths.profile, env);
5440
+ const supervisor = stopPidSync(state.containerSupervisorPid);
5441
+ const daemon = stopPidSync(lock?.pid);
5442
+ if (options.disable) {
5443
+ writeJsonFileSync(paths.service, {
5444
+ ...state,
5445
+ version: 1,
5446
+ profile: paths.profile,
5447
+ mode: 'container',
5448
+ background: true,
5449
+ containerSupervisorDisabled: true,
5450
+ updatedAt: now(),
5451
+ });
5452
+ }
5453
+ return {
5454
+ ok: Boolean(supervisor.ok && daemon.ok),
5455
+ mode: 'container',
5456
+ supervisorPid: state.containerSupervisorPid || null,
5457
+ pid: lock?.pid || null,
5458
+ supervisor,
5459
+ process: daemon,
5460
+ file: state.containerSupervisor || '',
5461
+ launcher: state.launcher || '',
5462
+ disabled: Boolean(options.disable),
5463
+ };
5464
+ }
5465
+
5083
5466
  function stopBackground(profile, env = process.env, options = {}) {
5467
+ const paths = profilePaths(profile, env);
5468
+ const state = readServiceStateSync(paths.profile, env);
5469
+ const requestedMode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5470
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE);
5471
+ if (requestedMode === 'container' || normalizeBackgroundServiceMode(state.mode) === 'container') {
5472
+ return stopContainerBackground(paths.profile, env, options);
5473
+ }
5084
5474
  if (process.platform === 'darwin') {
5085
- const paths = profilePaths(profile, env);
5086
5475
  const label = launchAgentLabel(paths.profile);
5087
5476
  const serviceTarget = `gui/${process.getuid()}/${label}`;
5088
5477
  const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
@@ -5569,7 +5958,10 @@ async function runUpgradeWorker(flags, env = process.env) {
5569
5958
 
5570
5959
  await emitProgress({ status: 'upgrading', phase: 'stage_service', progress: 45, message: 'Staging service launcher.' });
5571
5960
  await writeServiceState(profile, {
5572
- mode: serviceBefore.mode || (process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : process.platform === 'win32' ? 'schtasks' : 'foreground'),
5961
+ mode: normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5962
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE)
5963
+ || normalizeBackgroundServiceMode(serviceBefore.mode)
5964
+ || backgroundServiceModeForPlatform(process.platform),
5573
5965
  background: true,
5574
5966
  packageSpec,
5575
5967
  packageName,