@magclaw/cli-core 0.1.27 → 0.1.29

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/README.md CHANGED
@@ -5,3 +5,9 @@ Shared local MagClaw CLI implementation.
5
5
  This package owns the reusable command implementation used by
6
6
  `@magclaw/daemon` and `@magclaw/computer`. Install the public entry packages
7
7
  instead of using this package directly.
8
+
9
+ Public entry commands:
10
+
11
+ - `magclaw`: daemon/profile operations.
12
+ - `magclaw-computer`: browser-approved Computer setup and control-plane
13
+ operations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magclaw/cli-core",
3
- "version": "0.1.27",
3
+ "version": "0.1.29",
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
@@ -12,7 +12,7 @@ import { renderListProfiles, shouldUseColor } from './list-renderer.js';
12
12
  export const DEFAULT_PROFILE = 'default';
13
13
  export const DEFAULT_SERVER_URL = 'http://127.0.0.1:6543';
14
14
  const DEFAULT_DAEMON_HEARTBEAT_MS = 25_000;
15
- const DEFAULT_DAEMON_INBOUND_WATCHDOG_MS = 70_000;
15
+ const DEFAULT_DAEMON_INBOUND_WATCHDOG_MS = 15_000;
16
16
  const DEFAULT_DAEMON_RECONNECT_MIN_MS = 1_000;
17
17
  const DEFAULT_DAEMON_RECONNECT_MAX_MS = 30_000;
18
18
  const DEFAULT_MAX_CONCURRENT_AGENT_STARTS = 5;
@@ -385,6 +385,10 @@ function rootLockPaths(env = process.env) {
385
385
  };
386
386
  }
387
387
 
