@magclaw/cli-core 0.1.24 → 0.1.25

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 +136 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magclaw/cli-core",
3
- "version": "0.1.24",
3
+ "version": "0.1.25",
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
@@ -404,6 +404,12 @@ export function profilePaths(profile = DEFAULT_PROFILE, env = process.env) {
404
404
  };
405
405
  }
406
406
 
407
+ function sleepSync(ms) {
408
+ const timeout = Math.max(0, Number(ms) || 0);
409
+ if (!timeout) return;
410
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, timeout);
411
+ }
412
+
407
413
  function machineFingerprintValue() {
408
414
  return `mfp_${crypto.createHash('sha256')
409
415
  .update([
@@ -673,6 +679,10 @@ async function readServiceState(profile = DEFAULT_PROFILE, env = process.env) {
673
679
  previousPackageSpec: state.previousPackageSpec || '',
674
680
  installedDaemonVersion: state.installedDaemonVersion || DAEMON_VERSION,
675
681
  installedPackageVersion: state.installedPackageVersion || packageVersion || state.installedDaemonVersion || DAEMON_VERSION,
682
+ remoteClosed: Boolean(state.remoteClosed),
683
+ remoteClosedAt: state.remoteClosedAt || '',
684
+ remoteCloseReason: state.remoteCloseReason || '',
685
+ remoteCloseCommandId: state.remoteCloseCommandId || '',
676
686
  updatedAt: state.updatedAt || '',
677
687
  };
678
688
  }
@@ -691,6 +701,31 @@ async function writeServiceState(profile = DEFAULT_PROFILE, patch = {}, env = pr
691
701
  return next;
692
702
  }
693
703
 
704
+ async function clearRemoteClosedServiceState(profile = DEFAULT_PROFILE, env = process.env) {
705
+ return writeServiceState(profile, {
706
+ remoteClosed: false,
707
+ remoteClosedAt: '',
708
+ remoteCloseReason: '',
709
+ remoteCloseCommandId: '',
710
+ }, env);
711
+ }
712
+
713
+ async function markForegroundServiceState(profile = DEFAULT_PROFILE, env = process.env) {
714
+ const packageInfo = runtimePackageInfo(env);
715
+ return writeServiceState(profile, {
716
+ mode: 'foreground',
717
+ background: false,
718
+ launcher: 'foreground',
719
+ packageSpec: packageInfo.spec,
720
+ packageName: packageInfo.name,
721
+ packageVersion: packageInfo.version,
722
+ packageKind: packageInfo.kind,
723
+ packageBin: packageInfo.bin,
724
+ installedDaemonVersion: packageInfo.version || DAEMON_VERSION,
725
+ installedPackageVersion: packageInfo.version || DAEMON_VERSION,
726
+ }, env);
727
+ }
728
+
694
729
  async function readUpgradeHandoff(profile = DEFAULT_PROFILE, env = process.env) {
695
730
  const paths = profilePaths(profile, env);
696
731
  const handoff = await readJsonFile(paths.upgradeHandoff, null);
@@ -1187,6 +1222,27 @@ export function toWebSocketUrl(serverUrl, config = {}) {
1187
1222
  if (/^mfp_[a-f0-9]{64}$/i.test(machineFingerprint)) {
1188
1223
  url.searchParams.set('machine_fingerprint', machineFingerprint.toLowerCase());
1189
1224
  }
1225
+ const packageName = String(config.packageName || '').trim();
1226
+ const packageVersion = String(config.packageVersion || config.daemonVersion || '').trim();
1227
+ const packageKind = String(config.packageKind || '').trim();
1228
+ const packageSpec = String(config.packageSpec || '').trim();
1229
+ const packageBin = String(config.packageBin || '').trim();
1230
+ const cliCoreVersion = String(config.cliCoreVersion || '').trim();
1231
+ const serviceMode = String(config.serviceMode || '').trim();
1232
+ const serviceBackground = Boolean(config.serviceBackground);
1233
+ const serviceActive = Boolean(config.serviceActive);
1234
+ if (packageName) url.searchParams.set('package_name', packageName);
1235
+ if (packageVersion) {
1236
+ url.searchParams.set('package_version', packageVersion);
1237
+ url.searchParams.set('daemon_version', packageVersion);
1238
+ }
1239
+ if (packageKind) url.searchParams.set('package_kind', packageKind);
1240
+ if (packageSpec) url.searchParams.set('package_spec', packageSpec);
1241
+ if (packageBin) url.searchParams.set('package_bin', packageBin);
1242
+ if (cliCoreVersion) url.searchParams.set('cli_core_version', cliCoreVersion);
1243
+ if (serviceMode) url.searchParams.set('service_mode', serviceMode);
1244
+ url.searchParams.set('service_background', String(serviceBackground));
1245
+ url.searchParams.set('service_active', String(serviceActive));
1190
1246
  return url;
1191
1247
  }
1192
1248
 
@@ -3098,6 +3154,12 @@ class ClaudeAgentSession {
3098
3154
  }
3099
3155
  }
3100
3156
 
3157
+ function readOnlySessionAckStatus(session) {
3158
+ const status = String(session?.status || '').toLowerCase();
3159
+ if ((!status || status === 'offline') && !session?.started) return 'idle';
3160
+ return session?.status || 'idle';
3161
+ }
3162
+
3101
3163
  class MagClawDaemon {
3102
3164
  constructor(config, env = process.env) {
3103
3165
  this.env = env;
@@ -3473,6 +3535,12 @@ class MagClawDaemon {
3473
3535
  packageBin: packageInfo.bin,
3474
3536
  };
3475
3537
  logWarning('daemon', `Remote close requested (${message.reason || 'closed_from_cloud'}).`);
3538
+ await writeServiceState(this.paths.profile, {
3539
+ remoteClosed: true,
3540
+ remoteClosedAt: now(),
3541
+ remoteCloseReason: message.reason || 'closed_from_cloud',
3542
+ remoteCloseCommandId: commandId,
3543
+ }, this.env);
3476
3544
  for (const session of this.sessions.values()) session.stop();
3477
3545
  this.sessions.clear();
3478
3546
  this.send({ type: 'daemon:close:ack',
@@ -3746,7 +3814,7 @@ class MagClawDaemon {
3746
3814
  ? await session.listSkills()
3747
3815
  : { agent: { id: agent.id, name: agent.name || agent.id }, global: [], workspace: [], plugin: [], tools: [] };
3748
3816
  this.send({ type: 'agent:skills:list_result', commandId: message.commandId, agentId: agent.id, skills });
3749
- this.send({ type: 'agent:ack', commandId: message.commandId, agentId: agent.id, status: session.status || 'idle' });
3817
+ this.send({ type: 'agent:ack', commandId: message.commandId, agentId: agent.id, status: readOnlySessionAckStatus(session) });
3750
3818
  } catch (error) {
3751
3819
  this.send({ type: 'agent:skills:list_result', commandId: message.commandId, agentId: agent.id, skills: { agent: { id: agent.id, name: agent.name || agent.id }, global: [], workspace: [], plugin: [], tools: [], error: error.message } });
3752
3820
  this.send({ type: 'agent:error', commandId: message.commandId, agentId: agent.id, error: error.message });
@@ -3771,7 +3839,7 @@ class MagClawDaemon {
3771
3839
  const session = await this.sessionForWorkspaceRequest(message);
3772
3840
  const tree = await session.listWorkspace(message.path || message.payload?.path || '');
3773
3841
  this.send({ type: 'agent:workspace:list_result', commandId: message.commandId, agentId: session.agent.id, tree });
3774
- this.send({ type: 'agent:ack', commandId: message.commandId, agentId: session.agent.id, status: session.status || 'idle' });
3842
+ this.send({ type: 'agent:ack', commandId: message.commandId, agentId: session.agent.id, status: readOnlySessionAckStatus(session) });
3775
3843
  } catch (error) {
3776
3844
  this.send({ type: 'agent:error', commandId: message.commandId, agentId: message.agentId || null, error: error.message });
3777
3845
  }
@@ -3782,7 +3850,7 @@ class MagClawDaemon {
3782
3850
  const session = await this.sessionForWorkspaceRequest(message);
3783
3851
  const file = await session.readWorkspaceFile(message.path || message.payload?.path || 'MEMORY.md');
3784
3852
  this.send({ type: 'agent:workspace:file_result', commandId: message.commandId, agentId: session.agent.id, file: file.file });
3785
- this.send({ type: 'agent:ack', commandId: message.commandId, agentId: session.agent.id, status: session.status || 'idle' });
3853
+ this.send({ type: 'agent:ack', commandId: message.commandId, agentId: session.agent.id, status: readOnlySessionAckStatus(session) });
3786
3854
  } catch (error) {
3787
3855
  this.send({ type: 'agent:error', commandId: message.commandId, agentId: message.agentId || null, error: error.message });
3788
3856
  }
@@ -4032,11 +4100,24 @@ class MagClawDaemon {
4032
4100
  async connectOnce() {
4033
4101
  if (this.closed) return;
4034
4102
  await this.refreshConfigFromDisk();
4035
- const url = toWebSocketUrl(this.config.serverUrl, this.config);
4103
+ const service = await readServiceState(this.paths.profile, this.env);
4104
+ const serviceStatus = backgroundServiceStatus(this.paths.profile, this.env);
4105
+ const packageInfo = runtimePackageInfo(this.env, service);
4106
+ const url = toWebSocketUrl(this.config.serverUrl, {
4107
+ ...this.config,
4108
+ packageName: packageInfo.name,
4109
+ packageVersion: packageInfo.version,
4110
+ packageKind: packageInfo.kind,
4111
+ packageSpec: packageInfo.spec,
4112
+ packageBin: packageInfo.bin,
4113
+ cliCoreVersion: CLI_CORE_VERSION,
4114
+ serviceMode: service.mode || serviceStatus.mode || 'foreground',
4115
+ serviceBackground: Boolean(service.background),
4116
+ serviceActive: Boolean(serviceStatus.active),
4117
+ });
4036
4118
  const requestModule = url.protocol === 'wss:' ? https : http;
4037
4119
  const requestUrl = new URL(url.href.replace(/^ws/, 'http'));
4038
4120
  const key = crypto.randomBytes(16).toString('base64');
4039
- const packageInfo = runtimePackageInfo(this.env);
4040
4121
  logInfo('daemon', `Connecting MagClaw daemon v${packageInfo.version || DAEMON_VERSION} profile "${this.paths.profile}" to ${this.config.serverUrl}...`);
4041
4122
  if (this.closed) return;
4042
4123
  return new Promise((resolve, reject) => {
@@ -4218,6 +4299,10 @@ async function writeLauncher(profile, env = process.env) {
4218
4299
  installedDaemonVersion: packageVersion || DAEMON_VERSION,
4219
4300
  installedPackageVersion: packageVersion || DAEMON_VERSION,
4220
4301
  commandMode: useNpmLauncher ? 'npm' : 'local',
4302
+ remoteClosed: false,
4303
+ remoteClosedAt: '',
4304
+ remoteCloseReason: '',
4305
+ remoteCloseCommandId: '',
4221
4306
  }, env);
4222
4307
  const code = [
4223
4308
  '#!/usr/bin/env node',
@@ -4234,6 +4319,11 @@ async function writeLauncher(profile, env = process.env) {
4234
4319
  `const defaultPackageSpec = ${JSON.stringify(service.packageSpec)};`,
4235
4320
  "let service = {};",
4236
4321
  "try { service = JSON.parse(fs.readFileSync(serviceFile, 'utf8')); } catch {}",
4322
+ "if (service.remoteClosed) {",
4323
+ " const stamp = new Date().toISOString().replace('T', ' ').slice(0, 19);",
4324
+ " console.error(`${stamp} INFO DAEMON profile ${profile} is closed from MagClaw Cloud; run magclaw start/restart/connect to reconnect.`);",
4325
+ " process.exit(0);",
4326
+ "}",
4237
4327
  "const packageSpec = String(service.packageSpec || defaultPackageSpec || '@magclaw/daemon@latest');",
4238
4328
  "const packageName = String(service.packageName || (packageSpec.startsWith('@magclaw/computer@') || packageSpec === '@magclaw/computer' ? '@magclaw/computer' : '@magclaw/daemon'));",
4239
4329
  "const packageKind = String(service.packageKind || (packageName === '@magclaw/computer' ? 'computer' : 'daemon'));",
@@ -4446,6 +4536,29 @@ function backgroundServiceStatus(profile, env = process.env) {
4446
4536
  return { mode: 'foreground', active: false };
4447
4537
  }
4448
4538
 
4539
+ function launchctlResultIsNotLoaded(result) {
4540
+ const detail = String(result?.stderr || result?.stdout || '').toLowerCase();
4541
+ return detail.includes('no such process') || detail.includes('could not find service') || detail.includes('not found');
4542
+ }
4543
+
4544
+ function launchctlServiceIsStopped(serviceTarget) {
4545
+ const result = spawnSync('launchctl', ['print', serviceTarget], { encoding: 'utf8' });
4546
+ if (result.status !== 0) return launchctlResultIsNotLoaded(result);
4547
+ const output = String(result.stdout || result.stderr || '');
4548
+ if (/\bstate\s*=\s*running\b/i.test(output)) return false;
4549
+ if (/\bpid\s*=\s*[1-9][0-9]*\b/i.test(output)) return false;
4550
+ return true;
4551
+ }
4552
+
4553
+ function waitForBackgroundServiceStopped(serviceTarget, timeoutMs = 2000) {
4554
+ const deadline = Date.now() + Math.max(250, Number(timeoutMs) || 2000);
4555
+ while (Date.now() < deadline) {
4556
+ if (launchctlServiceIsStopped(serviceTarget)) return true;
4557
+ sleepSync(100);
4558
+ }
4559
+ return launchctlServiceIsStopped(serviceTarget);
4560
+ }
4561
+
4449
4562
  async function waitForPidExit(pid, timeoutMs = 2000) {
4450
4563
  const deadline = Date.now() + timeoutMs;
4451
4564
  while (Date.now() < deadline) {
@@ -4498,23 +4611,32 @@ function stopBackground(profile, env = process.env, options = {}) {
4498
4611
  if (process.platform === 'darwin') {
4499
4612
  const paths = profilePaths(profile, env);
4500
4613
  const label = launchAgentLabel(paths.profile);
4614
+ const serviceTarget = `gui/${process.getuid()}/${label}`;
4501
4615
  const plist = path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`);
