@magclaw/cli-core 0.1.33 → 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 +463 -72
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magclaw/cli-core",
3
- "version": "0.1.33",
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
  }
@@ -2153,32 +2199,152 @@ async function globalSkillRoots() {
2153
2199
  return roots;
2154
2200
  }
2155
2201
 
2156
- 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 = {}) {
2157
2336
  const targetSkillsRoot = path.join(codexHome, 'skills');
2158
2337
  await mkdir(targetSkillsRoot, { recursive: true });
2159
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();
2160
2343
  for (const sourceSkillsRoot of [...roots].reverse()) {
2161
- const entries = await readdir(sourceSkillsRoot, { withFileTypes: true }).catch(() => []);
2162
- for (const entry of entries) {
2163
- const source = path.join(sourceSkillsRoot, entry.name);
2164
- if (entry.name === '.system' && (entry.isDirectory() || entry.isSymbolicLink())) {
2165
- const targetSystemRoot = path.join(targetSkillsRoot, '.system');
2166
- await mkdir(targetSystemRoot, { recursive: true });
2167
- const systemEntries = await readdir(source, { withFileTypes: true }).catch(() => []);
2168
- for (const systemEntry of systemEntries) {
2169
- const systemSource = path.join(source, systemEntry.name);
2170
- const systemTarget = path.join(targetSystemRoot, systemEntry.name);
2171
- await linkPathEntry(systemSource, systemTarget).catch(() => {});
2172
- }
2173
- continue;
2174
- }
2175
- if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) continue;
2176
- await linkPathEntry(source, path.join(targetSkillsRoot, entry.name)).catch(() => {});
2177
- }
2344
+ for (const name of await linkSkillRootEntries(sourceSkillsRoot, targetSkillsRoot, agent, { includeSystem: true })) desiredNames.add(name);
2178
2345
  }
2179
-
2180
- const workspaceSkillsLink = path.join(workspace, 'skills');
2181
- await linkPathEntry(targetSkillsRoot, workspaceSkillsLink).catch(() => {});
2346
+ for (const name of await linkSkillRootEntries(workspaceSkills, targetSkillsRoot, agent)) desiredNames.add(name);
2347
+ await pruneGeneratedSkillLinks(targetSkillsRoot, desiredNames, agent);
2182
2348
  }
2183
2349
 