388
+ function computerChannelPath(env = process.env) {
389
+ return path.join(daemonRoot(env), 'channel');
390
+ }
391
+
388
392
  export function profilePaths(profile = DEFAULT_PROFILE, env = process.env) {
389
393
  const profileName = safeProfileName(profile);
390
394
  const dir = path.join(daemonRoot(env), 'profiles', profileName);
@@ -541,6 +545,10 @@ export function parseCli(argv = process.argv) {
541
545
  flags.help = true;
542
546
  continue;
543
547
  }
548
+ if (item === '-V') {
549
+ flags.version = true;
550
+ continue;
551
+ }
544
552
  if (!item.startsWith('--')) {
545
553
  positionals.push(item);
546
554
  continue;
@@ -612,6 +620,150 @@ function renderHelp() {
612
620
  ].join('\n');
613
621
  }
614
622
 
623
+ function renderComputerHelp(subcommand = '') {
624
+ const command = String(subcommand || '').trim();
625
+ const usage = {
626
+ login: [
627
+ 'Usage: magclaw-computer login [options] <serverSlug>',
628
+ '',
629
+ 'Start MagClaw browser approval for one server. This is an alias for setup.',
630
+ '',
631
+ 'Options:',
632
+ ' --server-url <url> MagClaw Cloud URL',
633
+ ' --name <name> Computer display name',
634
+ ' --no-start Save the approved profile without starting the daemon',
635
+ ' -h, --help Show this help',
636
+ ],
637
+ attach: [
638
+ 'Usage: magclaw-computer attach [options] <serverSlug>',
639
+ '',
640
+ 'Attach this Computer to one MagClaw server using browser approval.',
641
+ '',
642
+ 'Options:',
643
+ ' --server-url <url> MagClaw Cloud URL',
644
+ ' --name <name> Computer display name',
645
+ ' --no-run Save the approved profile without starting the daemon',
646
+ ' --foreground Run foreground if background service cannot start',
647
+ ' -h, --help Show this help',
648
+ ],
649
+ setup: [
650
+ 'Usage: magclaw-computer setup [options] <serverSlug>',
651
+ '',
652
+ 'Set up this Computer for one server, then start its daemon unless --no-start is set.',
653
+ '',
654
+ 'Options:',
655
+ ' --server-url <url> MagClaw Cloud URL',
656
+ ' --name <name> Computer display name',
657
+ ' --no-start Save the approved profile without starting the daemon',
658
+ ' --foreground Run foreground if background service cannot start',
659
+ ' -h, --help Show this help',
660
+ ],
661
+ detach: [
662
+ 'Usage: magclaw-computer detach <serverSlug>',
663
+ '',
664
+ 'Stop one local profile and remove its local attachment state.',
665
+ ],
666
+ status: [
667
+ 'Usage: magclaw-computer status [options] [serverSlug]',
668
+ '',
669
+ 'Show aggregate Computer state, or one server profile when a slug is provided.',
670
+ '',
671
+ 'Options:',
672
+ ' --json Emit the machine-readable report',
673
+ ' -h, --help Show this help',
674
+ ],
675
+ start: [
676
+ 'Usage: magclaw-computer start [options] [serverSlug]',
677
+ '',
678
+ 'Start one saved background daemon profile, or all saved profiles when no slug is provided.',
679
+ '',
680
+ 'Options:',
681
+ ' --foreground Run in this terminal for one selected profile',
682
+ ],
683
+ stop: [
684
+ 'Usage: magclaw-computer stop [options] [serverSlug]',
685
+ '',
686
+ 'Stop one daemon profile, or all saved profiles when no slug is provided.',
687
+ '',
688
+ 'Options:',
689
+ ' --disable Suppress background relaunch until the next start',
690
+ ],
691
+ doctor: [
692
+ 'Usage: magclaw-computer doctor [options] [serverSlug]',
693
+ '',
694
+ 'Diagnose local profiles, service state, runtime availability, and stale pidfiles.',
695
+ '',
696
+ 'Options:',
697
+ ' --json Emit the machine-readable report',
698
+ ' --cleanup Clear stale local locks while diagnosing',
699
+ ' --fix Alias for --cleanup',
700
+ ],
701
+ logs: [
702
+ 'Usage: magclaw-computer logs [options] [serverSlug]',
703
+ '',
704
+ 'Print recent daemon logs for one attached server profile.',
705
+ '',
706
+ 'Options:',
707
+ ' --lines <n> Number of trailing lines to print (default 120)',
708
+ ' --server <slug> Select a server profile',
709
+ ],
710
+ runners: [
711
+ 'Usage: magclaw-computer runners <command> [options]',
712
+ '',
713
+ 'Computer runner control plane.',
714
+ '',
715
+ 'Commands:',
716
+ ' list List local daemon profiles and known Computer bindings',
717
+ ' stop <agentId> Not available locally; stop Agents from the MagClaw web console',
718
+ ],
719
+ channel: [
720
+ 'Usage: magclaw-computer channel [set <channel>]',
721
+ '',
722
+ 'Show or set the local Computer release channel (latest | alpha | pinned:<semver>).',
723
+ ],
724
+ upgrade: [
725
+ 'Usage: magclaw-computer upgrade [options]',
726
+ '',
727
+ 'Upgrade the background Computer package for a saved profile.',
728
+ '',
729
+ 'Options:',
730
+ ' --dry-run Preview upgrade actions',
731
+ ' --channel <name> latest | alpha | pinned:<semver>',
732
+ ' --target-version <semver> Explicit target version',
733
+ ' --force Accepted for Slock parity; currently maps to the normal upgrade path',
734
+ ],
735
+ };
736
+ if (command && usage[command]) return `${usage[command].join('\n')}\n`;
737
+ return [
738
+ `MagClaw Computer CLI ${DAEMON_VERSION}`,
739
+ '',
740
+ 'Usage: magclaw-computer [options] [command]',
741
+ '',
742
+ 'MagClaw Computer - local-machine control plane (browser approval + per-server profiles).',
743
+ '',
744
+ 'Options:',
745
+ ' -V, --version output the version number',
746
+ ' -h, --help show help for command',
747
+ '',
748
+ 'Commands:',
749
+ ' login [options] <serverSlug> Browser-approved login for one server (alias for setup)',
750
+ ' attach [options] <serverSlug> Attach this Computer to one server',
751
+ ' setup [options] <serverSlug> Login/attach if needed, then start',
752
+ ' adopt-legacy [options] <serverSlug> Migrate from legacy pair-token style setup when possible',
753
+ ' detach <serverSlug> Remove one local server attachment',
754
+ ' status [options] [serverSlug] Show aggregate or per-profile state',
755
+ ' start [options] [serverSlug] Start one or all saved profiles',
756
+ ' stop [options] [serverSlug] Stop one or all saved profiles',
757
+ ' doctor [options] [serverSlug] Diagnose local profiles and runtime state',
758
+ ' logs [options] [serverSlug] Print one profile daemon log',
759
+ ' runners Computer runner control plane',
760
+ ' channel Show or set release channel',
761
+ ' upgrade [options] Upgrade the Computer package',
762
+ ' help [command] show help for command',
763
+ '',
764
+ ].join('\n');
765
+ }
766
+
615
767
  async function readJsonFile(file, fallback = {}) {
616
768
  if (!existsSync(file)) return fallback;
617
769
  try {
@@ -710,6 +862,27 @@ async function clearRemoteClosedServiceState(profile = DEFAULT_PROFILE, env = pr
710
862
  }, env);
711
863
  }
712
864
 
865
+ export function daemonRunLaunchedByBackgroundService(env = process.env) {
866
+ return String(env.MAGCLAW_DAEMON_BACKGROUND_SERVICE || '').trim() === '1';
867
+ }
868
+
869
+ function backgroundServiceModeForPlatform(platform = process.platform) {
870
+ if (platform === 'darwin') return 'launchd';
871
+ if (platform === 'linux') return 'systemd';
872
+ if (platform === 'win32') return 'schtasks';
873
+ return 'foreground';
874
+ }
875
+
876
+ export function serviceStatePatchForDaemonRun(service = {}, env = process.env, platform = process.platform) {
877
+ if (daemonRunLaunchedByBackgroundService(env)) {
878
+ return {
879
+ mode: service.mode || backgroundServiceModeForPlatform(platform),
880
+ background: true,
881
+ };
882
+ }
883
+ return { mode: 'foreground', background: false };
884
+ }
885
+
713
886
  async function markForegroundServiceState(profile = DEFAULT_PROFILE, env = process.env) {
714
887
  const packageInfo = runtimePackageInfo(env);
715
888
  return writeServiceState(profile, {
@@ -726,6 +899,22 @@ async function markForegroundServiceState(profile = DEFAULT_PROFILE, env = proce
726
899
  }, env);
727
900
  }
728
901
 
902
+ async function markDaemonRunServiceState(profile = DEFAULT_PROFILE, env = process.env) {
903
+ if (!daemonRunLaunchedByBackgroundService(env)) return markForegroundServiceState(profile, env);
904
+ const service = await readServiceState(profile, env);
905
+ const packageInfo = runtimePackageInfo(env, service);
906
+ return writeServiceState(profile, {
907
+ ...serviceStatePatchForDaemonRun(service, env),
908
+ packageSpec: packageInfo.spec,
909
+ packageName: packageInfo.name,
910
+ packageVersion: packageInfo.version,
911
+ packageKind: packageInfo.kind,
912
+ packageBin: packageInfo.bin,
913
+ installedDaemonVersion: packageInfo.version || DAEMON_VERSION,
914
+ installedPackageVersion: packageInfo.version || DAEMON_VERSION,
915
+ }, env);
916
+ }
917
+
729
918
  async function readUpgradeHandoff(profile = DEFAULT_PROFILE, env = process.env) {
730
919
  const paths = profilePaths(profile, env);
731
920
  const handoff = await readJsonFile(paths.upgradeHandoff, null);
@@ -3249,6 +3438,8 @@ class MagClawDaemon {
3249
3438
  this.pendingUpgradeRequest = null;
3250
3439
  this.upgradeIdleTimer = null;
3251
3440
  this.upgradeWorkerStarting = false;
3441
+ this.runtimeStatusTimer = null;
3442
+ this.runtimeStatusInFlight = false;
3252
3443
  }
3253
3444
 
3254
3445
  send(payload) {
@@ -3613,13 +3804,12 @@ class MagClawDaemon {
3613
3804
  } else {
3614
3805
  logInfo('daemon', 'Foreground close request did not stop background service.');
3615
3806
  }
3616
- this.close();
3807
+ this.close({ notify: false, reason: 'cloud_close' });
3617
3808
  process.exitCode = 0;
3618
3809
  setTimeout(() => process.exit(0), 50).unref?.();
3619
3810
  }
3620
3811
 
3621
3812
  async readyPayload() {
3622
- const runtimes = await detectRuntimes(this.env);
3623
3813
  const owner = await ensureMachineFingerprint(this.paths.profile, this.env);
3624
3814
  const service = await readServiceState(this.paths.profile, this.env);
3625
3815
  const serviceStatus = backgroundServiceStatus(this.paths.profile, this.env);
@@ -3658,8 +3848,7 @@ class MagClawDaemon {
3658
3848
  cliCoreVersion: CLI_CORE_VERSION,
3659
3849
  },
3660
3850
  upgrade: upgrade || null,
3661
- runtimes: runtimes.filter((runtime) => runtime.installed).map((runtime) => runtime.id),
3662
- runtimeDetails: runtimes,
3851
+ runtimeScanPending: true,
3663
3852
  runningAgents: [...this.sessions.keys()],
3664
3853
  capabilities: CAPABILITIES,
3665
3854
  };
@@ -3670,10 +3859,68 @@ class MagClawDaemon {
3670
3859
  const sent = this.send(payload);
3671
3860
  logInfo(
3672
3861
  'daemon',
3673
- `Sent ready payload for computer ${payload.computerId || 'unpaired'} (runtimes=${payload.runtimes.join(', ') || 'none'}, runningAgents=${payload.runningAgents.length}, sent=${sent}).`,
3862
+ `Sent ready payload for computer ${payload.computerId || 'unpaired'} (runtimes=deferred, runningAgents=${payload.runningAgents.length}, sent=${sent}).`,
3674
3863
  );
3675
3864
  }
3676
3865
 
3866
+ runtimeStatusDelayMs() {
3867
+ return envInteger(this.env, 'MAGCLAW_DAEMON_RUNTIME_STATUS_DELAY_MS', 1000, { min: 0, max: 60_000 });
3868
+ }
3869
+
3870
+ clearRuntimeStatusTimer() {
3871
+ if (!this.runtimeStatusTimer) return;
3872
+ clearTimeout(this.runtimeStatusTimer);
3873
+ this.runtimeStatusTimer = null;
3874
+ }
3875
+
3876
+ scheduleRuntimeStatus(reason = 'ready_ack') {
3877
+ if (this.closed || this.runtimeStatusTimer || this.runtimeStatusInFlight) return;
3878
+ this.runtimeStatusTimer = setTimeout(() => {
3879
+ this.runtimeStatusTimer = null;
3880
+ this.sendRuntimeStatus(reason).catch((error) => {
3881
+ logWarning('daemon', `Failed to send runtime status: ${error.message}`);
3882
+ });
3883
+ }, this.runtimeStatusDelayMs());
3884
+ this.runtimeStatusTimer.unref?.();
3885
+ }
3886
+
3887
+ async runtimeStatusPayload(reason = 'ready_ack') {
3888
+ const runtimes = await detectRuntimes(this.env);
3889
+ const packageInfo = runtimePackageInfo(this.env);
3890
+ return {
3891
+ type: 'daemon:runtime_status',
3892
+ time: now(),
3893
+ reason,
3894
+ computerId: this.config.computerId || null,
3895
+ daemonVersion: packageInfo.version || DAEMON_VERSION,
3896
+ packageName: packageInfo.name,
3897
+ packageVersion: packageInfo.version,
3898
+ packageKind: packageInfo.kind,
3899
+ packageSpec: packageInfo.spec,
3900
+ packageBin: packageInfo.bin,
3901
+ cliCoreVersion: CLI_CORE_VERSION,
3902
+ runtimes: runtimes.filter((runtime) => runtime.installed).map((runtime) => runtime.id),
3903
+ runtimeDetails: runtimes,
3904
+ runningAgents: [...this.sessions.keys()],
3905
+ };
3906
+ }
3907
+
3908
+ async sendRuntimeStatus(reason = 'ready_ack') {
3909
+ if (this.closed || this.runtimeStatusInFlight) return false;
3910
+ this.runtimeStatusInFlight = true;
3911
+ try {
3912
+ const payload = await this.runtimeStatusPayload(reason);
3913
+ const sent = this.send(payload);
3914
+ logInfo(
3915
+ 'daemon',
3916
+ `Sent runtime status for computer ${payload.computerId || 'unpaired'} (runtimes=${payload.runtimes.join(', ') || 'none'}, sent=${sent}).`,
3917
+ );
3918
+ return sent;
3919
+ } finally {
3920
+ this.runtimeStatusInFlight = false;
3921
+ }
3922
+ }
3923
+
3677
3924
  sendHeartbeat() {
3678
3925
  const packageInfo = runtimePackageInfo(this.env);
3679
3926
  const sent = this.send({
@@ -3801,6 +4048,7 @@ class MagClawDaemon {
3801
4048
  break;
3802
4049
  case 'ready:ack':
3803
4050
  logInfo('daemon', `MagClaw daemon ready for computer ${message.computerId || this.config.computerId}.`);
4051
+ this.scheduleRuntimeStatus('ready_ack');
3804
4052
  break;
3805
4053
  case 'ping':
3806
4054
  this.send({ type: 'pong', time: now() });
@@ -3856,7 +4104,7 @@ class MagClawDaemon {
3856
4104
  break;
3857
4105
  case 'token:revoked':
3858
4106
  logError('daemon', 'Machine token was revoked by the server.');
3859
- this.close();
4107
+ this.close({ notify: false, reason: 'token_revoked' });
3860
4108
  process.exitCode = 2;
3861
4109
  break;
3862
4110
  default:
@@ -4134,13 +4382,35 @@ class MagClawDaemon {
4134
4382
  });
4135
4383
  }
4136
4384
 
4137
- close() {
4385
+ sendStoppingNotice(reason = 'local_stop') {
4386
+ const packageInfo = runtimePackageInfo(this.env);
4387
+ const sent = this.send({
4388
+ type: 'daemon:stopping',
4389
+ time: now(),
4390
+ reason,
4391
+ computerId: this.config.computerId || null,
4392
+ daemonVersion: packageInfo.version || DAEMON_VERSION,
4393
+ packageName: packageInfo.name,
4394
+ packageVersion: packageInfo.version,
4395
+ packageKind: packageInfo.kind,
4396
+ packageSpec: packageInfo.spec,
4397
+ packageBin: packageInfo.bin,
4398
+ runningAgents: [...this.sessions.keys()],
4399
+ });
4400
+ if (sent) logInfo('daemon', `Sent stopping notice (${reason}).`);
4401
+ return sent;
4402
+ }
4403
+
4404
+ close(options = {}) {
4138
4405
  if (this.closed) return;
4406
+ const notify = options.notify !== false;
4407
+ if (notify) this.sendStoppingNotice(options.reason || 'local_stop');
4139
4408
  this.closed = true;
4140
4409
  for (const session of this.sessions.values()) session.stop();
4141
4410
  this.sessions.clear();
4142
4411
  this.stopHeartbeat();
4143
4412
  this.clearInboundWatchdog();
4413
+ this.clearRuntimeStatusTimer();
4144
4414
  if (this.agentStartPumpTimer) {
4145
4415
  clearTimeout(this.agentStartPumpTimer);
4146
4416
  this.agentStartPumpTimer = null;
@@ -4155,7 +4425,10 @@ class MagClawDaemon {
4155
4425
  this.request.destroy(new Error('MagClaw daemon is shutting down.'));
4156
4426
  this.request = null;
4157
4427
  }
4158
- if (this.socket && !this.socket.destroyed) this.socket.destroy();
4428
+ if (this.socket && !this.socket.destroyed) {
4429
+ if (notify) this.socket.end();
4430
+ else this.socket.destroy();
4431
+ }
4159
4432
  this.socket = null;
4160
4433
  }
4161
4434
 
@@ -4190,6 +4463,7 @@ class MagClawDaemon {
4190
4463
  settled = true;
4191
4464
  this.stopHeartbeat();
4192
4465
  this.clearInboundWatchdog();
4466
+ this.clearRuntimeStatusTimer();
4193
4467
  if (this.socket && this.socket.destroyed) this.socket = null;
4194
4468
  if (this.request === req) this.request = null;
4195
4469
  callback(value);
@@ -4413,6 +4687,7 @@ async function writeLauncher(profile, env = process.env) {
4413
4687
  " MAGCLAW_DAEMON_PACKAGE_SPEC: packageSpec,",
4414
4688
  " MAGCLAW_DAEMON_PACKAGE_KIND: packageKind,",
4415
4689
  " MAGCLAW_DAEMON_PACKAGE_BIN: packageBin,",
4690
+ " MAGCLAW_DAEMON_BACKGROUND_SERVICE: '1',",
4416
4691
  " PATH: launchPath,",
4417
4692
  "};",
4418
4693
  "if (packageKind === 'computer') childEnv.MAGCLAW_COMPUTER_DAEMON = '1';",
@@ -4567,17 +4842,40 @@ async function startBackground(profile, env = process.env) {
4567
4842
  return { ok: false, mode: 'foreground', message: 'Background daemon is only automated on macOS launchd, Linux user systemd, and Windows schtasks.' };
4568
4843
  }
4569
4844
 
4845
+ export function parseLaunchdPrintStatus(result = {}) {
4846
+ const stdout = String(result.stdout || '');
4847
+ const stderr = String(result.stderr || '');
4848
+ const state = (stdout.match(/^\s*state\s*=\s*(.+?)\s*$/m)?.[1] || '').trim();
4849
+ const activeCountValue = Number(stdout.match(/^\s*active count\s*=\s*(\d+)\s*$/m)?.[1] || NaN);
4850
+ const stateStatus = state || (result.status === 0 ? 'loaded' : 'inactive');
4851
+ const active = result.status === 0 && (
4852
+ stateStatus.toLowerCase() === 'running'
4853
+ || (!state && Number.isFinite(activeCountValue) && activeCountValue > 0)
4854
+ );
4855
+ return {
4856
+ active,
4857
+ status: active ? 'running' : stateStatus,
4858
+ state,
4859
+ activeCount: Number.isFinite(activeCountValue) ? activeCountValue : null,
4860
+ error: result.status === 0 ? '' : String(stderr || stdout || '').trim(),
4861
+ };
4862
+ }
4863
+
4570
4864
  function backgroundServiceStatus(profile, env = process.env) {
4571
4865
  const paths = profilePaths(profile, env);
4572
4866
  if (process.platform === 'darwin') {
4573
4867
  const label = launchAgentLabel(paths.profile);
4574
4868
  const result = spawnSync('launchctl', ['print', `gui/${process.getuid()}/${label}`], { encoding: 'utf8' });
4869
+ const parsed = parseLaunchdPrintStatus(result);
4575
4870
  return {
4576
4871
  mode: 'launchd',
4577
- active: result.status === 0,
4872
+ active: parsed.active,
4578
4873
  label,
4579
4874
  file: path.join(os.homedir(), 'Library', 'LaunchAgents', `${label}.plist`),
4580
- error: result.status === 0 ? '' : String(result.stderr || result.stdout || '').trim(),
4875
+ status: parsed.status,
4876
+ state: parsed.state,
4877
+ activeCount: parsed.activeCount,
4878
+ error: parsed.error,
4581
4879
  };
4582
4880
  }
4583
4881
  if (process.platform === 'linux') {
@@ -4787,14 +5085,15 @@ async function status(profile) {
4787
5085
  };
4788
5086
  }
4789
5087
 
4790
- async function logs(profile) {
5088
+ async function logs(profile, options = {}) {
4791
5089
  const paths = profilePaths(profile);
5090
+ const lines = Math.max(1, Math.min(5000, Number(options.lines || 120) || 120));
4792
5091
  const files = [path.join(paths.logDir, 'daemon.log'), path.join(paths.logDir, 'daemon.err.log')];
4793
5092
  for (const file of files) {
4794
5093
  if (!existsSync(file)) continue;
4795
5094
  process.stdout.write(`\n==> ${file} <==\n`);
4796
5095
  const text = await readFile(file, 'utf8').catch(() => '');
4797
- process.stdout.write(text.split(/\r?\n/).slice(-120).join('\n'));
5096
+ process.stdout.write(text.split(/\r?\n/).slice(-lines).join('\n'));
4798
5097
  process.stdout.write('\n');
4799
5098
  }
4800
5099
  }
@@ -4833,6 +5132,9 @@ async function listProfiles(env = process.env) {
4833
5132
  label: service.label || '',
4834
5133
  serviceName: service.serviceName || '',
4835
5134
  taskName: service.taskName || '',
5135
+ status: service.status || '',
5136
+ state: service.state || '',
5137
+ activeCount: service.activeCount ?? null,
4836
5138
  },
4837
5139
  createdAt: config.createdAt || '',
4838
5140
  updatedAt: config.updatedAt || '',
@@ -5279,10 +5581,22 @@ async function postSetupJson(serverUrl, pathname, body = {}) {
5279
5581
  return data;
5280
5582
  }
5281
5583
 
5584
+ function hasComputerTarget(flags = {}) {
5585
+ return Boolean(flags.profileExplicit || flags.server || flags.serverSlug || flags.slug || flags._?.[1]);
5586
+ }
5587
+
5588
+ function profileFromComputerTarget(value = '') {
5589
+ return safeProfileName(normalizeSetupServerSlug(value) || value || DEFAULT_PROFILE);
5590
+ }
5591
+
5592
+ function computerTargetProfile(flags = {}, fallback = DEFAULT_PROFILE) {
5593
+ return profileFromComputerTarget(flags.server || flags.serverSlug || flags.slug || flags._?.[1] || flags.profile || fallback);
5594
+ }
5595
+
5282
5596
  async function runComputerSetup(flags, env = process.env) {
5283
5597
  const subcommand = String(flags._?.[0] || '').trim();
5284
- if (subcommand !== 'setup') {
5285
- throw new Error('Usage: magclaw computer setup /<server-slug> --server-url <url>');
5598
+ if (!['setup', 'attach', 'login'].includes(subcommand)) {
5599
+ throw new Error('Usage: magclaw-computer setup /<server-slug> --server-url <url>');
5286
5600
  }
5287
5601
  const serverSlug = normalizeSetupServerSlug(flags._?.[1] || flags.server || flags.serverSlug || flags.slug);
5288
5602
  if (!serverSlug) throw new Error('Run computer setup with a server slug, for example: magclaw computer setup /my-server');
@@ -5343,6 +5657,20 @@ async function runComputerSetup(flags, env = process.env) {
5343
5657
  await saveProfile(config.profile, config, env);
5344
5658
  await clearRemoteClosedServiceState(config.profile, env);
5345
5659
  const cli = await tryInstallCliShim(flags, env);
5660
+ if (flags.noStart || flags.noRun) {
5661
+ printJson({
5662
+ ok: true,
5663
+ started: false,
5664
+ cli,
5665
+ computerId: config.computerId,
5666
+ computerName: config.computerName || config.name || displayName,
5667
+ profile: config.profile,
5668
+ serverName: config.serverName,
5669
+ serverSlug: approved.serverSlug || serverSlug,
5670
+ next: `Run magclaw-computer start ${config.profile} when ready.`,
5671
+ });
5672
+ return;
5673
+ }
5346
5674
  const result = await startBackground(config.profile, env);
5347
5675
  printJson({
5348
5676
  ...result,
@@ -5359,6 +5687,255 @@ async function runComputerSetup(flags, env = process.env) {
5359
5687
  }
5360
5688
  }
5361
5689
 
5690
+ async function renderComputerAggregateStatus(env = process.env) {
5691
+ const report = await listProfiles(env);
5692
+ return {
5693
+ ok: true,
5694
+ root: report.root,
5695
+ loggedIn: report.profiles.some((profile) => profile.configured),
5696
+ supervisor: {
5697
+ model: 'per-profile-service',
5698
+ running: report.profiles.some((profile) => profile.running || profile.service?.active),
5699
+ managedProfiles: report.profiles.length,
5700
+ },
5701
+ profiles: report.profiles,
5702
+ };
5703
+ }
5704
+
5705
+ function formatComputerStatus(report = {}) {
5706
+ const profiles = report.profiles || [];
5707
+ if (report.profile) {
5708
+ return [
5709
+ `Profile: ${report.profile}`,
5710
+ `Configured: ${report.configured ? 'yes' : 'no'}`,
5711
+ `Daemon: ${report.running ? `running (pid ${report.pid})` : 'stopped'}`,
5712
+ `Service: ${report.service?.mode || 'foreground'}${report.service?.active ? ' active' : ''}`,
5713
+ `Server URL: ${report.serverUrl || '-'}`,
5714
+ `Computer ID: ${report.computerId || '-'}`,
5715
+ `Config: ${report.configPath}`,
5716
+ '',
5717
+ ].join('\n');
5718
+ }
5719
+ return [
5720
+ 'MagClaw Computers',
5721
+ `Profiles: ${profiles.length} Running: ${profiles.filter((profile) => profile.running || profile.service?.active).length}`,
5722
+ `Root: ${report.root || '-'}`,
5723
+ '',
5724
+ ...profiles.map((profile) => [
5725
+ `${profile.running || profile.service?.active ? 'online ' : 'offline'} ${profile.profile}`,
5726
+ ` server=${profile.serverSlug || profile.serverName || '-'} computer=${profile.computerId || '-'} service=${profile.service?.mode || 'foreground'}`,
5727
+ ].join('\n')),
5728
+ profiles.length ? '' : 'No profiles. Run `magclaw-computer setup /<serverSlug>` first.',
5729
+ '',
5730
+ ].join('\n');
5731
+ }
5732
+
5733
+ async function computerStatus(flags = {}, env = process.env) {
5734
+ if (hasComputerTarget(flags)) return status(computerTargetProfile(flags, flags.profile || DEFAULT_PROFILE));
5735
+ return renderComputerAggregateStatus(env);
5736
+ }
5737
+
5738
+ async function startAllComputerProfiles(env = process.env) {
5739
+ const report = await listProfiles(env);
5740
+ const results = [];
5741
+ for (const profile of report.profiles) {
5742
+ if (!profile.configured) continue;
5743
+ results.push({ profile: profile.profile, ...(await startSavedBackground({ profile: profile.profile }, env)) });
5744
+ }
5745
+ return { ok: results.every((item) => item.ok), count: results.length, results };
5746
+ }
5747
+
5748
+ async function stopAllComputerProfiles(flags = {}, env = process.env) {
5749
+ const report = await listProfiles(env);
5750
+ const results = [];
5751
+ for (const profile of report.profiles) {
5752
+ results.push({ profile: profile.profile, ...(await stopDaemon(profile.profile, env, { disable: Boolean(flags.disable) })) });
5753
+ }
5754
+ return { ok: results.every((item) => item.ok), count: results.length, results };
5755
+ }
5756
+
5757
+ async function detachComputerProfile(flags = {}, env = process.env) {
5758
+ const profile = computerTargetProfile(flags);
5759
+ if (!profile || profile === DEFAULT_PROFILE && !hasComputerTarget(flags)) {
5760
+ throw new Error('Usage: magclaw-computer detach <serverSlug>');
5761
+ }
5762
+ const paths = profilePaths(profile, env);
5763
+ const stopped = await uninstallBackground(profile, env);
5764
+ await rm(paths.dir, { recursive: true, force: true });
5765
+ return { ok: true, profile, detached: true, stopped };
5766
+ }
5767
+
5768
+ async function cleanupComputerResidue(env = process.env) {
5769
+ const report = await listProfiles(env);
5770
+ const cleaned = [];
5771
+ await activeComputerLock(env);
5772
+ for (const profile of report.profiles) {
5773
+ const paths = profilePaths(profile.profile, env);
5774
+ const before = existsSync(paths.lockFile);
5775
+ await activeDaemonLock(profile.profile, env);
5776
+ if (before && !existsSync(paths.lockFile)) cleaned.push(paths.lockFile);
5777
+ }
5778
+ return cleaned;
5779
+ }
5780
+
5781
+ async function computerDoctor(flags = {}, env = process.env) {
5782
+ const cleanup = Boolean(flags.cleanup || flags.fix);
5783
+ const target = hasComputerTarget(flags) ? computerTargetProfile(flags, flags.profile || DEFAULT_PROFILE) : '';
5784
+ const runtime = await doctor(env);
5785
+ const aggregate = await renderComputerAggregateStatus(env);
5786
+ const selected = target ? await status(target) : null;
5787
+ const cleaned = cleanup ? await cleanupComputerResidue(env) : [];
5788
+ const checks = [
5789
+ { name: 'MAGCLAW_DAEMON_HOME', ok: true, detail: aggregate.root },
5790
+ { name: 'profiles', ok: aggregate.profiles.length > 0, detail: `${aggregate.profiles.length} configured` },
5791
+ { name: 'runtime', ok: runtime.runtimes.some((item) => item.available), detail: runtime.runtimes.filter((item) => item.available).map((item) => item.id).join(', ') || 'none detected' },
5792
+ ];
5793
+ if (selected) {
5794
+ checks.push(
5795
+ { name: `profile ${target}`, ok: selected.configured, detail: selected.configPath },
5796
+ { name: `daemon ${target}`, ok: selected.running || selected.service?.active, detail: selected.running ? `running (pid ${selected.pid})` : (selected.service?.active ? 'service active' : 'stopped') },
5797
+ );
5798
+ }
5799
+ return {
5800
+ ok: checks.every((check) => check.ok !== false),
5801
+ checks,
5802
+ runtime,
5803
+ aggregate,
5804
+ ...(selected ? { profile: selected } : {}),
5805
+ cleanup: { requested: cleanup, staleLocksCleared: cleaned },
5806
+ };
5807
+ }
5808
+
5809
+ async function readComputerChannel(env = process.env) {
5810
+ const file = computerChannelPath(env);
5811
+ const value = existsSync(file) ? String(await readFile(file, 'utf8')).trim() : 'latest';
5812
+ return value || 'latest';
5813
+ }
5814
+
5815
+ function validateComputerChannel(value = '') {
5816
+ const channel = String(value || '').trim();
5817
+ if (channel === 'latest' || channel === 'alpha' || /^pinned:[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$/.test(channel)) return channel;
5818
+ throw new Error('Channel must be latest, alpha, or pinned:<semver>.');
5819
+ }
5820
+
5821
+ async function setComputerChannel(value, env = process.env) {
5822
+ const channel = validateComputerChannel(value);
5823
+ const file = computerChannelPath(env);
5824
+ await mkdir(path.dirname(file), { recursive: true });
5825
+ await writeFile(file, `${channel}\n`);
5826
+ return channel;
5827
+ }
5828
+
5829
+ async function computerChannel(flags = {}, env = process.env) {
5830
+ const action = String(flags._?.[1] || flags._?.[0] || '').trim();
5831
+ const value = String(flags._?.[2] || flags.channel || '').trim();
5832
+ if (action === 'set') {
5833
+ const channel = await setComputerChannel(value, env);
5834
+ return { ok: true, channel };
5835
+ }
5836
+ return { ok: true, channel: await readComputerChannel(env), file: computerChannelPath(env) };
5837
+ }
5838
+
5839
+ async function computerRunners(flags = {}, env = process.env) {
5840
+ const action = String(flags._?.[1] || 'list').trim();
5841
+ if (action === 'list') {
5842
+ const aggregate = await renderComputerAggregateStatus(env);
5843
+ return {
5844
+ ok: true,
5845
+ note: 'MagClaw local CLI can list Computer profiles. Per-agent runner stop/list remains a cloud console or agent-tool operation.',
5846
+ profiles: aggregate.profiles.map((profile) => ({
5847
+ profile: profile.profile,
5848
+ serverSlug: profile.serverSlug,
5849
+ computerId: profile.computerId,
5850
+ running: profile.running || profile.service?.active,
5851
+ })),
5852
+ };
5853
+ }
5854
+ if (action === 'stop') {
5855
+ throw new Error('Local runner stop is not available yet. Stop Agents from the MagClaw web console or agent runtime controls.');
5856
+ }
5857
+ throw new Error(`Unknown runners command: ${action}`);
5858
+ }
5859
+
5860
+ async function computerUpgrade(flags = {}, env = process.env) {
5861
+ const channel = flags.channel ? validateComputerChannel(flags.channel) : await readComputerChannel(env);
5862
+ const targetVersion = flags.targetVersion || flags.to || flags.version || (String(channel).startsWith('pinned:') ? String(channel).slice('pinned:'.length) : channel);
5863
+ await runManualUpgrade({
5864
+ ...flags,
5865
+ to: targetVersion,
5866
+ targetVersion,
5867
+ packageName: COMPUTER_PACKAGE_NAME,
5868
+ packageBin: 'magclaw-computer',
5869
+ }, {
5870
+ ...env,
5871
+ MAGCLAW_ENTRY_PACKAGE_NAME: COMPUTER_PACKAGE_NAME,
5872
+ MAGCLAW_DAEMON_PACKAGE_NAME: COMPUTER_PACKAGE_NAME,
5873
+ MAGCLAW_DAEMON_PACKAGE_KIND: 'computer',
5874
+ MAGCLAW_DAEMON_PACKAGE_BIN: 'magclaw-computer',
5875
+ });
5876
+ }
5877
+
5878
+ async function runComputerCommand(flags, env = process.env) {
5879
+ const subcommand = String(flags._?.[0] || 'help').trim();
5880
+ if (subcommand === 'help' || flags.help) {
5881
+ process.stdout.write(renderComputerHelp(subcommand === 'help' ? flags._?.[1] : subcommand));
5882
+ return;
5883
+ }
5884
+ switch (subcommand) {
5885
+ case 'login':
5886
+ case 'attach':
5887
+ case 'setup':
5888
+ await runComputerSetup(flags, env);
5889
+ break;
5890
+ case 'adopt-legacy':
5891
+ throw new Error('MagClaw legacy adoption is handled by `magclaw-computer setup /<serverSlug>` or `magclaw connect --pair-token <token>`.');
5892
+ case 'detach':
5893
+ printJson(await detachComputerProfile(flags, env));
5894
+ break;
5895
+ case 'status': {
5896
+ const report = await computerStatus(flags, env);
5897
+ if (flags.json) printJson(report);
5898
+ else process.stdout.write(formatComputerStatus(report));
5899
+ break;
5900
+ }
5901
+ case 'start':
5902
+ if (hasComputerTarget(flags)) {
5903
+ if (flags.foreground) {
5904
+ await runForegroundDaemon(await buildConfig({ ...flags, profile: computerTargetProfile(flags) }, env), env);
5905
+ } else {
5906
+ printJson(await startSavedBackground({ ...flags, profile: computerTargetProfile(flags) }, env));
5907
+ }
5908
+ } else {
5909
+ printJson(await startAllComputerProfiles(env));
5910
+ }
5911
+ break;
5912
+ case 'stop':
5913
+ if (hasComputerTarget(flags)) printJson(await stopDaemon(computerTargetProfile(flags), env, { disable: Boolean(flags.disable) }));
5914
+ else printJson(await stopAllComputerProfiles(flags, env));
5915
+ break;
5916
+ case 'doctor': {
5917
+ const report = await computerDoctor(flags, env);
5918
+ if (flags.json) printJson(report);
5919
+ else process.stdout.write(`${report.checks.map((check) => `${check.ok ? 'ok' : 'fail'} ${check.name}: ${check.detail}`).join('\n')}\n`);
5920
+ break;
5921
+ }
5922
+ case 'logs':
5923
+ await logs(computerTargetProfile(flags), { lines: flags.lines || flags.lineCount });
5924
+ break;
5925
+ case 'runners':
5926
+ printJson(await computerRunners(flags, env));
5927
+ break;
5928
+ case 'channel':
5929
+ printJson(await computerChannel(flags, env));
5930
+ break;
5931
+ case 'upgrade':
5932
+ await computerUpgrade(flags, env);
5933
+ break;
5934
+ default:
5935
+ throw new Error(`Unknown computer command: ${subcommand}`);
5936
+ }
5937
+ }
5938
+
5362
5939
  async function buildConfig(flags, env = process.env) {
5363
5940
  const diskConfig = await readProfile(flags.profile, env);
5364
5941
  const profile = flags.profile || diskConfig.profile || DEFAULT_PROFILE;
@@ -5381,12 +5958,12 @@ async function buildConfig(flags, env = process.env) {
5381
5958
 
5382
5959
  async function runForegroundDaemon(config, env = process.env) {
5383
5960
  const releaseLock = await acquireDaemonLock(config.profile, config, env);
5384
- await markForegroundServiceState(config.profile, env);
5961
+ await markDaemonRunServiceState(config.profile, env);
5385
5962
  const daemon = new MagClawDaemon(config, env);
5386
5963
  let forceExitTimer = null;
5387
5964
  const shutdown = (signal) => {
5388
5965
  process.exitCode = signal === 'SIGINT' ? 130 : 143;
5389
- daemon.close();
5966
+ daemon.close({ reason: signal === 'SIGINT' ? 'sigint' : 'sigterm' });
5390
5967
  forceExitTimer ||= setTimeout(() => process.exit(process.exitCode || 1), 5000);
5391
5968
  forceExitTimer.unref?.();
5392
5969
  };
@@ -5458,6 +6035,14 @@ function requireExplicitProfile(command, flags = {}) {
5458
6035
 
5459
6036
  export async function main(argv = process.argv, env = process.env) {
5460
6037
  const { command, flags } = parseCli(argv);
6038
+ if (flags.version) {
6039
+ process.stdout.write(`${DAEMON_VERSION}\n`);
6040
+ return;
6041
+ }
6042
+ if (command === 'computer' && flags.help) {
6043
+ process.stdout.write(renderComputerHelp(flags._?.[0] || ''));
6044
+ return;
6045
+ }
5461
6046
  if (command === 'help' || flags.help) {
5462
6047
  process.stdout.write(renderHelp());
5463
6048
  return;
@@ -5467,7 +6052,7 @@ export async function main(argv = process.argv, env = process.env) {
5467
6052
  await runConnect(flags, env);
5468
6053
  break;
5469
6054
  case 'computer':
5470
- await runComputerSetup(flags, env);
6055
+ await runComputerCommand(flags, env);
5471
6056
  break;
5472
6057
  case 'start': {
5473
6058
  printJson(await startSavedBackground(flags, env));
@@ -57,7 +57,8 @@ function statusLabel(profile, color) {
57
57
 
58
58
  function serviceLabel(profile) {
59
59
  const mode = fallback(profile.service?.mode, 'foreground');
60
- return profile.service?.active ? `${mode}: active` : `${mode}: inactive`;
60
+ const status = fallback(profile.service?.status || (profile.service?.active ? 'active' : 'inactive'), 'inactive');
61
+ return `${mode}: ${status}`;
61
62
  }
62
63
 
63
64
  export function shouldUseColor({ env = process.env, stream = process.stdout, flags = {} } = {}) {