4502
4616
  if (options.disable) {
4503
- const disabled = spawnSync('launchctl', ['disable', `gui/${process.getuid()}/${label}`], { encoding: 'utf8' });
4617
+ const disabled = spawnSync('launchctl', ['disable', serviceTarget], { encoding: 'utf8' });
4504
4618
  const stopped = spawnSync('launchctl', ['stop', label], { encoding: 'utf8' });
4619
+ const stoppedConfirmed = stopped.status === 0 && waitForBackgroundServiceStopped(serviceTarget);
4620
+ const needsBootout = disabled.status !== 0 || !stoppedConfirmed;
4621
+ const bootout = needsBootout
4622
+ ? spawnSync('launchctl', ['bootout', serviceTarget], { encoding: 'utf8' })
4623
+ : null;
4624
+ const bootoutOk = bootout ? (bootout.status === 0 || launchctlResultIsNotLoaded(bootout)) : false;
4505
4625
  if (stopped.status !== 0) spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plist], { stdio: 'ignore' });
4506
4626
  return {
4507
- ok: disabled.status === 0 || stopped.status === 0,
4627
+ ok: disabled.status === 0 && (stoppedConfirmed || bootoutOk),
4508
4628
  mode: 'launchd',
4509
4629
  label,
4630
+ serviceTarget,
4510
4631
  file: plist,
4511
4632
  disabled: disabled.status === 0,
4512
- stopped: stopped.status === 0,
4513
- error: disabled.status === 0 && stopped.status === 0 ? '' : String(stopped.stderr || disabled.stderr || stopped.stdout || disabled.stdout || '').trim(),
4633
+ stopped: stoppedConfirmed || bootoutOk,
4634
+ bootout: bootout ? bootout.status === 0 : false,
4635
+ error: disabled.status === 0 && (stoppedConfirmed || bootoutOk) ? '' : String(bootout?.stderr || stopped.stderr || disabled.stderr || bootout?.stdout || stopped.stdout || disabled.stdout || '').trim(),
4514
4636
  };