2184
2350
  function firstFrontmatterValue(content, keys) {
@@ -2231,11 +2397,12 @@ function shortenSkillPath(absPath, { agentRoot = '', codexHome = '' } = {}) {
2231
2397
  async function parseSkillFile(filePath, scope, context = {}) {
2232
2398
  const content = await readFile(filePath, 'utf8').catch(() => '');
2233
2399
  const resolvedFilePath = await realpath(filePath).catch(() => filePath);
2400
+ const logicalFilePath = path.resolve(filePath);
2234
2401
  const name = firstFrontmatterValue(content, ['name', 'title']) || skillNameFromPath(filePath);
2235
2402
  const description = firstFrontmatterValue(content, ['description', 'summary', 'short_description', 'short-description'])
2236
2403
  || firstMarkdownParagraph(content)
2237
2404
  || 'No description provided.';
2238
- const shortPath = shortenSkillPath(resolvedFilePath, context);
2405
+ const shortPath = shortenSkillPath(logicalFilePath, context);
2239
2406
  return {
2240
2407
  id: `${scope}:${shortPath}`,
2241
2408
  name,
@@ -2326,15 +2493,6 @@ async function findPluginSkillFiles(root, { maxEntries = 400 } = {}) {
2326
2493
  return found;
2327
2494
  }
2328
2495
 
2329
- async function resolvedRoots(paths) {
2330
- const roots = [];
2331
- for (const item of paths) {
2332
- if (!item || !existsSync(item)) continue;
2333
- roots.push(await realpath(item).catch(() => path.resolve(item)));
2334
- }
2335
- return roots;
2336
- }
2337
-
2338
2496
  function uniqueSkills(items) {
2339
2497
  const seen = new Set();
2340
2498
  return items.filter((item) => {
@@ -2366,6 +2524,35 @@ function daemonSkillTools() {
2366
2524
  ];
2367
2525
  }
2368
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
+
2369
2556
  const DAEMON_PROGRESSIVE_DISCLOSURE_SECTION = [
2370
2557
  '## 渐进式披露',
2371
2558
  '- 其他 Agent 默认只会先读取本文件;不要假设它们已经看到 `notes/` 或 `workspace/` 中的详细文件。',
@@ -2650,7 +2837,7 @@ class CodexAgentSession {
2650
2837
  codexHome: this.codexHome(),
2651
2838
  runtimeKind: 'codex',
2652
2839
  });
2653
- await syncGlobalSkillsIntoAgentHome(this.codexHome(), this.workspace());
2840
+ await syncGlobalSkillsIntoAgentHome(this.codexHome(), this.workspace(), this.agent);
2654
2841
  await writeFile(path.join(this.codexHome(), 'config.toml'), [
2655
2842
  'wire_api = "responses"',
2656
2843
  '',
@@ -2668,8 +2855,8 @@ class CodexAgentSession {
2668
2855
  '',
2669
2856
  'This workspace is isolated for a MagClaw cloud-connected agent.',
2670
2857
  'Do not assume files from the user localhost MagClaw instance are present here.',
2671
- 'Global Codex skills are linked into `./skills` for read-only reuse when available.',
2672
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.',
2673
2860
  '',
2674
2861
  ].join('\n'));
2675
2862
  }
@@ -2684,40 +2871,12 @@ class CodexAgentSession {
2684
2871
 
2685
2872
  async listSkills() {
2686
2873
  await this.prepare();
2687
- const context = {
2688
- agentRoot: this.agentDir(),
2874
+ return listDaemonAgentSkills({
2875
+ agent: this.agent,
2876
+ agentDir: this.agentDir(),
2877
+ workspace: this.workspace(),
2689
2878
  codexHome: this.codexHome(),
2690
- };
2691
- const roots = await globalSkillRoots();
2692
- const globalSkills = [];
2693
- for (const root of roots) globalSkills.push(...await scanSkillsDir(root, 'global', context));
2694
- const globalResolvedRoots = await resolvedRoots(roots);
2695
- const agentRoots = [
2696
- path.join(this.codexHome(), 'skills'),
2697
- path.join(this.agentDir(), '.codex', 'skills'),
2698
- path.join(this.agentDir(), '.agents', 'skills'),
2699
- ];
2700
- const agentSkills = [];
2701
- for (const root of agentRoots) agentSkills.push(...await scanSkillsDir(root, 'agent', context));
2702
- const workspaceSkills = agentSkills.filter((skill) => {
2703
- const resolved = path.resolve(skill.absolutePath);
2704
- return !globalResolvedRoots.some((root) => resolved === root || resolved.startsWith(`${root}${path.sep}`));
2705
2879
  });
2706
- const pluginFiles = await findPluginSkillFiles(path.join(SOURCE_CODEX_HOME, 'plugins', 'cache'));
2707
- const pluginSkills = [];
2708
- for (const file of pluginFiles) pluginSkills.push(await parseSkillFile(file, 'plugin', context));
2709
- return {
2710
- agent: {
2711
- id: this.agent.id,
2712
- name: this.agent.name || this.agent.id,
2713
- codexHome: this.codexHome(),
2714
- workspacePath: this.workspace(),
2715
- },
2716
- global: uniqueSkills(globalSkills),
2717
- workspace: uniqueSkills(workspaceSkills),
2718
- plugin: uniqueSkills(pluginSkills),
2719
- tools: daemonSkillTools(),
2720
- };
2721
2880
  }
2722
2881
 
2723
2882
  sendStatus(status, activity = null) {
@@ -3348,6 +3507,7 @@ class ClaudeAgentSession {
3348
3507
 
3349
3508
  async prepare() {
3350
3509
  await mkdir(this.workspace(), { recursive: true });
3510
+ await ensureWorkspaceSkillsDir(this.workspace(), path.join(this.agentDir(), 'codex-home'), this.agent);
3351
3511
  await ensureDaemonAgentWorkspaceRoot(this.agentDir(), this.agent);
3352
3512
  await prepareRuntimeHooks({
3353
3513
  agentDir: this.agentDir(),
@@ -3358,6 +3518,8 @@ class ClaudeAgentSession {
3358
3518
  '# MagClaw Remote Claude Agent Workspace',
3359
3519
  '',
3360
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.',
3361
3523
  '',
3362
3524
  ].join('\n'));
3363
3525
  }
@@ -3370,6 +3532,15 @@ class ClaudeAgentSession {
3370
3532
  return readDaemonAgentWorkspaceFile(this.agentDir(), this.agent, relPath);
3371
3533
  }
3372
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
+
3373
3544
  sendStatus(status, activity = null) {
3374
3545
  this.status = status;
3375
3546
  this.onStatusChange(this, status);
@@ -4725,8 +4896,12 @@ async function writeLauncher(profile, env = process.env) {
4725
4896
  || previousService.packageBin
4726
4897
  || packageBinForPackageName(packageName),
4727
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));
4728
4903
  const service = await writeServiceState(paths.profile, {
4729
- mode: process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : process.platform === 'win32' ? 'schtasks' : 'foreground',
4904
+ mode: serviceMode,
4730
4905
  background: true,
4731
4906
  launcher,
4732
4907
  packageSpec,
@@ -4809,6 +4984,72 @@ async function writeLauncher(profile, env = process.env) {
4809
4984
  return launcher;
4810
4985
  }
4811
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
+
4812
5053
  function launchAgentLabel(profile) {
4813
5054
  return `ai.magclaw.daemon.${safeProfileName(profile)}`;
4814
5055
  }
@@ -4824,6 +5065,61 @@ async function ensureExecutable(file) {
4824
5065
  await chmod(file, 0o755).catch(() => {});
4825
5066
  }
4826
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
+
4827
5123
  async function startMacBackground(profile, env = process.env) {
4828
5124
  const paths = profilePaths(profile, env);
4829
5125
  const label = launchAgentLabel(paths.profile);
@@ -4934,6 +5230,11 @@ async function startWindowsBackground(profile, env = process.env) {
4934
5230
  }
4935
5231
 
4936
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);
4937
5238
  if (process.platform === 'darwin') return startMacBackground(profile, env);
4938
5239
  if (process.platform === 'linux') return startLinuxBackground(profile, env);
4939
5240
  if (process.platform === 'win32') return startWindowsBackground(profile, env);
@@ -4961,6 +5262,25 @@ export function parseLaunchdPrintStatus(result = {}) {
4961
5262
 
4962
5263
  function backgroundServiceStatus(profile, env = process.env) {
4963
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
+ }
4964
5284
  if (process.platform === 'darwin') {
4965
5285
  const label = launchAgentLabel(paths.profile);
4966
5286
  const result = spawnSync('launchctl', ['print', `gui/${process.getuid()}/${label}`], { encoding: 'utf8' });
@@ -5081,9 +5401,77 @@ async function stopActiveDaemon(profile, env = process.env) {
5081
5401
  return { ok: stopped, running: !stopped, pid, signal };
5082
5402
  }
5083
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
+
5084
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
+ }
5085
5474
  if (process.platform === 'darwin') {
5086
- const paths = profilePaths(profile, env);
5087
5475
  const label = launchAgentLabel(paths.profile);
5088
5476
  const serviceTarget = `gui/${process.getuid()}/${label}`;
5089
5477
  const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
@@ -5570,7 +5958,10 @@ async function runUpgradeWorker(flags, env = process.env) {
5570
5958
 
5571
5959
  await emitProgress({ status: 'upgrading', phase: 'stage_service', progress: 45, message: 'Staging service launcher.' });
5572
5960
  await writeServiceState(profile, {
5573
- 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),
5574
5965
  background: true,
5575
5966
  packageSpec,
5576
5967
  packageName,