@magclaw/cli-core 0.1.33 → 0.1.35

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.
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';
@@ -61,6 +61,79 @@ function now() {
61
61
  return new Date().toISOString();
62
62
  }
63
63
 
64
+ function claudeStreamEvents(raw) {
65
+ if (!raw || typeof raw !== 'object') return [];
66
+ const event = raw;
67
+ const output = [];
68
+ if (event.type === 'system' && event.subtype === 'init') {
69
+ output.push({
70
+ type: 'system',
71
+ sessionId: event.session_id || event.sessionId || '',
72
+ model: event.model || '',
73
+ cwd: event.cwd || '',
74
+ });
75
+ return output;
76
+ }
77
+ const content = Array.isArray(event.message?.content) ? event.message.content : [];
78
+ if (event.type === 'assistant') {
79
+ for (const block of content) {
80
+ if (block?.type === 'text' && typeof block.text === 'string' && block.text) {
81
+ output.push({ type: 'text', delta: block.text });
82
+ } else if (block?.type === 'thinking' && typeof block.thinking === 'string' && block.thinking) {
83
+ output.push({ type: 'thinking', delta: block.thinking });
84
+ } else if (block?.type === 'tool_use' && block.id && block.name) {
85
+ output.push({ type: 'tool_use', id: block.id, name: block.name, input: block.input });
86
+ }
87
+ }
88
+ return output;
89
+ }
90
+ if (event.type === 'user') {
91
+ for (const block of content) {
92
+ if (block?.type === 'tool_result' && block.tool_use_id) {
93
+ output.push({
94
+ type: 'tool_result',
95
+ id: block.tool_use_id,
96
+ output: typeof block.content === 'string' ? block.content : JSON.stringify(block.content),
97
+ isError: block.is_error === true,
98
+ });
99
+ }
100
+ }
101
+ return output;
102
+ }
103
+ if (event.type === 'result') {
104
+ if (event.usage) {
105
+ output.push({
106
+ type: 'usage',
107
+ inputTokens: event.usage.input_tokens,
108
+ outputTokens: event.usage.output_tokens,
109
+ costUsd: event.total_cost_usd,
110
+ });
111
+ }
112
+ output.push({ type: 'done', sessionId: event.session_id || event.sessionId || '' });
113
+ }
114
+ return output;
115
+ }
116
+
117
+ function claudeToolActivityDetail(event) {
118
+ if (!event || typeof event !== 'object') return 'Claude Code activity';
119
+ if (event.type === 'tool_use') return `Claude Code using ${event.name || 'tool'}`;
120
+ if (event.type === 'tool_result') return event.isError ? 'Claude Code tool returned an error' : 'Claude Code tool completed';
121
+ if (event.type === 'thinking') return 'Claude Code thinking';
122
+ if (event.type === 'usage') return `Claude Code usage input=${event.inputTokens || 0} output=${event.outputTokens || 0}`;
123
+ return 'Claude Code activity';
124
+ }
125
+
126
+ function codexStderrRuntimeError(text = '') {
127
+ const detail = String(text || '').trim();
128
+ if (!detail) return '';
129
+ const lower = detail.toLowerCase();
130
+ if (lower.includes('responses_websocket') && lower.includes('error')) return detail.slice(0, 2000);
131
+ if (lower.includes('failed to connect to websocket') && lower.includes('/v1/responses')) return detail.slice(0, 2000);
132
+ if (lower.includes('authentication') && lower.includes('openai')) return detail.slice(0, 2000);
133
+ if (lower.includes('not logged in') || lower.includes('login is required')) return detail.slice(0, 2000);
134
+ return '';
135
+ }
136
+
64
137
  function packageInfoFromSpec(packageSpec = '') {
65
138
  const match = String(packageSpec || '').trim().match(/^(@magclaw\/(?:daemon|computer))(?:@(.+))?$/);
66
139
  return {
@@ -489,6 +562,38 @@ export async function activeComputerLock(env = process.env) {
489
562
  return activeLockFile(paths.lockFile, { scope: 'computer' });
490
563
  }
491
564
 
565
+ function readJsonFileSync(file, fallback = {}) {
566
+ if (!existsSync(file)) return fallback;
567
+ try {
568
+ return JSON.parse(readFileSync(file, 'utf8'));
569
+ } catch {
570
+ return fallback;
571
+ }
572
+ }
573
+
574
+ function writeJsonFileSync(file, value) {
575
+ mkdirSync(path.dirname(file), { recursive: true });
576
+ writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
577
+ }
578
+
579
+ function readServiceStateSync(profile = DEFAULT_PROFILE, env = process.env) {
580
+ const paths = profilePaths(profile, env);
581
+ return readJsonFileSync(paths.service, {});
582
+ }
583
+
584
+ function activeDaemonLockSync(profile = DEFAULT_PROFILE, env = process.env) {
585
+ const paths = profilePaths(profile, env);
586
+ const lock = readJsonFileSync(paths.lockFile, null);
587
+ if (lock?.pid && pidIsRunning(lock.pid)) {
588
+ return {
589
+ profile: paths.profile,
590
+ ...lock,
591
+ lockFile: paths.lockFile,
592
+ };
593
+ }
594
+ return null;
595
+ }
596
+
492
597
  async function writeLockFile(file, lock) {
493
598
  const handle = await open(file, 'wx');
494
599
  try {
@@ -730,7 +835,7 @@ function renderComputerHelp(subcommand = '') {
730
835
  ' --dry-run Preview upgrade actions',
731
836
  ' --channel <name> latest | alpha | pinned:<semver>',
732
837
  ' --target-version <semver> Explicit target version',
733
- ' --force Accepted for Slock parity; currently maps to the normal upgrade path',
838
+ ' --force Accepted for MagClaw compatibility; currently maps to the normal upgrade path',
734
839
  ],
735
840
  };
736
841
  if (command && usage[command]) return `${usage[command].join('\n')}\n`;
@@ -873,10 +978,24 @@ function backgroundServiceModeForPlatform(platform = process.platform) {
873
978
  return 'foreground';
874
979
  }
875
980
 
981
+ function normalizeBackgroundServiceMode(value = '') {
982
+ const mode = String(value || '').trim().toLowerCase();
983
+ if (['container', 'k8s', 'kubernetes', 'pod'].includes(mode)) return 'container';
984
+ if (['launchd', 'systemd', 'schtasks', 'foreground'].includes(mode)) return mode;
985
+ return '';
986
+ }
987
+
988
+ function requestedBackgroundServiceMode(env = process.env, platform = process.platform) {
989
+ return normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
990
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE)
991
+ || backgroundServiceModeForPlatform(platform);
992
+ }
993
+
876
994
  export function serviceStatePatchForDaemonRun(service = {}, env = process.env, platform = process.platform) {
877
995
  if (daemonRunLaunchedByBackgroundService(env)) {
996
+ const serviceMode = normalizeBackgroundServiceMode(service.mode);
878
997
  return {
879
- mode: service.mode || backgroundServiceModeForPlatform(platform),
998
+ mode: serviceMode && serviceMode !== 'foreground' ? serviceMode : requestedBackgroundServiceMode(env, platform),
880
999
  background: true,
881
1000
  };
882
1001
  }
@@ -1675,6 +1794,60 @@ function contextSnippet(value, limit = 240) {
1675
1794
  return text.length >= limit ? `${text.slice(0, Math.max(0, limit - 3)).trim()}...` : text;
1676
1795
  }
1677
1796
 
1797
+ function contextImageMimeFromName(value = '') {
1798
+ const name = String(value || '').toLowerCase().split(/[?#]/)[0];
1799
+ if (name.endsWith('.png')) return 'image/png';
1800
+ if (name.endsWith('.jpg') || name.endsWith('.jpeg')) return 'image/jpeg';
1801
+ if (name.endsWith('.webp')) return 'image/webp';
1802
+ if (name.endsWith('.gif')) return 'image/gif';
1803
+ if (name.endsWith('.svg')) return 'image/svg+xml';
1804
+ return '';
1805
+ }
1806
+
1807
+ function contextDataImageUrl(value = '') {
1808
+ const text = String(value || '').trim();
1809
+ return /^data:image\//i.test(text) ? text : '';
1810
+ }
1811
+
1812
+ function contextImageType(reference = {}) {
1813
+ const explicit = String(reference.type || reference.mime || reference.mimeType || '').toLowerCase();
1814
+ if (explicit.startsWith('image/')) return explicit;
1815
+ const data = contextDataImageUrl(reference.dataUrl || reference.url || reference.downloadUrl);
1816
+ if (data) return data.match(/^data:([^;,]+)[;,]/i)?.[1]?.toLowerCase() || 'image';
1817
+ return contextImageMimeFromName(reference.name || reference.filename || reference.url || reference.downloadUrl || reference.path || reference.description);
1818
+ }
1819
+
1820
+ function isContextImageReference(reference = {}) {
1821
+ return contextImageType(reference).startsWith('image/');
1822
+ }
1823
+
1824
+ function remoteImageUrl(value = '', serverUrl = '', fallbackPath = '') {
1825
+ const raw = String(value || '').trim();
1826
+ if (contextDataImageUrl(raw)) return raw;
1827
+ const base = String(serverUrl || DEFAULT_SERVER_URL).replace(/\/+$/, '');
1828
+ const candidate = raw || String(fallbackPath || '').trim();
1829
+ if (!candidate) return '';
1830
+ if (candidate.startsWith('/')) {
1831
+ try {
1832
+ return new URL(candidate, base).toString();
1833
+ } catch {
1834
+ return '';
1835
+ }
1836
+ }
1837
+ if (/^https?:\/\//i.test(candidate)) {
1838
+ try {
1839
+ const parsed = new URL(candidate);
1840
+ if (['0.0.0.0', '127.0.0.1', 'localhost', '::1'].includes(parsed.hostname) && base) {
1841
+ return new URL(`${parsed.pathname}${parsed.search}${parsed.hash}`, base).toString();
1842
+ }
1843
+ return parsed.toString();
1844
+ } catch {
1845
+ return '';
1846
+ }
1847
+ }
1848
+ return '';
1849
+ }
1850
+
1678
1851
  function contextActorName(pack, id) {
1679
1852
  const value = String(id || '').trim();
1680
1853
  if (!value) return 'unknown';
@@ -1721,6 +1894,50 @@ function renderContextTasks(pack) {
1721
1894
  }).join('\n');
1722
1895
  }
1723
1896
 
1897
+ function renderContextAttachments(pack) {
1898
+ const attachments = contextArray(pack?.attachments);
1899
+ if (!attachments.length) return '- (none)';
1900
+ return attachments.map((item) => {
1901
+ const details = [
1902
+ item.id ? `id=${item.id}` : '',
1903
+ item.messageId ? `from msg=${item.messageId}` : '',
1904
+ item.source ? `source=${item.source}` : '',
1905
+ item.path ? `path=${item.path}` : '',
1906
+ item.url ? `url=${item.url}` : '',
1907
+ item.id ? `tool=read_attachment(attachmentId="${item.id}")` : '',
1908
+ ].filter(Boolean).join(', ');
1909
+ return `- ${item.name || item.filename || item.id || 'attachment'} ${item.type || item.mime || 'file'} ${Number(item.bytes || item.sizeBytes || 0)} bytes${details ? ` (${details})` : ''}`;
1910
+ }).join('\n');
1911
+ }
1912
+
1913
+ function renderContextTargetAgentAvatar(pack) {
1914
+ const avatar = pack?.targetAgent?.avatar;
1915
+ if (!avatar || avatar.kind === 'none') return '';
1916
+ const description = avatar.description ? ` (${avatar.description})` : '';
1917
+ if (avatar.visualInput !== false && isContextImageReference(avatar)) {
1918
+ return `- Your profile avatar: image supplied as visual input${description}. Use it when the user asks what your avatar shows.`;
1919
+ }
1920
+ return `- Your profile avatar: ${avatar.description || 'configured'}, but no visual input is available.`;
1921
+ }
1922
+
1923
+ function contextParticipantAvatarVisualInputs(pack, targetAgentId = '') {
1924
+ return compactContextParticipants(pack, targetAgentId).selected.filter((item) => (
1925
+ item.id !== targetAgentId
1926
+ && item.type === 'agent'
1927
+ && item.avatar
1928
+ && item.avatar.kind !== 'none'
1929
+ && item.avatar.visualInput !== false
1930
+ && isContextImageReference(item.avatar)
1931
+ ));
1932
+ }
1933
+
1934
+ function renderContextParticipantAvatarInputs(pack, targetAgentId = '') {
1935
+ const visible = contextParticipantAvatarVisualInputs(pack, targetAgentId);
1936
+ if (!visible.length) return '';
1937
+ const names = visible.map((item) => `@${item.name || item.id}`).join(', ');
1938
+ return `- Participant avatar visual inputs: ${names}. Use these when comparing an uploaded image to another Agent avatar; call read_agent_avatar if the relevant Agent is omitted.`;
1939
+ }
1940
+
1724
1941
  function renderContextEventMembers(pack, event = {}) {
1725
1942
  const ids = [
1726
1943
  ...contextArray(event.memberIds),
@@ -1843,6 +2060,8 @@ function renderRemoteAgentContextPack(pack, targetAgentId = '') {
1843
2060
  `- Space: ${pack.space?.type || 'space'} (${pack.space?.visibility || 'public'}${pack.space?.defaultChannel ? ', default workspace channel' : ''})`,
1844
2061
  pack.space?.description ? `- Channel description: ${contextSnippet(pack.space.description, 180)}` : '',
1845
2062
  `- Participants shown: ${participants.selected.map((item) => renderContextParticipant(item, targetAgentId)).join(', ') || '(none)'}`,
2063
+ renderContextTargetAgentAvatar(pack),
2064
+ renderContextParticipantAvatarInputs(pack, targetAgentId),
1846
2065
  participants.omitted ? `- Participants omitted: ${participants.omitted}. Use list_agents/read_agent_profile or search_agent_memory when a broader roster or specialties matter.` : '',
1847
2066
  pack.space?.type === 'channel' && !pack.space?.defaultChannel ? renderContextSuggestedMembers(pack) : '',
1848
2067
  '',
@@ -1871,9 +2090,12 @@ function renderRemoteAgentContextPack(pack, targetAgentId = '') {
1871
2090
  'Relevant tasks:',
1872
2091
  renderContextTasks(pack),
1873
2092
  '',
2093
+ 'Visible attachment metadata and original-file tools:',
2094
+ renderContextAttachments(pack),
2095
+ '',
1874
2096
  renderContextPeerMemory(pack),
1875
2097
  '',
1876
- 'Progressive context tools: list_agents, read_agent_profile, read_history, search_messages, search_agent_memory, read_agent_memory, read_agent_file, and list_tasks are available through MagClaw MCP.',
2098
+ 'Progressive context tools: list_agents, read_agent_profile, read_agent_avatar, read_history, search_messages, list_attachments, read_attachment, search_agent_memory, read_agent_memory, read_agent_file, and list_tasks are available through MagClaw MCP.',
1877
2099
  'For "who can we bring in" or agent suitability questions, use the server member list above first; call list_agents without a target for the server-wide agent roster, because target filters to the current channel.',
1878
2100
  'For agent capability or specialty questions, use peer memory first; if memory is empty or weak, search_messages/read_history for earlier user role assignments before saying the fact is unknown.',
1879
2101
  'Use this compact snapshot first. Call the tools only when the answer depends on omitted participants, deeper history, memory, or task details.',
@@ -1974,11 +2196,14 @@ function canonicalMagClawToolName(name) {
1974
2196
  'send_message',
1975
2197
  'read_history',
1976
2198
  'search_messages',
2199
+ 'list_attachments',
2200
+ 'read_attachment',
1977
2201
  'search_agent_memory',
1978
2202
  'read_agent_memory',
1979
2203
  'read_agent_file',
1980
2204
  'list_agents',
1981
2205
  'read_agent_profile',
2206
+ 'read_agent_avatar',
1982
2207
  'write_memory',
1983
2208
  'list_tasks',
1984
2209
  'create_tasks',
@@ -2153,32 +2378,152 @@ async function globalSkillRoots() {
2153
2378
  return roots;
2154
2379
  }
2155
2380
 
2156
- async function syncGlobalSkillsIntoAgentHome(codexHome, workspace) {
2381
+ function pathIsWithinResolvedRoots(resolvedPath, roots = []) {
2382
+ const cleanPath = path.resolve(resolvedPath);
2383
+ return roots.some((root) => cleanPath === root || cleanPath.startsWith(`${root}${path.sep}`));
2384
+ }
2385
+
2386
+ async function resolvedRoots(roots = []) {
2387
+ const resolved = [];
2388
+ for (const root of roots) {
2389
+ const logical = path.resolve(root);
2390
+ if (!resolved.includes(logical)) resolved.push(logical);
2391
+ const physical = await realpath(root).catch(() => logical);
2392
+ if (!resolved.includes(physical)) resolved.push(physical);
2393
+ }
2394
+ return resolved;
2395
+ }
2396
+
2397
+ async function ensureWorkspaceSkillsDir(workspace, codexHome = '', agent = {}) {
2398
+ const workspaceSkills = path.join(workspace, 'skills');
2399
+ const legacyGeneratedSkills = codexHome ? path.join(codexHome, 'skills') : '';
2400
+ await mkdir(path.dirname(workspaceSkills), { recursive: true });
2401
+ try {
2402
+ const existing = await lstat(workspaceSkills);
2403
+ if (existing.isSymbolicLink()) {
2404
+ const current = await readlink(workspaceSkills);
2405
+ const resolved = path.resolve(path.dirname(workspaceSkills), current);
2406
+ if (legacyGeneratedSkills && resolved === path.resolve(legacyGeneratedSkills)) {
2407
+ await unlink(workspaceSkills);
2408
+ await mkdir(workspaceSkills, { recursive: true });
2409
+ logInfo('skills', `Repaired workspace skills directory for agent ${agent.id || 'unknown'}.`);
2410
+ } else {
2411
+ logWarning('skills', `Workspace skills path for agent ${agent.id || 'unknown'} is a custom symlink; leaving it untouched.`);
2412
+ }
2413
+ } else if (!existing.isDirectory()) {
2414
+ logWarning('skills', `Workspace skills path for agent ${agent.id || 'unknown'} is not a directory; leaving it untouched.`);
2415
+ return null;
2416
+ }
2417
+ } catch (error) {
2418
+ if (error.code !== 'ENOENT') throw error;
2419
+ await mkdir(workspaceSkills, { recursive: true });
2420
+ }
2421
+ return workspaceSkills;
2422
+ }
2423
+
2424
+ async function migrateLegacyAgentSkills(codexHome, workspaceSkills, globalResolvedRoots, agent = {}) {
2425
+ const codexSkillsRoot = path.join(codexHome, 'skills');
2426
+ if (!workspaceSkills || !existsSync(codexSkillsRoot)) return;
2427
+ const entries = await readdir(codexSkillsRoot, { withFileTypes: true }).catch(() => []);
2428
+ for (const entry of entries) {
2429
+ if (entry.name.startsWith('.') || entry.name === '.system') continue;
2430
+ const source = path.join(codexSkillsRoot, entry.name);
2431
+ const target = path.join(workspaceSkills, entry.name);
2432
+ if (existsSync(target)) continue;
2433
+ const sourceInfo = await lstat(source).catch(() => null);
2434
+ if (!sourceInfo) continue;
2435
+ try {
2436
+ if (sourceInfo.isSymbolicLink()) {
2437
+ const current = await readlink(source);
2438
+ const resolved = path.resolve(path.dirname(source), current);
2439
+ if (pathIsWithinResolvedRoots(resolved, globalResolvedRoots)) continue;
2440
+ const realTarget = await realpath(resolved).catch(() => resolved);
2441
+ if (pathIsWithinResolvedRoots(realTarget, globalResolvedRoots)) continue;
2442
+ const targetInfo = await stat(realTarget).catch(() => null);
2443
+ if (!targetInfo) continue;
2444
+ await symlink(realTarget, target, targetInfo.isDirectory() ? 'dir' : 'file');
2445
+ await unlink(source);
2446
+ } else {
2447
+ await rename(source, target);
2448
+ }
2449
+ logInfo('skills', `Migrated legacy local skill ${entry.name} for agent ${agent.id || 'unknown'}.`);
2450
+ } catch (error) {
2451
+ logWarning('skills', `Could not migrate legacy local skill ${entry.name} for agent ${agent.id || 'unknown'}: ${error.message}`);
2452
+ }
2453
+ }
2454
+ }
2455
+
2456
+ async function linkRuntimeSkillEntry(source, target, agent = {}) {
2457
+ const linked = await linkPathEntry(source, target);
2458
+ if (linked) return true;
2459
+ const existing = await lstat(target).catch(() => null);
2460
+ if (existing && !existing.isSymbolicLink() && path.resolve(source) !== path.resolve(target)) {
2461
+ logWarning('skills', `Could not link skill ${path.basename(target)} for agent ${agent.id || 'unknown'} because the runtime path is not a symlink.`);
2462
+ }
2463
+ return false;
2464
+ }
2465
+
2466
+ async function linkSkillRootEntries(sourceRoot, targetRoot, agent = {}, { includeSystem = false } = {}) {
2467
+ const desired = new Set();
2468
+ if (!sourceRoot || !existsSync(sourceRoot)) return desired;
2469
+ const entries = await readdir(sourceRoot, { withFileTypes: true }).catch(() => []);
2470
+ for (const entry of entries) {
2471
+ if (entry.name.startsWith('.') && !(includeSystem && entry.name === '.system')) continue;
2472
+ const source = path.join(sourceRoot, entry.name);
2473
+ if (includeSystem && entry.name === '.system' && (entry.isDirectory() || entry.isSymbolicLink())) {
2474
+ desired.add(entry.name);
2475
+ const targetSystemRoot = path.join(targetRoot, '.system');
2476
+ await mkdir(targetSystemRoot, { recursive: true });
2477
+ const systemEntries = await readdir(source, { withFileTypes: true }).catch(() => []);
2478
+ for (const systemEntry of systemEntries) {
2479
+ if (!systemEntry.isDirectory() && !systemEntry.isSymbolicLink() && !systemEntry.isFile()) continue;
2480
+ const systemSource = path.join(source, systemEntry.name);
2481
+ const systemTarget = path.join(targetSystemRoot, systemEntry.name);
2482
+ await linkRuntimeSkillEntry(systemSource, systemTarget, agent).catch((error) => {
2483
+ logWarning('skills', `Could not link system skill ${systemEntry.name} for agent ${agent.id || 'unknown'}: ${error.message}`);
2484
+ });
2485
+ }
2486
+ continue;
2487
+ }
2488
+ if (!entry.isDirectory() && !entry.isSymbolicLink() && !entry.isFile()) continue;
2489
+ desired.add(entry.name);
2490
+ const target = path.join(targetRoot, entry.name);
2491
+ await linkRuntimeSkillEntry(source, target, agent).catch((error) => {
2492
+ logWarning('skills', `Could not link skill ${entry.name} for agent ${agent.id || 'unknown'}: ${error.message}`);
2493
+ });
2494
+ }
2495
+ return desired;
2496
+ }
2497
+
2498
+ async function pruneGeneratedSkillLinks(targetSkillsRoot, desiredNames, agent = {}) {
2499
+ const entries = await readdir(targetSkillsRoot, { withFileTypes: true }).catch(() => []);
2500
+ for (const entry of entries) {
2501
+ if (entry.name.startsWith('.') && entry.name !== '.system') continue;
2502
+ if (desiredNames.has(entry.name)) continue;
2503
+ const target = path.join(targetSkillsRoot, entry.name);
2504
+ const existing = await lstat(target).catch(() => null);
2505
+ if (!existing) continue;
2506
+ if (existing.isSymbolicLink()) {
2507
+ await unlink(target);
2508
+ } else if (entry.name !== '.system') {
2509
+ logWarning('skills', `Stale runtime skill ${entry.name} for agent ${agent.id || 'unknown'} was not removed because it is not a symlink.`);
2510
+ }
2511
+ }
2512
+ }
2513
+
2514
+ async function syncGlobalSkillsIntoAgentHome(codexHome, workspace, agent = {}) {
2157
2515
  const targetSkillsRoot = path.join(codexHome, 'skills');
2158
2516
  await mkdir(targetSkillsRoot, { recursive: true });
2159
2517
  const roots = await globalSkillRoots();
2518
+ const globalResolvedRoots = await resolvedRoots(roots);
2519
+ const workspaceSkills = await ensureWorkspaceSkillsDir(workspace, codexHome, agent);
2520
+ await migrateLegacyAgentSkills(codexHome, workspaceSkills, globalResolvedRoots, agent);
2521
+ const desiredNames = new Set();
2160
2522
  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
- }
2523
+ for (const name of await linkSkillRootEntries(sourceSkillsRoot, targetSkillsRoot, agent, { includeSystem: true })) desiredNames.add(name);
2178
2524
  }
2179
-
2180
- const workspaceSkillsLink = path.join(workspace, 'skills');
2181
- await linkPathEntry(targetSkillsRoot, workspaceSkillsLink).catch(() => {});
2525
+ for (const name of await linkSkillRootEntries(workspaceSkills, targetSkillsRoot, agent)) desiredNames.add(name);
2526
+ await pruneGeneratedSkillLinks(targetSkillsRoot, desiredNames, agent);
2182
2527
  }
2183
2528
 
2184
2529
  function firstFrontmatterValue(content, keys) {
@@ -2231,11 +2576,12 @@ function shortenSkillPath(absPath, { agentRoot = '', codexHome = '' } = {}) {
2231
2576
  async function parseSkillFile(filePath, scope, context = {}) {
2232
2577
  const content = await readFile(filePath, 'utf8').catch(() => '');
2233
2578
  const resolvedFilePath = await realpath(filePath).catch(() => filePath);
2579
+ const logicalFilePath = path.resolve(filePath);
2234
2580
  const name = firstFrontmatterValue(content, ['name', 'title']) || skillNameFromPath(filePath);
2235
2581
  const description = firstFrontmatterValue(content, ['description', 'summary', 'short_description', 'short-description'])
2236
2582
  || firstMarkdownParagraph(content)
2237
2583
  || 'No description provided.';
2238
- const shortPath = shortenSkillPath(resolvedFilePath, context);
2584
+ const shortPath = shortenSkillPath(logicalFilePath, context);
2239
2585
  return {
2240
2586
  id: `${scope}:${shortPath}`,
2241
2587
  name,
@@ -2326,15 +2672,6 @@ async function findPluginSkillFiles(root, { maxEntries = 400 } = {}) {
2326
2672
  return found;
2327
2673
  }
2328
2674
 
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
2675
  function uniqueSkills(items) {
2339
2676
  const seen = new Set();
2340
2677
  return items.filter((item) => {
@@ -2350,11 +2687,14 @@ function daemonSkillTools() {
2350
2687
  'send_message',
2351
2688
  'read_history',
2352
2689
  'search_messages',
2690
+ 'list_attachments',
2691
+ 'read_attachment',
2353
2692
  'search_agent_memory',
2354
2693
  'read_agent_memory',
2355
2694
  'read_agent_file',
2356
2695
  'list_agents',
2357
2696
  'read_agent_profile',
2697
+ 'read_agent_avatar',
2358
2698
  'write_memory',
2359
2699
  'list_tasks',
2360
2700
  'create_tasks',
@@ -2366,6 +2706,35 @@ function daemonSkillTools() {
2366
2706
  ];
2367
2707
  }
2368
2708
 
2709
+ async function listDaemonAgentSkills({ agent, agentDir, workspace, codexHome = '' }) {
2710
+ const context = { agentRoot: agentDir, codexHome };
2711
+ const roots = await globalSkillRoots();
2712
+ const globalSkills = [];
2713
+ for (const root of roots) globalSkills.push(...await scanSkillsDir(root, 'global', context));
2714
+ const agentRoots = [
2715
+ path.join(workspace, 'skills'),
2716
+ path.join(agentDir, '.codex', 'skills'),
2717
+ path.join(agentDir, '.agents', 'skills'),
2718
+ ];
2719
+ const agentSkills = [];
2720
+ for (const root of agentRoots) agentSkills.push(...await scanSkillsDir(root, 'agent', context));
2721
+ const pluginFiles = await findPluginSkillFiles(path.join(SOURCE_CODEX_HOME, 'plugins', 'cache'));
2722
+ const pluginSkills = [];
2723
+ for (const file of pluginFiles) pluginSkills.push(await parseSkillFile(file, 'plugin', context));
2724
+ return {
2725
+ agent: {
2726
+ id: agent.id,
2727
+ name: agent.name || agent.id,
2728
+ codexHome: codexHome || undefined,
2729
+ workspacePath: workspace,
2730
+ },
2731
+ global: uniqueSkills(globalSkills),
2732
+ workspace: uniqueSkills(agentSkills),
2733
+ plugin: uniqueSkills(pluginSkills),
2734
+ tools: daemonSkillTools(),
2735
+ };
2736
+ }
2737
+
2369
2738
  const DAEMON_PROGRESSIVE_DISCLOSURE_SECTION = [
2370
2739
  '## 渐进式披露',
2371
2740
  '- 其他 Agent 默认只会先读取本文件;不要假设它们已经看到 `notes/` 或 `workspace/` 中的详细文件。',
@@ -2621,9 +2990,12 @@ class CodexAgentSession {
2621
2990
  this.completedToolCallIds = new Set();
2622
2991
  this.activeTurnToolSignatures = new Set();
2623
2992
  this.activeTurnUsedSendMessage = false;
2993
+ this.activeTurnSawResponseDelta = false;
2994
+ this.activeTurnDeltaItemIds = new Set();
2624
2995
  this.codexMessageQueue = Promise.resolve();
2625
2996
  this.streamActivityTimer = null;
2626
2997
  this.pendingStreamActivity = null;
2998
+ this.lastRuntimeError = '';
2627
2999
  this.trajectoryCoalesceMs = envInteger(this.env, 'MAGCLAW_DAEMON_TRAJECTORY_COALESCE_MS', DEFAULT_TRAJECTORY_COALESCE_MS, { min: 0, max: 5_000 });
2628
3000
  }
2629
3001
 
@@ -2650,7 +3022,7 @@ class CodexAgentSession {
2650
3022
  codexHome: this.codexHome(),
2651
3023
  runtimeKind: 'codex',
2652
3024
  });
2653
- await syncGlobalSkillsIntoAgentHome(this.codexHome(), this.workspace());
3025
+ await syncGlobalSkillsIntoAgentHome(this.codexHome(), this.workspace(), this.agent);
2654
3026
  await writeFile(path.join(this.codexHome(), 'config.toml'), [
2655
3027
  'wire_api = "responses"',
2656
3028
  '',
@@ -2668,8 +3040,8 @@ class CodexAgentSession {
2668
3040
  '',
2669
3041
  'This workspace is isolated for a MagClaw cloud-connected agent.',
2670
3042
  '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
3043
  'Agent-specific skills can be installed under `./skills/<skill-name>/SKILL.md`; this path belongs to this agent only.',
3044
+ 'Runtime-generated adapter directories are not skill install targets.',
2673
3045
  '',
2674
3046
  ].join('\n'));
2675
3047
  }
@@ -2684,40 +3056,12 @@ class CodexAgentSession {
2684
3056
 
2685
3057
  async listSkills() {
2686
3058
  await this.prepare();
2687
- const context = {
2688
- agentRoot: this.agentDir(),
3059
+ return listDaemonAgentSkills({
3060
+ agent: this.agent,
3061
+ agentDir: this.agentDir(),
3062
+ workspace: this.workspace(),
2689
3063
  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
3064
  });
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
3065
  }
2722
3066
 
2723
3067
  sendStatus(status, activity = null) {
@@ -2801,6 +3145,38 @@ class CodexAgentSession {
2801
3145
  if (payload) this.send(payload);
2802
3146
  }
2803
3147
 
3148
+ async reportRuntimeError(errorText, rawText = '') {
3149
+ const error = String(errorText || rawText || 'Codex runtime error.').trim().slice(0, 2000);
3150
+ if (!error) return;
3151
+ if (this.status === 'error' && this.lastRuntimeError === error) return;
3152
+ this.lastRuntimeError = error;
3153
+ const activity = {
3154
+ source: 'codex-stderr',
3155
+ error,
3156
+ text: String(rawText || error).trim().slice(0, 2000),
3157
+ at: now(),
3158
+ };
3159
+ this.send({
3160
+ type: 'agent:error',
3161
+ commandId: this.activeDeliveryId || undefined,
3162
+ deliveryId: this.activeDeliveryId || null,
3163
+ agentId: this.agent.id,
3164
+ error,
3165
+ });
3166
+ if (this.activeDeliveryId) {
3167
+ await this.markDelivery(this.activeDeliveryId, 'failed', {
3168
+ agentId: this.agent.id,
3169
+ sessionKey: this.sessionKey || null,
3170
+ messageId: this.lastSourceMessage?.id || null,
3171
+ workItemId: this.lastSourceMessage?.workItemId || null,
3172
+ error,
3173
+ }).catch((markError) => {
3174
+ logWarning('daemon', `Failed to mark delivery ${this.activeDeliveryId} failed after Codex runtime error: ${markError.message}`);
3175
+ });
3176
+ }
3177
+ this.sendStatus('error', activity);
3178
+ }
3179
+
2804
3180
  async requestMagClawTool(pathname, { method = 'GET', query = {}, body = null } = {}) {
2805
3181
  const url = new URL(`${this.serverUrl.replace(/\/+$/, '')}${pathname}`);
2806
3182
  for (const [key, value] of Object.entries(query || {})) {
@@ -2839,6 +3215,92 @@ class CodexAgentSession {
2839
3215
  }
2840
3216
  }
2841
3217
 
3218
+ async readAttachmentImageInput(reference = {}) {
3219
+ const attachmentId = String(reference.id || reference.attachmentId || reference.attachment_id || '').trim();
3220
+ if (!attachmentId) return null;
3221
+ try {
3222
+ const data = await this.requestMagClawTool('/api/agent-tools/attachments/read', {
3223
+ query: {
3224
+ agentId: this.agent.id,
3225
+ attachmentId,
3226
+ maxBytes: 8 * 1024 * 1024,
3227
+ },
3228
+ });
3229
+ const dataUrl = contextDataImageUrl(data?.dataUrl);
3230
+ if (!dataUrl || data?.file?.truncated) return null;
3231
+ return {
3232
+ key: `attachment:${attachmentId}`,
3233
+ input: { type: 'image', url: dataUrl },
3234
+ };
3235
+ } catch (error) {
3236
+ logWarning('attachments', `Could not read image attachment ${attachmentId} for agent ${this.agent.id}: ${error.message}`);
3237
+ return null;
3238
+ }
3239
+ }
3240
+
3241
+ async imageInputFromContextReference(reference = {}, { preferReadAttachment = false } = {}) {
3242
+ if (!isContextImageReference(reference)) return null;
3243
+ if (preferReadAttachment) {
3244
+ const resolved = await this.readAttachmentImageInput(reference);
3245
+ if (resolved) return resolved;
3246
+ }
3247
+ const dataUrl = contextDataImageUrl(reference.dataUrl || reference.url || reference.downloadUrl);
3248
+ if (dataUrl) {
3249
+ return {
3250
+ key: `url:${dataUrl}`,
3251
+ input: { type: 'image', url: dataUrl },
3252
+ };
3253
+ }
3254
+ const url = remoteImageUrl(reference.url || reference.downloadUrl || '', this.serverUrl, reference.description);
3255
+ if (url) {
3256
+ return {
3257
+ key: `url:${url}`,
3258
+ input: { type: 'image', url },
3259
+ };
3260
+ }
3261
+ const filePath = String(reference.path || '').trim();
3262
+ if (filePath && existsSync(filePath)) {
3263
+ return {
3264
+ key: `path:${filePath}`,
3265
+ input: { type: 'localImage', path: filePath },
3266
+ };
3267
+ }
3268
+ return null;
3269
+ }
3270
+
3271
+ async imageInputsForDelivery(message = {}) {
3272
+ const inputs = [];
3273
+ const seen = new Set();
3274
+ const pack = message?.contextPack || {};
3275
+ const attachments = contextArray(pack.attachments);
3276
+ for (const attachment of attachments) {
3277
+ const resolved = await this.imageInputFromContextReference(attachment, { preferReadAttachment: true });
3278
+ if (!resolved || seen.has(resolved.key)) continue;
3279
+ seen.add(resolved.key);
3280
+ inputs.push(resolved.input);
3281
+ }
3282
+ const avatar = pack?.targetAgent?.avatar || null;
3283
+ if (avatar?.visualInput !== false) {
3284
+ const resolved = await this.imageInputFromContextReference(avatar);
3285
+ if (resolved && !seen.has(resolved.key)) {
3286
+ seen.add(resolved.key);
3287
+ inputs.push(resolved.input);
3288
+ }
3289
+ }
3290
+ const targetAgentId = String(pack?.targetAgentId || pack?.targetAgent?.id || this.agent.id || '');
3291
+ for (const participant of contextArray(pack.participants)) {
3292
+ if (participant?.type !== 'agent') continue;
3293
+ if (targetAgentId && String(participant.id || '') === targetAgentId) continue;
3294
+ const participantAvatar = participant.avatar || null;
3295
+ if (!participantAvatar || participantAvatar.visualInput === false || !isContextImageReference(participantAvatar)) continue;
3296
+ const resolved = await this.imageInputFromContextReference(participantAvatar);
3297
+ if (!resolved || seen.has(resolved.key)) continue;
3298
+ seen.add(resolved.key);
3299
+ inputs.push(resolved.input);
3300
+ }
3301
+ return inputs;
3302
+ }
3303
+
2842
3304
  async executeMagClawTool(name, rawArgs = {}) {
2843
3305
  const args = { ...rawArgs, agentId: rawArgs.agentId || this.agent.id };
2844
3306
  switch (name) {
@@ -2876,6 +3338,25 @@ class CodexAgentSession {
2876
3338
  limit: args.limit,
2877
3339
  },
2878
3340
  });
3341
+ case 'list_attachments':
3342
+ return this.requestMagClawTool('/api/agent-tools/attachments', {
3343
+ query: {
3344
+ agentId: args.agentId,
3345
+ target: args.target || args.channel,
3346
+ workItemId: args.workItemId || args.work_item_id,
3347
+ messageId: args.messageId || args.message_id,
3348
+ limit: args.limit,
3349
+ },
3350
+ });
3351
+ case 'read_attachment':
3352
+ return this.requestMagClawTool('/api/agent-tools/attachments/read', {
3353
+ query: {
3354
+ agentId: args.agentId,
3355
+ attachmentId: args.attachmentId || args.attachment_id || args.id,
3356
+ maxBytes: args.maxBytes || args.max_bytes,
3357
+ format: args.format,
3358
+ },
3359
+ });
2879
3360
  case 'search_agent_memory':
2880
3361
  return this.requestMagClawTool('/api/agent-tools/memory/search', {
2881
3362
  query: {
@@ -2917,6 +3398,14 @@ class CodexAgentSession {
2917
3398
  targetAgentId: args.targetAgentId || args.targetAgent,
2918
3399
  },
2919
3400
  });
3401
+ case 'read_agent_avatar':
3402
+ return this.requestMagClawTool('/api/agent-tools/agents/avatar/read', {
3403
+ query: {
3404
+ agentId: args.agentId,
3405
+ targetAgentId: args.targetAgentId || args.targetAgent,
3406
+ maxBytes: args.maxBytes || args.max_bytes,
3407
+ },
3408
+ });
2920
3409
  case 'write_memory':
2921
3410
  {
2922
3411
  const local = await writeDaemonLocalMemory(this.agentDir(), this.agent, args);
@@ -3002,6 +3491,18 @@ class CodexAgentSession {
3002
3491
  }
3003
3492
  }
3004
3493
 
3494
+ appendCompletedAgentText(text = '', { hadDelta = false } = {}) {
3495
+ const value = String(text || '');
3496
+ if (!value) return;
3497
+ if (hadDelta) {
3498
+ if (this.responseBuffer.endsWith(value) || this.responseBuffer.includes(value)) return;
3499
+ if (value.startsWith(this.responseBuffer)) this.responseBuffer = value;
3500
+ else this.responseBuffer += value;
3501
+ return;
3502
+ }
3503
+ if (!this.responseBuffer.includes(value)) this.responseBuffer += value;
3504
+ }
3505
+
3005
3506
  async executeCodexToolItem(item = {}, requestId = null, params = {}) {
3006
3507
  const callId = codexToolCallId(item) || String(params.callId || params.call_id || '');
3007
3508
  const name = canonicalMagClawToolName(codexToolName(item));
@@ -3098,10 +3599,18 @@ class CodexAgentSession {
3098
3599
  this.child.stderr.on('data', (chunk) => {
3099
3600
  const text = chunk.toString().trim();
3100
3601
  if (!text) return;
3602
+ const runtimeError = codexStderrRuntimeError(text);
3603
+ if (runtimeError) {
3604
+ this.reportRuntimeError(runtimeError, text).catch((error) => {
3605
+ logWarning('daemon', `Failed to report Codex runtime error for ${this.agent.id}: ${error.message}`);
3606
+ });
3607
+ return;
3608
+ }
3101
3609
  this.send({
3102
3610
  type: 'agent:activity',
3103
3611
  agentId: this.agent.id,
3104
3612
  status: this.status || 'working',
3613
+ deliveryId: this.activeDeliveryId || null,
3105
3614
  activity: { source: 'codex-stderr', text: text.slice(0, 2000), at: now() },
3106
3615
  });
3107
3616
  });
@@ -3142,19 +3651,23 @@ class CodexAgentSession {
3142
3651
  this.pendingPrompts.push({ prompt, message, workItem, deliveryId });
3143
3652
  return;
3144
3653
  }
3145
- this.startTurn(prompt, message, workItem, deliveryId);
3654
+ await this.startTurn(prompt, message, workItem, deliveryId);
3146
3655
  }
3147
3656
 
3148
- startTurn(prompt, message = {}, workItem = null, deliveryId = '') {
3657
+ async startTurn(prompt, message = {}, workItem = null, deliveryId = '') {
3149
3658
  if (!this.threadId) return false;
3150
3659
  this.activeDeliveryId = deliveryId || '';
3151
3660
  this.activeTurnToolSignatures = new Set();
3152
3661
  this.activeTurnUsedSendMessage = false;
3662
+ this.activeTurnSawResponseDelta = false;
3663
+ this.activeTurnDeltaItemIds = new Set();
3664
+ this.lastRuntimeError = '';
3153
3665
  const model = this.agent.model || undefined;
3154
3666
  const effort = this.agent.reasoningEffort || undefined;
3667
+ const imageInputs = await this.imageInputsForDelivery(message);
3155
3668
  const params = {
3156
3669
  threadId: this.threadId,
3157
- input: [{ type: 'text', text: prompt }],
3670
+ input: [{ type: 'text', text: prompt }, ...imageInputs],
3158
3671
  approvalPolicy: codexApprovalPolicy(this.env),
3159
3672
  ...(model ? { model } : {}),
3160
3673
  ...(effort ? { effort } : {}),
@@ -3223,7 +3736,7 @@ class CodexAgentSession {
3223
3736
  this.send({ type: 'agent:session', agentId: this.agent.id, status: 'idle', sessionId: this.threadId, sessionKey: this.sessionKey || null });
3224
3737
  this.sendStatus('idle', { source: '@magclaw/daemon', detail: 'Codex session ready', at: now() });
3225
3738
  const queued = this.pendingPrompts.splice(0);
3226
- for (const item of queued) this.startTurn(item.prompt, item.message, item.workItem, item.deliveryId);
3739
+ for (const item of queued) await this.startTurn(item.prompt, item.message, item.workItem, item.deliveryId);
3227
3740
  } else if (pending?.method === 'turn/start' || pending?.method === 'turn/steer') {
3228
3741
  this.activeTurnId = message.result?.turn?.id || message.result?.turnId || this.activeTurnId;
3229
3742
  }
@@ -3259,6 +3772,9 @@ class CodexAgentSession {
3259
3772
  }
3260
3773
  if (method === 'item/agentMessage/delta' || method === 'response/output_text/delta') {
3261
3774
  this.responseBuffer += String(params.delta || params.text || '');
3775
+ const itemId = String(params.itemId || params.item_id || params.item?.id || '');
3776
+ if (itemId) this.activeTurnDeltaItemIds.add(itemId);
3777
+ this.activeTurnSawResponseDelta = true;
3262
3778
  this.queueCodexStreamActivity();
3263
3779
  return;
3264
3780
  }
@@ -3266,7 +3782,10 @@ class CodexAgentSession {
3266
3782
  const item = params.item || {};
3267
3783
  if (await this.executeCodexToolItem(item, null, params)) return;
3268
3784
  const text = item?.text || item?.message || params.text || '';
3269
- if (text) this.responseBuffer += String(text);
3785
+ const itemId = String(item?.id || item?.itemId || item?.item_id || '');
3786
+ this.appendCompletedAgentText(text, {
3787
+ hadDelta: Boolean((itemId && this.activeTurnDeltaItemIds.has(itemId)) || this.activeTurnSawResponseDelta),
3788
+ });
3270
3789
  return;
3271
3790
  }
3272
3791
  if (method === 'turn/completed' || method === 'turn/failed') {
@@ -3298,6 +3817,8 @@ class CodexAgentSession {
3298
3817
  this.responseBuffer = '';
3299
3818
  this.activeTurnId = '';
3300
3819
  this.activeTurnUsedSendMessage = false;
3820
+ this.activeTurnSawResponseDelta = false;
3821
+ this.activeTurnDeltaItemIds.clear();
3301
3822
  this.sendStatus(method === 'turn/completed' ? 'idle' : 'error', {
3302
3823
  source: '@magclaw/daemon',
3303
3824
  detail: method === 'turn/completed' ? 'Turn completed' : 'Turn failed',
@@ -3336,6 +3857,11 @@ class ClaudeAgentSession {
3336
3857
  this.status = 'offline';
3337
3858
  this.started = false;
3338
3859
  this.activeDeliveryId = '';
3860
+ this.responseBuffer = '';
3861
+ this.lastSourceMessage = null;
3862
+ this.pendingMessageDelta = null;
3863
+ this.messageDeltaTimer = null;
3864
+ this.trajectoryCoalesceMs = envInteger(this.env, 'MAGCLAW_DAEMON_TRAJECTORY_COALESCE_MS', DEFAULT_TRAJECTORY_COALESCE_MS, { min: 0, max: 5_000 });
3339
3865
  }
3340
3866
 
3341
3867
  agentDir() {
@@ -3348,6 +3874,7 @@ class ClaudeAgentSession {
3348
3874
 
3349
3875
  async prepare() {
3350
3876
  await mkdir(this.workspace(), { recursive: true });
3877
+ await ensureWorkspaceSkillsDir(this.workspace(), path.join(this.agentDir(), 'codex-home'), this.agent);
3351
3878
  await ensureDaemonAgentWorkspaceRoot(this.agentDir(), this.agent);
3352
3879
  await prepareRuntimeHooks({
3353
3880
  agentDir: this.agentDir(),
@@ -3358,6 +3885,8 @@ class ClaudeAgentSession {
3358
3885
  '# MagClaw Remote Claude Agent Workspace',
3359
3886
  '',
3360
3887
  'This workspace is isolated for a MagClaw cloud-connected Claude Code agent.',
3888
+ 'Agent-specific skills can be installed under `./skills/<skill-name>/SKILL.md`; this path belongs to this agent only.',
3889
+ 'Runtime-generated adapter directories are not skill install targets.',
3361
3890
  '',
3362
3891
  ].join('\n'));
3363
3892
  }
@@ -3370,6 +3899,15 @@ class ClaudeAgentSession {
3370
3899
  return readDaemonAgentWorkspaceFile(this.agentDir(), this.agent, relPath);
3371
3900
  }
3372
3901
 
3902
+ async listSkills() {
3903
+ await this.prepare();
3904
+ return listDaemonAgentSkills({
3905
+ agent: this.agent,
3906
+ agentDir: this.agentDir(),
3907
+ workspace: this.workspace(),
3908
+ });
3909
+ }
3910
+
3373
3911
  sendStatus(status, activity = null) {
3374
3912
  this.status = status;
3375
3913
  this.onStatusChange(this, status);
@@ -3387,6 +3925,87 @@ class ClaudeAgentSession {
3387
3925
  });
3388
3926
  }
3389
3927
 
3928
+ queueMessageDelta(delta = '') {
3929
+ const body = this.responseBuffer.trim();
3930
+ if (!body) return;
3931
+ this.pendingMessageDelta = {
3932
+ type: 'agent:message_delta',
3933
+ agentId: this.agent.id,
3934
+ deliveryId: this.activeDeliveryId || null,
3935
+ payload: {
3936
+ body,
3937
+ delta: String(delta || ''),
3938
+ message: this.lastSourceMessage || null,
3939
+ sourceMessage: this.lastSourceMessage || null,
3940
+ spaceType: this.lastSourceMessage?.spaceType || 'channel',
3941
+ spaceId: this.lastSourceMessage?.spaceId || 'chan_all',
3942
+ parentMessageId: this.lastSourceMessage?.parentMessageId || null,
3943
+ idempotencyKey: this.activeDeliveryId || null,
3944
+ },
3945
+ };
3946
+ if (this.trajectoryCoalesceMs <= 0) {
3947
+ this.flushMessageDelta();
3948
+ return;
3949
+ }
3950
+ if (this.messageDeltaTimer) return;
3951
+ this.messageDeltaTimer = setTimeout(() => {
3952
+ this.messageDeltaTimer = null;
3953
+ this.flushMessageDelta();
3954
+ }, this.trajectoryCoalesceMs);
3955
+ this.messageDeltaTimer.unref?.();
3956
+ }
3957
+
3958
+ flushMessageDelta() {
3959
+ if (this.messageDeltaTimer) {
3960
+ clearTimeout(this.messageDeltaTimer);
3961
+ this.messageDeltaTimer = null;
3962
+ }
3963
+ const payload = this.pendingMessageDelta;
3964
+ this.pendingMessageDelta = null;
3965
+ if (payload) this.send(payload);
3966
+ }
3967
+
3968
+ handleClaudeStreamEvent(event) {
3969
+ if (event.type === 'system') {
3970
+ if (event.sessionId) {
3971
+ this.send({
3972
+ type: 'agent:session',
3973
+ agentId: this.agent.id,
3974
+ status: this.status,
3975
+ sessionId: event.sessionId,
3976
+ sessionKey: null,
3977
+ });
3978
+ }
3979
+ return;
3980
+ }
3981
+ if (event.type === 'text') {
3982
+ this.responseBuffer += String(event.delta || '');
3983
+ this.queueMessageDelta(event.delta || '');
3984
+ this.send({
3985
+ type: 'agent:activity',
3986
+ agentId: this.agent.id,
3987
+ status: 'working',
3988
+ deliveryId: this.activeDeliveryId || null,
3989
+ activity: { source: 'claude-code-stream', chars: this.responseBuffer.length, at: now() },
3990
+ });
3991
+ return;
3992
+ }
3993
+ if (event.type === 'thinking' || event.type === 'tool_use' || event.type === 'tool_result' || event.type === 'usage') {
3994
+ this.send({
3995
+ type: 'agent:activity',
3996
+ agentId: this.agent.id,
3997
+ status: event.type === 'thinking' ? 'thinking' : 'working',
3998
+ deliveryId: this.activeDeliveryId || null,
3999
+ activity: {
4000
+ source: 'claude-code-stream',
4001
+ phase: event.type,
4002
+ detail: claudeToolActivityDetail(event),
4003
+ at: now(),
4004
+ },
4005
+ });
4006
+ }
4007
+ }
4008
+
3390
4009
  async start() {
3391
4010
  if (this.started) return;
3392
4011
  await this.prepare();
@@ -3397,11 +4016,12 @@ class ClaudeAgentSession {
3397
4016
  async deliver(message = {}, workItem = null, deliveryId = '') {
3398
4017
  await this.start();
3399
4018
  this.activeDeliveryId = deliveryId || '';
4019
+ this.responseBuffer = '';
4020
+ this.lastSourceMessage = message || null;
3400
4021
  const prompt = deliveryPrompt(this.agent, message, workItem);
3401
4022
  const claudeCommand = this.env.CLAUDE_PATH || 'claude';
3402
- const args = ['--print'];
4023
+ const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose'];
3403
4024
  if (this.agent.model) args.push('--model', String(this.agent.model));
3404
- args.push(prompt);
3405
4025
  const timeoutMs = Number(this.env.MAGCLAW_DAEMON_RUNTIME_TIMEOUT_MS || 10 * 60 * 1000);
3406
4026
  if (this.activeDeliveryId) {
3407
4027
  await this.markDelivery(this.activeDeliveryId, 'started', {
@@ -3412,31 +4032,33 @@ class ClaudeAgentSession {
3412
4032
  }
3413
4033
  this.sendStatus('thinking', { source: 'claude-code', detail: 'Claude Code turn started', at: now() });
3414
4034
  await new Promise((resolve) => {
3415
- let stdout = '';
3416
4035
  let stderr = '';
4036
+ let stdoutBuffer = '';
3417
4037
  let settled = false;
4038
+ const finalMessageFrame = (body) => ({
4039
+ type: 'agent:message',
4040
+ agentId: this.agent.id,
4041
+ deliveryId: this.activeDeliveryId || null,
4042
+ payload: {
4043
+ body,
4044
+ message,
4045
+ sourceMessage: message,
4046
+ spaceType: message.spaceType || 'channel',
4047
+ spaceId: message.spaceId || 'chan_all',
4048
+ parentMessageId: message.parentMessageId || null,
4049
+ idempotencyKey: this.activeDeliveryId || null,
4050
+ },
4051
+ });
3418
4052
  const finish = (status, detail) => {
3419
4053
  if (settled) return;
3420
4054
  settled = true;
3421
4055
  clearTimeout(timer);
4056
+ this.flushMessageDelta();
3422
4057
  this.child = null;
4058
+ const body = this.responseBuffer.trim();
3423
4059
  if (status === 'idle') {
3424
- const body = stdout.trim();
3425
4060
  if (body) {
3426
- const frame = {
3427
- type: 'agent:message',
3428
- agentId: this.agent.id,
3429
- deliveryId: this.activeDeliveryId || null,
3430
- payload: {
3431
- body,
3432
- message,
3433
- sourceMessage: message,
3434
- spaceType: message.spaceType || 'channel',
3435
- spaceId: message.spaceId || 'chan_all',
3436
- parentMessageId: message.parentMessageId || null,
3437
- idempotencyKey: this.activeDeliveryId || null,
3438
- },
3439
- };
4061
+ const frame = finalMessageFrame(body);
3440
4062
  this.send(frame);
3441
4063
  this.markDelivery(this.activeDeliveryId, 'completed', { resultFrame: frame }).catch(() => {});
3442
4064
  }
@@ -3444,8 +4066,10 @@ class ClaudeAgentSession {
3444
4066
  this.sendStatus('idle', { source: 'claude-code', detail: detail || 'Claude Code turn completed', at: now() });
3445
4067
  } else {
3446
4068
  const error = detail || stderr.trim() || 'Claude Code failed.';
4069
+ const frame = body ? finalMessageFrame(body) : null;
4070
+ if (frame) this.send(frame);
3447
4071
  this.send({ type: 'agent:error', commandId: this.activeDeliveryId || undefined, deliveryId: this.activeDeliveryId || null, agentId: this.agent.id, error });
3448
- this.markDelivery(this.activeDeliveryId, 'failed', { error }).catch(() => {});
4072
+ this.markDelivery(this.activeDeliveryId, 'failed', { error, ...(frame ? { resultFrame: frame } : {}) }).catch(() => {});
3449
4073
  this.sendStatus('error', { source: 'claude-code', error, at: now() });
3450
4074
  }
3451
4075
  this.activeDeliveryId = '';
@@ -3468,19 +4092,40 @@ class ClaudeAgentSession {
3468
4092
  finish('error', 'Claude Code session timed out.');
3469
4093
  }, timeoutMs);
3470
4094
  this.child.stdout.on('data', (chunk) => {
3471
- stdout += chunk.toString();
3472
- this.send({
3473
- type: 'agent:activity',
3474
- agentId: this.agent.id,
3475
- status: 'working',
3476
- activity: { source: 'claude-code', chars: stdout.length, at: now() },
3477
- });
4095
+ stdoutBuffer += chunk.toString();
4096
+ const lines = stdoutBuffer.split(/\r?\n/);
4097
+ stdoutBuffer = lines.pop() || '';
4098
+ for (const line of lines) {
4099
+ if (!line.trim()) continue;
4100
+ try {
4101
+ const raw = JSON.parse(line);
4102
+ for (const event of claudeStreamEvents(raw)) this.handleClaudeStreamEvent(event);
4103
+ } catch (error) {
4104
+ stderr += `${line}\n`;
4105
+ this.send({
4106
+ type: 'agent:activity',
4107
+ agentId: this.agent.id,
4108
+ status: 'working',
4109
+ deliveryId: this.activeDeliveryId || null,
4110
+ activity: { source: 'claude-code-stream', error: `Invalid stream JSON: ${error.message}`, at: now() },
4111
+ });
4112
+ }
4113
+ }
3478
4114
  });
3479
4115
  this.child.stderr.on('data', (chunk) => {
3480
4116
  stderr += chunk.toString();
3481
4117
  });
3482
4118
  this.child.on('error', (error) => finish('error', error.message));
3483
4119
  this.child.on('close', (code, signal) => {
4120
+ if (stdoutBuffer.trim()) {
4121
+ try {
4122
+ const raw = JSON.parse(stdoutBuffer.trim());
4123
+ for (const event of claudeStreamEvents(raw)) this.handleClaudeStreamEvent(event);
4124
+ } catch (error) {
4125
+ stderr += `${stdoutBuffer}\n`;
4126
+ }
4127
+ stdoutBuffer = '';
4128
+ }
3484
4129
  if (code === 0) finish('idle');
3485
4130
  else finish('error', stderr.trim() || `Claude Code exited with code ${code ?? 'unknown'}${signal ? ` (${signal})` : ''}.`);
3486
4131
  });
@@ -3489,6 +4134,7 @@ class ClaudeAgentSession {
3489
4134
 
3490
4135
  stop() {
3491
4136
  this.status = 'stopping';
4137
+ this.flushMessageDelta();
3492
4138
  if (this.child) this.child.kill('SIGTERM');
3493
4139
  this.started = false;
3494
4140
  }
@@ -4725,8 +5371,12 @@ async function writeLauncher(profile, env = process.env) {
4725
5371
  || previousService.packageBin
4726
5372
  || packageBinForPackageName(packageName),
4727
5373
  ).trim() || packageBinForPackageName(packageName);
5374
+ const envServiceMode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5375
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE);
5376
+ const previousMode = normalizeBackgroundServiceMode(previousService.mode);
5377
+ const serviceMode = envServiceMode || (previousMode && previousMode !== 'foreground' ? previousMode : backgroundServiceModeForPlatform(process.platform));
4728
5378
  const service = await writeServiceState(paths.profile, {
4729
- mode: process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : process.platform === 'win32' ? 'schtasks' : 'foreground',
5379
+ mode: serviceMode,
4730
5380
  background: true,
4731
5381
  launcher,
4732
5382
  packageSpec,
@@ -4809,6 +5459,72 @@ async function writeLauncher(profile, env = process.env) {
4809
5459
  return launcher;
4810
5460
  }
4811
5461
 
5462
+ async function writeContainerSupervisor(profile, launcher, env = process.env) {
5463
+ const paths = profilePaths(profile, env);
5464
+ await mkdir(paths.runDir, { recursive: true });
5465
+ await mkdir(paths.logDir, { recursive: true });
5466
+ const supervisor = path.join(paths.runDir, 'container-supervisor.js');
5467
+ const restartSec = Math.max(1, Math.min(60, Number(env.MAGCLAW_DAEMON_CONTAINER_RESTART_SEC || 3) || 3));
5468
+ const code = [
5469
+ '#!/usr/bin/env node',
5470
+ "const { spawn } = require('node:child_process');",
5471
+ "const fs = require('node:fs');",
5472
+ "const path = require('node:path');",
5473
+ `const launcher = ${JSON.stringify(launcher)};`,
5474
+ `const serviceFile = ${JSON.stringify(paths.service)};`,
5475
+ `const logDir = ${JSON.stringify(paths.logDir)};`,
5476
+ `const restartMs = ${JSON.stringify(restartSec * 1000)};`,
5477
+ 'let child = null;',
5478
+ 'let stopping = false;',
5479
+ 'function readService() {',
5480
+ " try { return JSON.parse(fs.readFileSync(serviceFile, 'utf8')); } catch { return {}; }",
5481
+ '}',
5482
+ 'function shouldStop() {',
5483
+ ' const service = readService();',
5484
+ ' return Boolean(service.remoteClosed || service.containerSupervisorDisabled);',
5485
+ '}',
5486
+ 'function openLog(name) {',
5487
+ ' fs.mkdirSync(logDir, { recursive: true });',
5488
+ " return fs.openSync(path.join(logDir, name), 'a');",
5489
+ '}',
5490
+ 'function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); }',
5491
+ 'function stop(signal) {',
5492
+ ' stopping = true;',
5493
+ " if (child && child.exitCode === null && child.signalCode === null) child.kill(signal || 'SIGTERM');",
5494
+ ' setTimeout(() => process.exit(0), 5000).unref?.();',
5495
+ '}',
5496
+ "process.once('SIGINT', () => stop('SIGINT'));",
5497
+ "process.once('SIGTERM', () => stop('SIGTERM'));",
5498
+ '(async () => {',
5499
+ ' while (!stopping) {',
5500
+ ' if (shouldStop()) break;',
5501
+ " const out = openLog('daemon.log');",
5502
+ " const err = openLog('daemon.err.log');",
5503
+ ' child = spawn(process.execPath, [launcher], {',
5504
+ " stdio: ['ignore', out, err],",
5505
+ ' env: {',
5506
+ ' ...process.env,',
5507
+ " MAGCLAW_DAEMON_SERVICE_MODE: 'container',",
5508
+ ' },',
5509
+ ' });',
5510
+ " await new Promise((resolve) => child.once('exit', resolve));",
5511
+ ' fs.closeSync(out);',
5512
+ ' fs.closeSync(err);',
5513
+ ' child = null;',
5514
+ ' if (stopping || shouldStop()) break;',
5515
+ ' await sleep(restartMs);',
5516
+ ' }',
5517
+ '})().catch((error) => {',
5518
+ " console.error(`[magclaw-container-supervisor] ${error && error.stack ? error.stack : error}`);",
5519
+ ' process.exit(1);',
5520
+ '});',
5521
+ '',
5522
+ ].join('\n');
5523
+ await writeFile(supervisor, code);
5524
+ await chmod(supervisor, 0o755).catch(() => {});
5525
+ return supervisor;
5526
+ }
5527
+
4812
5528
  function launchAgentLabel(profile) {
4813
5529
  return `ai.magclaw.daemon.${safeProfileName(profile)}`;
4814
5530
  }
@@ -4824,6 +5540,61 @@ async function ensureExecutable(file) {
4824
5540
  await chmod(file, 0o755).catch(() => {});
4825
5541
  }
4826
5542
 
5543
+ async function startContainerBackground(profile, env = process.env) {
5544
+ const paths = profilePaths(profile, env);
5545
+ await mkdir(paths.logDir, { recursive: true });
5546
+ const launcher = await writeLauncher(paths.profile, { ...env, MAGCLAW_DAEMON_SERVICE_MODE: 'container' });
5547
+ await ensureExecutable(launcher);
5548
+ const supervisor = await writeContainerSupervisor(paths.profile, launcher, env);
5549
+ await ensureExecutable(supervisor);
5550
+ await writeServiceState(paths.profile, {
5551
+ mode: 'container',
5552
+ background: true,
5553
+ launcher,
5554
+ containerSupervisor: supervisor,
5555
+ containerSupervisorDisabled: false,
5556
+ }, env);
5557
+ const current = backgroundServiceStatus(paths.profile, env);
5558
+ if (current.active) {
5559
+ return {
5560
+ ok: true,
5561
+ mode: 'container',
5562
+ active: true,
5563
+ alreadyRunning: true,
5564
+ pid: current.pid || null,
5565
+ supervisorPid: current.supervisorPid || null,
5566
+ file: supervisor,
5567
+ launcher,
5568
+ status: current.status || 'running',
5569
+ };
5570
+ }
5571
+ const child = spawn(process.execPath, [supervisor], {
5572
+ cwd: process.cwd(),
5573
+ detached: true,
5574
+ stdio: 'ignore',
5575
+ env: {
5576
+ ...env,
5577
+ MAGCLAW_DAEMON_SERVICE_MODE: 'container',
5578
+ },
5579
+ });
5580
+ child.unref();
5581
+ await writeServiceState(paths.profile, {
5582
+ mode: 'container',
5583
+ background: true,
5584
+ launcher,
5585
+ containerSupervisor: supervisor,
5586
+ containerSupervisorPid: child.pid || null,
5587
+ }, env);
5588
+ return {
5589
+ ok: true,
5590
+ mode: 'container',
5591
+ active: true,
5592
+ supervisorPid: child.pid || null,
5593
+ file: supervisor,
5594
+ launcher,
5595
+ };
5596
+ }
5597
+
4827
5598
  async function startMacBackground(profile, env = process.env) {
4828
5599
  const paths = profilePaths(profile, env);
4829
5600
  const label = launchAgentLabel(paths.profile);
@@ -4934,6 +5705,11 @@ async function startWindowsBackground(profile, env = process.env) {
4934
5705
  }
4935
5706
 
4936
5707
  async function startBackground(profile, env = process.env) {
5708
+ const service = await readServiceState(profile, env);
5709
+ const mode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5710
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE)
5711
+ || normalizeBackgroundServiceMode(service.mode);
5712
+ if (mode === 'container') return startContainerBackground(profile, env);
4937
5713
  if (process.platform === 'darwin') return startMacBackground(profile, env);
4938
5714
  if (process.platform === 'linux') return startLinuxBackground(profile, env);
4939
5715
  if (process.platform === 'win32') return startWindowsBackground(profile, env);
@@ -4961,6 +5737,25 @@ export function parseLaunchdPrintStatus(result = {}) {
4961
5737
 
4962
5738
  function backgroundServiceStatus(profile, env = process.env) {
4963
5739
  const paths = profilePaths(profile, env);
5740
+ const serviceState = readServiceStateSync(paths.profile, env);
5741
+ const requestedMode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5742
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE);
5743
+ if (requestedMode === 'container' || normalizeBackgroundServiceMode(serviceState.mode) === 'container') {
5744
+ const lock = activeDaemonLockSync(paths.profile, env);
5745
+ const supervisorPid = Number(serviceState.containerSupervisorPid || 0);
5746
+ const supervisorRunning = Number.isInteger(supervisorPid) && supervisorPid > 0 && pidIsRunning(supervisorPid);
5747
+ const daemonRunning = Boolean(lock?.pid);
5748
+ return {
5749
+ mode: 'container',
5750
+ active: daemonRunning || supervisorRunning,
5751
+ pid: lock?.pid || null,
5752
+ supervisorPid: supervisorRunning ? supervisorPid : null,
5753
+ file: serviceState.containerSupervisor || '',
5754
+ launcher: serviceState.launcher || '',
5755
+ status: daemonRunning ? 'running' : supervisorRunning ? 'supervising' : 'inactive',
5756
+ error: '',
5757
+ };
5758
+ }
4964
5759
  if (process.platform === 'darwin') {
4965
5760
  const label = launchAgentLabel(paths.profile);
4966
5761
  const result = spawnSync('launchctl', ['print', `gui/${process.getuid()}/${label}`], { encoding: 'utf8' });
@@ -5081,9 +5876,77 @@ async function stopActiveDaemon(profile, env = process.env) {
5081
5876
  return { ok: stopped, running: !stopped, pid, signal };
5082
5877
  }
5083
5878
 
5879
+ function waitForPidExitSync(pid, timeoutMs = 2000) {
5880
+ const deadline = Date.now() + timeoutMs;
5881
+ while (Date.now() < deadline) {
5882
+ if (!pidIsRunning(pid)) return true;
5883
+ sleepSync(100);
5884
+ }
5885
+ return !pidIsRunning(pid);
5886
+ }
5887
+
5888
+ function stopPidSync(pid, { allowCurrent = false, timeoutMs = 2000 } = {}) {
5889
+ const value = Number(pid);
5890
+ if (!Number.isInteger(value) || value <= 0) return { ok: true, running: false };
5891
+ if (!allowCurrent && value === process.pid) {
5892
+ return { ok: true, running: true, pid: value, skippedCurrent: true };
5893
+ }
5894
+ try {
5895
+ process.kill(value, 'SIGTERM');
5896
+ } catch (error) {
5897
+ if (error?.code === 'ESRCH') return { ok: true, running: false, pid: value, stale: true };
5898
+ return { ok: false, running: true, pid: value, error: error.message };
5899
+ }
5900
+ if (waitForPidExitSync(value, timeoutMs)) return { ok: true, running: false, pid: value, signal: 'SIGTERM' };
5901
+ try {
5902
+ process.kill(value, 'SIGKILL');
5903
+ } catch (error) {
5904
+ if (error?.code === 'ESRCH') return { ok: true, running: false, pid: value, signal: 'SIGKILL' };
5905
+ return { ok: false, running: true, pid: value, signal: 'SIGKILL', error: error.message };
5906
+ }
5907
+ const stopped = waitForPidExitSync(value, 1000);
5908
+ return { ok: stopped, running: !stopped, pid: value, signal: 'SIGKILL' };
5909
+ }
5910
+
5911
+ function stopContainerBackground(profile, env = process.env, options = {}) {
5912
+ const paths = profilePaths(profile, env);
5913
+ const state = readServiceStateSync(paths.profile, env);
5914
+ const lock = activeDaemonLockSync(paths.profile, env);
5915
+ const supervisor = stopPidSync(state.containerSupervisorPid);
5916
+ const daemon = stopPidSync(lock?.pid);
5917
+ if (options.disable) {
5918
+ writeJsonFileSync(paths.service, {
5919
+ ...state,
5920
+ version: 1,
5921
+ profile: paths.profile,
5922
+ mode: 'container',
5923
+ background: true,
5924
+ containerSupervisorDisabled: true,
5925
+ updatedAt: now(),
5926
+ });
5927
+ }
5928
+ return {
5929
+ ok: Boolean(supervisor.ok && daemon.ok),
5930
+ mode: 'container',
5931
+ supervisorPid: state.containerSupervisorPid || null,
5932
+ pid: lock?.pid || null,
5933
+ supervisor,
5934
+ process: daemon,
5935
+ file: state.containerSupervisor || '',
5936
+ launcher: state.launcher || '',
5937
+ disabled: Boolean(options.disable),
5938
+ };
5939
+ }
5940
+
5084
5941
  function stopBackground(profile, env = process.env, options = {}) {
5942
+ const paths = profilePaths(profile, env);
5943
+ const state = readServiceStateSync(paths.profile, env);
5944
+ const requestedMode = normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
5945
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE);
5946
+ if (requestedMode === 'container' || normalizeBackgroundServiceMode(state.mode) === 'container') {
5947
+ return stopContainerBackground(paths.profile, env, options);
5948
+ }
5085
5949
  if (process.platform === 'darwin') {
5086
- const paths = profilePaths(profile, env);
5087
5950
  const label = launchAgentLabel(paths.profile);
5088
5951
  const serviceTarget = `gui/${process.getuid()}/${label}`;
5089
5952
  const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
@@ -5570,7 +6433,10 @@ async function runUpgradeWorker(flags, env = process.env) {
5570
6433
 
5571
6434
  await emitProgress({ status: 'upgrading', phase: 'stage_service', progress: 45, message: 'Staging service launcher.' });
5572
6435
  await writeServiceState(profile, {
5573
- mode: serviceBefore.mode || (process.platform === 'darwin' ? 'launchd' : process.platform === 'linux' ? 'systemd' : process.platform === 'win32' ? 'schtasks' : 'foreground'),
6436
+ mode: normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_SERVICE_MODE)
6437
+ || normalizeBackgroundServiceMode(env.MAGCLAW_DAEMON_BACKGROUND_MODE)
6438
+ || normalizeBackgroundServiceMode(serviceBefore.mode)
6439
+ || backgroundServiceModeForPlatform(process.platform),
5574
6440
  background: true,
5575
6441
  packageSpec,
5576
6442
  packageName,