4515
4637
  }
4516
- spawnSync('launchctl', ['bootout', `gui/${process.getuid()}`, plist], { stdio: 'ignore' });
4517
- return { ok: true, mode: 'launchd', label, file: plist };
4638
+ const bootout = spawnSync('launchctl', ['bootout', serviceTarget], { encoding: 'utf8' });
4639
+ return { ok: bootout.status === 0 || launchctlResultIsNotLoaded(bootout), mode: 'launchd', label, serviceTarget, file: plist, error: bootout.stderr || bootout.stdout || '' };
4518
4640
  }
4519
4641
  if (process.platform === 'linux') {
4520
4642
  const paths = profilePaths(profile, env);
@@ -5141,6 +5263,7 @@ async function runComputerSetup(flags, env = process.env) {
5141
5263
  fingerprint: owner.fingerprint,
5142
5264
  }, env);
5143
5265
  await saveProfile(config.profile, config, env);
5266
+ await clearRemoteClosedServiceState(config.profile, env);
5144
5267
  const cli = await tryInstallCliShim(flags, env);
5145
5268
  const result = await startBackground(config.profile, env);
5146
5269
  printJson({
@@ -5179,6 +5302,7 @@ async function buildConfig(flags, env = process.env) {
5179
5302
 
5180
5303
  async function runForegroundDaemon(config, env = process.env) {
5181
5304
  const releaseLock = await acquireDaemonLock(config.profile, config, env);
5305
+ await markForegroundServiceState(config.profile, env);
5182
5306
  const daemon = new MagClawDaemon(config, env);
5183
5307
  let forceExitTimer = null;
5184
5308
  const shutdown = (signal) => {
@@ -5205,6 +5329,7 @@ async function runConnect(flags, env = process.env) {
5205
5329
  throw new Error('Run connect with --api-key, --pair-token for legacy pairing, or use a saved profile with a machine token.');
5206
5330
  }
5207
5331
  await saveProfile(config.profile, config, env);
5332
+ await clearRemoteClosedServiceState(config.profile, env);
5208
5333
  const cli = await tryInstallCliShim(flags, env);
5209
5334
  if (flags.background) {
5210
5335
  const result = await startBackground(config.profile, env);