@platless/axom-cli 0.5.9 → 0.6.36

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 (163) hide show
  1. package/README.md +18 -5
  2. package/dist/axom-patch/cli/README.md +100 -12
  3. package/dist/axom-patch/cli/account-limits/generate.py +15 -12
  4. package/dist/axom-patch/cli/account-limits/kimi-proxy.mjs +20 -3
  5. package/dist/axom-patch/cli/claude-account-limits.json +9 -9
  6. package/dist/axom-patch/cli/codex-account-limits.json +11 -11
  7. package/dist/axom-patch/cli/codex-terminal-output.json +16 -0
  8. package/dist/axom-patch/cli/commandcode-account-limits.json +9 -9
  9. package/dist/axom-patch/cli/kimi-quota.mjs +12 -0
  10. package/dist/axom-patch/cli/kimi-subagents.mjs +173 -0
  11. package/dist/axom-patch/cli/nightly-task.md +55 -13
  12. package/dist/axom-patch/cli/probes/codex-terminal-output.mjs +33 -0
  13. package/dist/axom-patch/cli/probes/kimi-subagent-subwork.mjs +82 -0
  14. package/dist/axom-patch/cli/qoder/dist/axom-acp-compat.mjs +1591 -0
  15. package/dist/axom-patch/cli/qoder/releases/1.1.75.json +360 -0
  16. package/dist/axom-patch/cli/qoder-background-terminal.md +13 -6
  17. package/dist/axom-patch/cli/qoder-foreground-output.md +14 -8
  18. package/dist/axom-patch/cli/qoder-native-acp.json +227 -0
  19. package/dist/axom-patch/cli/qoder-steering.md +20 -25
  20. package/dist/axom-patch/cli/qoder-subagent-subwork.md +17 -68
  21. package/dist/axom-patch/cli/runtime.json +16 -18
  22. package/dist/axom-patch/cli/zcode-account-limits.json +10 -11
  23. package/dist/axom-patch/cli/zcode-models.json +1 -2
  24. package/dist/axom-patch/cli/zcode-subagent-subwork.json +9 -10
  25. package/dist/cli.js +4 -36
  26. package/dist/commands/knowledge.js +47 -0
  27. package/dist/commands/update.js +65 -3
  28. package/dist/commands/workstation.js +73 -7
  29. package/dist/install/claude/commands.js +13 -13
  30. package/dist/mcp.js +1 -1
  31. package/dist/native/darwin-arm64/axom-quic +0 -0
  32. package/dist/workstation/acp/acpAgents.js +102 -98
  33. package/dist/workstation/acp/acpClient.js +26 -0
  34. package/dist/workstation/acp/builds/install/npm.js +99 -10
  35. package/dist/workstation/acp/initializeResponse.js +6 -1
  36. package/dist/workstation/acp/managedPatches.js +189 -25
  37. package/dist/workstation/acp/managedRuntime.js +314 -0
  38. package/dist/workstation/acp/patchAssets.js +40 -0
  39. package/dist/workstation/acp/processor/checkpoint.js +25 -34
  40. package/dist/workstation/acp/processor/control.js +5 -78
  41. package/dist/workstation/acp/processor/runtime.js +5 -81
  42. package/dist/workstation/acp/processor/session.js +51 -80
  43. package/dist/workstation/acp/processor/state.js +6 -13
  44. package/dist/workstation/acp/processor/step.js +3 -20
  45. package/dist/workstation/acp/processor/test/fixtureLog.js +9 -16
  46. package/dist/workstation/acp/processor/updates.js +131 -105
  47. package/dist/workstation/acp/processorSession.js +8 -172
  48. package/dist/workstation/acp/registry/catalog.js +65 -39
  49. package/dist/workstation/acp/registry/launch.js +16 -1
  50. package/dist/workstation/acp/switching.js +0 -3
  51. package/dist/workstation/acp/test/fakeAgent.js +13 -0
  52. package/dist/workstation/acp/test/keeperWorld.js +28 -0
  53. package/dist/workstation/acp/test/raw/sources.js +16 -130
  54. package/dist/workstation/acp/test/realAdapter.js +1 -47
  55. package/dist/workstation/acp/test/scriptAgent.js +11 -3
  56. package/dist/workstation/agentFlow.js +1 -1
  57. package/dist/workstation/agentSessions.js +38 -143
  58. package/dist/workstation/channels/management.js +95 -11
  59. package/dist/workstation/channels/url.js +1 -1
  60. package/dist/workstation/config.js +16 -4
  61. package/dist/workstation/connection/encryptedChannel.js +4 -1
  62. package/dist/workstation/control/bindingStore.js +200 -0
  63. package/dist/workstation/control/commands.js +124 -0
  64. package/dist/workstation/control/daemonIdentity.js +53 -0
  65. package/dist/workstation/control/enrollment.js +106 -0
  66. package/dist/workstation/control/inspect.js +71 -0
  67. package/dist/workstation/control/locks.js +63 -0
  68. package/dist/workstation/control/protocol.js +40 -0
  69. package/dist/workstation/control/restartGuard.js +46 -0
  70. package/dist/workstation/control/runtimeClient.js +97 -0
  71. package/dist/workstation/control/runtimeHost.js +148 -0
  72. package/dist/workstation/control/serverIdentity.js +35 -0
  73. package/dist/workstation/daemon.js +106 -6
  74. package/dist/workstation/daemonctl.js +77 -14
  75. package/dist/workstation/desktopControl/audio.js +99 -0
  76. package/dist/workstation/desktopControl/capabilities.js +92 -0
  77. package/dist/workstation/desktopControl/captureModes.js +9 -0
  78. package/dist/workstation/desktopControl/captureTargetLabel.js +9 -0
  79. package/dist/workstation/desktopControl/deviceSockets.js +246 -0
  80. package/dist/workstation/desktopControl/helperProcess.js +353 -0
  81. package/dist/workstation/desktopControl/lobby.js +129 -0
  82. package/dist/workstation/desktopControl/macProvider.js +155 -0
  83. package/dist/workstation/desktopControl/media.js +331 -0
  84. package/dist/workstation/desktopControl/nativeProvider.js +1018 -0
  85. package/dist/workstation/desktopControl/networkEvidence.js +43 -0
  86. package/dist/workstation/desktopControl/overview.js +58 -0
  87. package/dist/workstation/desktopControl/protocol.js +302 -0
  88. package/dist/workstation/desktopControl/quicMedia.js +240 -0
  89. package/dist/workstation/desktopControl/rateController.js +226 -0
  90. package/dist/workstation/desktopControl/supervisor.js +1681 -0
  91. package/dist/workstation/desktopControl/transport.js +27 -0
  92. package/dist/workstation/desktopControl/wallpaper.js +78 -0
  93. package/dist/workstation/files/credentials.js +142 -0
  94. package/dist/workstation/files/text.js +42 -8
  95. package/dist/workstation/keepers/kit/bundle.js +21 -17
  96. package/dist/workstation/keepers/kit/launcher.js +47 -7
  97. package/dist/workstation/keepers/kit/paths.js +2 -1
  98. package/dist/workstation/managedInstall.js +57 -0
  99. package/dist/workstation/protocol/agent.js +2 -15
  100. package/dist/workstation/protocol/management.js +93 -4
  101. package/dist/workstation/protocol/runtimeDiagnostics.js +15 -0
  102. package/dist/workstation/rd/artifact.js +119 -0
  103. package/dist/workstation/rd/control.js +119 -0
  104. package/dist/workstation/rd/display.js +29 -0
  105. package/dist/workstation/rd/provider.js +222 -0
  106. package/dist/workstation/rd/sessions.js +391 -0
  107. package/dist/workstation/rd/state.js +45 -0
  108. package/dist/workstation/rd/update.js +171 -0
  109. package/dist/workstation/rd/upkeep.js +38 -0
  110. package/dist/workstation/register.js +27 -37
  111. package/dist/workstation/service/installer.js +104 -13
  112. package/dist/workstation/service/launchd.js +1 -0
  113. package/dist/workstation/storageMetrics.js +118 -0
  114. package/dist/workstation/systemMetrics.js +44 -92
  115. package/dist/workstation/toolchain.js +94 -0
  116. package/dist/workstation/tools/browser.js +28 -4
  117. package/dist/workstation/update/artifact.js +2 -1
  118. package/dist/workstation/update/globalInstall.js +116 -0
  119. package/dist/workstation/update/npmInstaller.js +49 -23
  120. package/dist/workstation/update/rdRollback.js +145 -0
  121. package/dist/workstation/update/runUpdate.js +3 -2
  122. package/dist/workstation/update/watchdog.js +16790 -334
  123. package/package.json +5 -5
  124. package/skills/axom/SKILL.md +11 -11
  125. package/skills/axom-definition/SKILL.md +3 -3
  126. package/skills/axom-platform/SKILL.md +3 -3
  127. package/dist/axom-patch/cli/probes/qoder-steering.mjs +0 -89
  128. package/dist/axom-patch/cli/probes/qoder-subagent-origin.mjs +0 -61
  129. package/dist/axom-patch/cli/qoder-steering.json +0 -52
  130. package/dist/axom-patch/cli/qoder-subagent-origin.json +0 -73
  131. package/dist/commands/guide.js +0 -38
  132. package/dist/runtime-deps/qoder-sdk/package-lock.json +0 -1189
  133. package/dist/runtime-deps/qoder-sdk/package.json +0 -8
  134. package/dist/workstation/acp/delegationMcp.js +0 -24
  135. package/dist/workstation/acp/processor/sideChannel.js +0 -80
  136. package/dist/workstation/acp/qoderStartup.js +0 -45
  137. package/dist/workstation/acp/test/raw/qoderCaptures.js +0 -125
  138. package/dist/workstation/qoderAdapter/backgroundProcess.js +0 -118
  139. package/dist/workstation/qoderAdapter/backgroundTerminal.js +0 -193
  140. package/dist/workstation/qoderAdapter/backgroundTerminalFold.js +0 -372
  141. package/dist/workstation/qoderAdapter/callbackContext.js +0 -76
  142. package/dist/workstation/qoderAdapter/completedContinuation.js +0 -139
  143. package/dist/workstation/qoderAdapter/contract.js +0 -78
  144. package/dist/workstation/qoderAdapter/foregroundOutput.js +0 -36
  145. package/dist/workstation/qoderAdapter/history.js +0 -85
  146. package/dist/workstation/qoderAdapter/identityIndex.js +0 -191
  147. package/dist/workstation/qoderAdapter/inputQueue.js +0 -42
  148. package/dist/workstation/qoderAdapter/main.js +0 -95
  149. package/dist/workstation/qoderAdapter/mapper.js +0 -117
  150. package/dist/workstation/qoderAdapter/observerPlugin.js +0 -245
  151. package/dist/workstation/qoderAdapter/observerReceiver.js +0 -187
  152. package/dist/workstation/qoderAdapter/permissions.js +0 -61
  153. package/dist/workstation/qoderAdapter/profile.js +0 -93
  154. package/dist/workstation/qoderAdapter/sdkDriver.js +0 -48
  155. package/dist/workstation/qoderAdapter/sdkRuntime.js +0 -93
  156. package/dist/workstation/qoderAdapter/sdkRuntimeInstall.js +0 -145
  157. package/dist/workstation/qoderAdapter/session.js +0 -319
  158. package/dist/workstation/qoderAdapter/subagentMeta.js +0 -28
  159. package/dist/workstation/qoderAdapter/subwork.js +0 -85
  160. package/dist/workstation/qoderAdapter/taskControl.js +0 -38
  161. package/dist/workstation/qoderAdapter/taskLedger.js +0 -163
  162. package/dist/workstation/qoderAdapter/usage.js +0 -37
  163. /package/dist/workstation/{qoderAdapter/types.js → desktopControl/provider.js} +0 -0
@@ -1,46 +1,36 @@
1
- import { UsageError } from '../config.js';
2
- import { clientDir, loadClientConfig, saveClientConfig, writeKeys } from './config.js';
1
+ import { randomUUID } from 'node:crypto';
2
+ import { clientDir } from './config.js';
3
3
  import { osInfo } from './osinfo.js';
4
- export async function registerWorkstation(server, clientName, token, ws) {
5
- const dir = clientDir();
6
- const existing = await loadClientConfig(dir).catch(() => undefined);
7
- const body = {
8
- name: clientName,
9
- ...(existing?.server === server ? { workstationId: existing.workstationId } : {}),
10
- os: osInfo(),
11
- defaultWorkspace: { name: ws.name, cwd: ws.cwd, shell: ws.shell },
12
- };
4
+ import { workstationEndpoint } from './control/serverIdentity.js';
5
+ import { LocalControlError } from './control/protocol.js';
6
+ import { z } from 'zod';
7
+ export const registerResponseSchema = z.object({ workstationId: z.string().min(1), ownerUserId: z.string().min(1).optional(), clientKey: z.string(), serverPub: z.string() });
8
+ /** No persistence; callers own the operation lock and identity transaction. */
9
+ export async function requestWorkstationRegistration(server, clientName, token, ws, workstationId, shell = ws?.shell ?? '/bin/sh') {
13
10
  let res;
14
11
  try {
15
- res = await fetch(new URL('/workstation/register', server), {
16
- method: 'POST',
12
+ res = await fetch(workstationEndpoint(server, '/workstation/register'), { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(30_000),
17
13
  headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
18
- body: JSON.stringify(body),
14
+ body: JSON.stringify({ name: clientName, ...(workstationId ? { workstationId } : {}), os: osInfo(), shell, ...(ws ? { defaultWorkspace: ws } : {}) }),
19
15
  });
20
16
  }
21
- catch (err) {
22
- throw new Error(`Failed to reach ${server}/workstation/register: ${err instanceof Error ? err.message : err}`);
17
+ catch {
18
+ throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration was sent but its outcome could not be confirmed. Check your workstation list before trying again.');
19
+ }
20
+ if (res.status === 409)
21
+ throw new LocalControlError('BINDING_CONFLICT', 'A workstation with this name is already registered. Check Settings → Workstations.');
22
+ if (res.status === 401 || res.status === 403)
23
+ throw new LocalControlError('AUTHORIZATION_REQUIRED', 'Workstation permission is required. Run `axom auth` and select workstation, then try again.');
24
+ if (!res.ok)
25
+ throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration could not be confirmed. Check your workstation list.');
26
+ try {
27
+ return registerResponseSchema.parse(await res.json());
23
28
  }
24
- if (!res.ok) {
25
- // A workstation with this name is already registered for this account — most commonly because
26
- // this same machine registered before an axom-cli upgrade and no longer has a local config at
27
- // the new config-directory path (see the client's move from ~/.axom-workstation to
28
- // ~/.axom/workstation). Rebinding the existing row silently would hand this machine's identity
29
- // to whichever caller re-registers next, so the server refuses instead of upserting.
30
- if (res.status === 409) {
31
- throw new UsageError(`A workstation named '${clientName}' is already registered for this account. This is expected `
32
- + `when the same machine re-registers after an axom-cli upgrade. Delete it under Settings → `
33
- + 'Workstations, then run `axom workstation start` again.');
34
- }
35
- if (res.status === 403) {
36
- throw new UsageError('This credential is not authorized to register a workstation. '
37
- + 'Run `axom auth` and select workstation, then try again.');
38
- }
39
- throw new Error(`Registration failed: ${res.status} ${await res.text()}`);
29
+ catch {
30
+ throw new LocalControlError('OUTCOME_UNKNOWN', 'Registration response was incomplete. Check your workstation list.');
40
31
  }
41
- const data = (await res.json());
42
- if (!data.workstationId)
43
- throw new Error('Registration response missing workstationId');
44
- await saveClientConfig(dir, { server, workstationId: data.workstationId, workspaces: [{ name: ws.name, cwd: ws.cwd }] });
45
- await writeKeys(dir, data.clientKey, data.serverPub);
32
+ }
33
+ export async function registerWorkstation(server, clientName, token, ws) {
34
+ const { enrollLegacyWorkstation } = await import('./control/enrollment.js');
35
+ await enrollLegacyWorkstation({ server, name: clientName, accessToken: token, workspace: ws, operationId: randomUUID() }, clientDir());
46
36
  }
@@ -58,6 +58,22 @@ async function assertWritable(directory) {
58
58
  }
59
59
  const defaultDeps = {
60
60
  run: runProcess,
61
+ readLaunchAgent: async (file) => {
62
+ const handle = await fs.open(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
63
+ try {
64
+ const stat = await handle.stat();
65
+ if (!stat.isFile() || stat.size > 65536)
66
+ throw new Error('Invalid LaunchAgent');
67
+ }
68
+ finally {
69
+ await handle.close();
70
+ }
71
+ const result = await runProcess('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file]);
72
+ const value = JSON.parse(result.stdout);
73
+ if (!value || typeof value !== 'object' || Array.isArray(value))
74
+ throw new Error('Invalid LaunchAgent');
75
+ return value;
76
+ },
61
77
  writeTemp,
62
78
  assertWritable,
63
79
  ensureDirectory: async (directory) => { await fs.mkdir(directory, { recursive: true }); },
@@ -219,24 +235,38 @@ export function createServiceManager(context, overrides = {}) {
219
235
  // durable LaunchAgent plist. This keeps explicit stop meaningful across both service managers.
220
236
  if (action === 'stop')
221
237
  return deps.run('launchctl', ['bootout', launchdDomain]);
222
- if (action === 'restart') {
223
- try {
224
- await deps.run('launchctl', ['bootout', launchdDomain]);
225
- }
226
- catch (error) {
227
- // `restart` after an explicit `stop` is a valid request: absence means there is nothing
228
- // left to unload, and launchdStart will bootstrap the durable plist.
229
- if (!launchdAbsent(error))
230
- throw error;
238
+ return launchdStart();
239
+ };
240
+ const enabled = async (plistDisabled = false) => {
241
+ if (context.platform === 'darwin') {
242
+ const result = await deps.run('launchctl', ['print-disabled', `gui/${context.userId}`]);
243
+ if (!result.stdout.includes('{') || !result.stdout.includes('}'))
244
+ throw new Error('Cannot determine launchd enablement');
245
+ const line = result.stdout.split('\n').find(line => line.includes(`"${SERVICE_LABEL}"`));
246
+ if (line) {
247
+ const override = /=>\s*(true|false|enabled|disabled)\b/.exec(line);
248
+ if (!override)
249
+ throw new Error('Cannot determine launchd enablement');
250
+ return override[1] === 'true' || override[1] === 'disabled' ? false : true;
231
251
  }
232
- return launchdStart();
252
+ return !plistDisabled;
253
+ }
254
+ try {
255
+ return (await deps.run('systemctl', ['--user', 'is-enabled', `${SERVICE_LABEL}.service`])).stdout.trim() === 'enabled';
256
+ }
257
+ catch (error) {
258
+ if (/disabled|not-found/.test(String(error)))
259
+ return false;
260
+ throw error;
233
261
  }
234
- return launchdStart();
235
262
  };
236
263
  // Extracted so `start()` can reuse it: reinstalling is the same work whether triggered
237
264
  // explicitly (`axom workstation install`) or as `start()`'s self-heal for a missing definition
238
265
  // file. Both platform branches leave the service running, so callers need no separate start.
239
266
  const performInstall = async (_options = {}) => {
267
+ const present = await deps.pathExists(target);
268
+ const plistDisabled = context.platform === 'darwin' && present && (await deps.readLaunchAgent(target)).Disabled === true;
269
+ const preserveDisabled = (context.platform === 'darwin' || present) && !await enabled(plistDisabled);
240
270
  await deps.assertWritable(context.npmPrefix);
241
271
  await deps.ensureDirectory(path.dirname(target));
242
272
  const temp = await deps.writeTemp(contents);
@@ -246,7 +276,8 @@ export function createServiceManager(context, overrides = {}) {
246
276
  await deps.run('mv', [targetTemp, target]);
247
277
  if (context.platform === 'linux') {
248
278
  await deps.run('systemctl', ['--user', 'daemon-reload']);
249
- await deps.run('systemctl', ['--user', 'enable', '--now', `${SERVICE_LABEL}.service`]);
279
+ if (!preserveDisabled)
280
+ await deps.run('systemctl', ['--user', 'enable', '--now', `${SERVICE_LABEL}.service`]);
250
281
  }
251
282
  else {
252
283
  // Reload an existing service before bootstrapping the replacement into the GUI domain.
@@ -255,7 +286,8 @@ export function createServiceManager(context, overrides = {}) {
255
286
  // bounded probe/bootstrap path so Bootstrap failed: 5 is confirmed then kickstarted, and
256
287
  // so a launchd that never releases the job is reported instead of leaving the machine
257
288
  // with the bootout applied and nothing bootstrapped in its place.
258
- await launchdStart();
289
+ if (!preserveDisabled)
290
+ await launchdStart();
259
291
  }
260
292
  }
261
293
  finally {
@@ -303,6 +335,11 @@ export function createServiceManager(context, overrides = {}) {
303
335
  */
304
336
  async start() {
305
337
  await withGuiSessionHint(async () => {
338
+ // An explicit Start is consent to re-enable; install/update alone preserves OFF.
339
+ if (context.platform === 'darwin')
340
+ await deps.run('launchctl', ['enable', launchdDomain]);
341
+ else
342
+ await deps.run('systemctl', ['--user', 'enable', `${SERVICE_LABEL}.service`]);
306
343
  if (!(await deps.pathExists(target)))
307
344
  await performInstall({});
308
345
  await lifecycle('start');
@@ -310,6 +347,60 @@ export function createServiceManager(context, overrides = {}) {
310
347
  },
311
348
  async stop() { await withGuiSessionHint(() => lifecycle('stop')); },
312
349
  async restart() { await withGuiSessionHint(() => lifecycle('restart')); },
350
+ async setEnabled(value) {
351
+ await withGuiSessionHint(async () => {
352
+ if (context.platform === 'darwin') {
353
+ await deps.run('launchctl', [value ? 'enable' : 'disable', launchdDomain]);
354
+ if (value) {
355
+ if (!await deps.pathExists(target))
356
+ await performInstall({});
357
+ await launchdStart();
358
+ }
359
+ else {
360
+ try {
361
+ await lifecycle('stop');
362
+ }
363
+ catch (error) {
364
+ if (!launchdAbsent(error))
365
+ throw error;
366
+ }
367
+ }
368
+ }
369
+ else
370
+ await deps.run('systemctl', ['--user', value ? 'enable' : 'disable', '--now', `${SERVICE_LABEL}.service`]);
371
+ });
372
+ },
373
+ async enablement() {
374
+ const installed = await deps.pathExists(target);
375
+ if (!installed)
376
+ return { installed: false, enabled: false, loaded: false, running: false };
377
+ let value;
378
+ try {
379
+ let disabled = false;
380
+ if (context.platform === 'darwin') {
381
+ const plist = await deps.readLaunchAgent(target);
382
+ const args = plist.ProgramArguments;
383
+ const environment = plist.EnvironmentVariables;
384
+ if (plist.Label !== SERVICE_LABEL || !Array.isArray(args) || args.length !== 3 || args[0] !== context.nodePath || args[1] !== context.cliPath || args[2] !== '__daemon' || environment?.AXOM_WORKSTATION_CONFIG_DIR !== context.configDir) {
385
+ return { installed, enabled: null, loaded: false, running: false, reason: 'service_identity_mismatch' };
386
+ }
387
+ disabled = plist.Disabled === true;
388
+ }
389
+ value = await enabled(disabled);
390
+ }
391
+ catch {
392
+ return { installed, enabled: null, loaded: false, running: false, reason: 'service_state_unknown' };
393
+ }
394
+ try {
395
+ const result = await lifecycle('status');
396
+ return { installed, enabled: value, loaded: true, running: context.platform === 'darwin' ? launchdIsRunning(result.stdout) : true };
397
+ }
398
+ catch (error) {
399
+ if (launchdAbsent(error) || /inactive|not loaded|not-found/.test(String(error)))
400
+ return { installed, enabled: value, loaded: false, running: false };
401
+ return { installed, enabled: value, loaded: false, running: false, reason: 'runtime_state_unknown' };
402
+ }
403
+ },
313
404
  async status() {
314
405
  try {
315
406
  const result = await lifecycle('status');
@@ -31,6 +31,7 @@ ${keyValue('AXOM_WORKSTATION_CONFIG_DIR', context.configDir)}
31
31
  ${keyValue('AXOM_WORKSTATION_NPM_PATH', context.npmPath)}
32
32
  ${keyValue('AXOM_WORKSTATION_NPM_PREFIX', context.npmPrefix)}
33
33
  ${keyValue('PATH', context.pathEnv)}
34
+ ${context.npmConfigPrefix ? keyValue('NPM_CONFIG_PREFIX', context.npmConfigPrefix) : ''}
34
35
  \t</dict>
35
36
  \t<key>StandardOutPath</key>
36
37
  \t<string>${xml(`${context.configDir}/daemon.log`)}</string>
@@ -0,0 +1,118 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { statSync } from 'node:fs';
3
+ import path from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ const execFileAsync = promisify(execFile);
6
+ const LOCAL_FILESYSTEMS = new Set([
7
+ 'ext2', 'ext3', 'ext4', 'xfs', 'btrfs', 'zfs', 'f2fs', 'bcachefs',
8
+ 'vfat', 'exfat', 'ntfs', 'ntfs3', 'fuseblk',
9
+ ]);
10
+ export function linuxStoragePools(mounts) {
11
+ const pools = new Map();
12
+ for (const mount of mounts) {
13
+ if (!LOCAL_FILESYSTEMS.has(mount.fstype) || mount.options?.split(',').includes('ro'))
14
+ continue;
15
+ if (/^\/(?:boot|efi)(?:\/|$)/.test(mount.target))
16
+ continue;
17
+ const source = mount.source.split('[')[0];
18
+ const key = mount.fstype === 'zfs'
19
+ ? `zfs:${source.split('/')[0]}`
20
+ : mount.uuid ? `${mount.fstype}:${mount.uuid}` : `dev:${mount['maj:min']}`;
21
+ const existing = pools.get(key);
22
+ if (existing)
23
+ existing.paths.push(mount.target);
24
+ else
25
+ pools.set(key, { key, label: mount.label || source, paths: [mount.target] });
26
+ }
27
+ if (pools.size === 0) {
28
+ const root = mounts.find((mount) => mount.target === '/' && mount.fstype === 'overlay');
29
+ if (root)
30
+ pools.set('overlay:/', { key: 'overlay:/', label: 'Container storage', paths: ['/'] });
31
+ }
32
+ return [...pools.values()];
33
+ }
34
+ export function darwinStoragePools(disks, physicalDisks, containers) {
35
+ const physical = new Set(physicalDisks.flatMap((disk) => [disk.DeviceIdentifier, ...(disk.Partitions ?? []).map((part) => part.DeviceIdentifier)]));
36
+ const volumes = disks.flatMap((disk) => [disk, ...(disk.Partitions ?? []), ...(disk.APFSVolumes ?? [])]);
37
+ const pools = [];
38
+ for (const container of containers) {
39
+ if (!container.PhysicalStores.some((store) => physical.has(store.DeviceIdentifier)))
40
+ continue;
41
+ const userVolumes = container.Volumes.filter((volume) => volume.Roles.length === 0 || volume.Roles.includes('Data'));
42
+ userVolumes.sort((first, second) => Number(second.Roles.includes('Data')) - Number(first.Roles.includes('Data')));
43
+ const mounted = userVolumes.flatMap((volume) => {
44
+ const disk = volumes.find((candidate) => candidate.DeviceIdentifier === volume.DeviceIdentifier);
45
+ return disk?.MountPoint ? [{ name: volume.Name, mount: disk.MountPoint }] : [];
46
+ });
47
+ if (mounted.length === 0)
48
+ continue;
49
+ const system = container.Volumes.find((volume) => volume.Roles.includes('System'));
50
+ pools.push({
51
+ key: `apfs:${container.APFSContainerUUID}`,
52
+ label: system?.Name || mounted[0].name,
53
+ paths: mounted.map((volume) => volume.mount),
54
+ });
55
+ }
56
+ for (const volume of volumes) {
57
+ if (!physical.has(volume.DeviceIdentifier) || !volume.MountPoint)
58
+ continue;
59
+ if (!['Apple_HFS', 'Microsoft Basic Data', 'Windows_NTFS', 'DOS_FAT_32', 'DOS_FAT_16', 'ExFAT'].includes(volume.Content ?? ''))
60
+ continue;
61
+ pools.push({
62
+ key: `volume:${volume.VolumeUUID || volume.DiskUUID || volume.DeviceIdentifier}`,
63
+ label: volume.VolumeName || volume.MountPoint,
64
+ paths: [volume.MountPoint],
65
+ });
66
+ }
67
+ return pools;
68
+ }
69
+ async function diskutilJson(args) {
70
+ const { stdout } = await execFileAsync('/usr/sbin/diskutil', args, {
71
+ timeout: 5_000, maxBuffer: 4 * 1024 * 1024,
72
+ });
73
+ const json = await new Promise((resolve, reject) => {
74
+ const child = execFile('/usr/bin/plutil', ['-convert', 'json', '-o', '-', '-'], {
75
+ timeout: 2_000, maxBuffer: 4 * 1024 * 1024,
76
+ }, (error, output) => error ? reject(error) : resolve(output));
77
+ child.stdin?.on('error', reject);
78
+ child.stdin?.end(stdout);
79
+ });
80
+ return JSON.parse(json);
81
+ }
82
+ export async function discoverStoragePools(platform, workspaces) {
83
+ if (platform === 'linux') {
84
+ const { stdout } = await execFileAsync('findmnt', [
85
+ '--json', '--list', '--output', 'TARGET,SOURCE,FSTYPE,UUID,LABEL,MAJ:MIN,OPTIONS',
86
+ ], { timeout: 5_000, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, LC_ALL: 'C' } });
87
+ const mounts = JSON.parse(stdout).filesystems;
88
+ return linuxStoragePools(mounts.filter((mount) => {
89
+ try {
90
+ return statSync(mount.target).isDirectory();
91
+ }
92
+ catch {
93
+ return false;
94
+ }
95
+ }));
96
+ }
97
+ if (platform === 'darwin') {
98
+ const [all, physical, apfs] = await Promise.all([
99
+ diskutilJson(['list', '-plist']),
100
+ diskutilJson(['list', '-plist', 'physical']),
101
+ diskutilJson(['apfs', 'list', '-plist']),
102
+ ]);
103
+ return darwinStoragePools(all.AllDisksAndPartitions, physical.AllDisksAndPartitions, apfs.Containers ?? []);
104
+ }
105
+ const pools = new Map();
106
+ for (const { cwd } of workspaces) {
107
+ try {
108
+ const key = `dev:${statSync(cwd).dev}`;
109
+ const existing = pools.get(key);
110
+ if (existing)
111
+ existing.paths.push(cwd);
112
+ else
113
+ pools.set(key, { key, label: path.parse(cwd).root, paths: [cwd] });
114
+ }
115
+ catch { }
116
+ }
117
+ return [...pools.values()];
118
+ }
@@ -2,8 +2,8 @@ import { execFile } from 'node:child_process';
2
2
  import { createHash } from 'node:crypto';
3
3
  import { readFileSync, statfsSync, statSync } from 'node:fs';
4
4
  import os from 'node:os';
5
- import path from 'node:path';
6
5
  import { promisify } from 'node:util';
6
+ import { discoverStoragePools } from './storageMetrics.js';
7
7
  const execFileAsync = promisify(execFile);
8
8
  export const STORAGE_TOPOLOGY_REFRESH_MS = 10 * 60 * 1000;
9
9
  function cpuTotals(cpus) {
@@ -36,45 +36,6 @@ function linuxMemoryPressure(readFile) {
36
36
  function stablePoolId(key) {
37
37
  return createHash('sha256').update(key).digest('hex').slice(0, 16);
38
38
  }
39
- async function defaultIdentifyStorage(platform, cwd, device) {
40
- try {
41
- if (platform === 'darwin') {
42
- const mounted = await execFileAsync('/bin/df', ['-P', cwd], {
43
- timeout: 1_000,
44
- env: { ...process.env, LC_ALL: 'C' },
45
- });
46
- const mountFields = mounted.stdout.trim().split('\n').at(-1)?.trim().split(/\s+/) ?? [];
47
- const mountPoint = mountFields.slice(5).join(' ') || path.parse(cwd).root;
48
- const { stdout } = await execFileAsync('/usr/sbin/diskutil', ['info', mountPoint], {
49
- timeout: 1_000,
50
- env: { ...process.env, LC_ALL: 'C' },
51
- });
52
- const container = /^\s*APFS Container Reference:\s*(\S+)\s*$/m.exec(stdout)?.[1];
53
- const volume = /^\s*Volume Name:\s*(.+?)\s*$/m.exec(stdout)?.[1];
54
- return container
55
- ? { key: `apfs:${container}`, label: `APFS Container ${container}` }
56
- : { key: `dev:${device}`, label: volume };
57
- }
58
- if (platform === 'linux') {
59
- const { stdout } = await execFileAsync('findmnt', ['-n', '-P', '-o', 'FSTYPE,SOURCE,UUID,LABEL,TARGET', '--target', cwd], {
60
- timeout: 2_000,
61
- env: { ...process.env, LC_ALL: 'C' },
62
- });
63
- const fields = Object.fromEntries([...stdout.matchAll(/(\w+)="([^"]*)"/g)].map((match) => [match[1], match[2]]));
64
- if (fields.FSTYPE === 'btrfs' && fields.UUID)
65
- return { key: `btrfs:${fields.UUID}`, label: fields.SOURCE };
66
- if (fields.FSTYPE === 'zfs' && fields.SOURCE) {
67
- const pool = fields.SOURCE.split('/')[0];
68
- return { key: `zfs:${pool}`, label: pool };
69
- }
70
- return { key: `dev:${device}`, label: fields.LABEL || fields.TARGET || fields.SOURCE };
71
- }
72
- }
73
- catch {
74
- // Resource-pool recognition is best-effort. Device identity below is the conservative fallback.
75
- }
76
- return { key: `dev:${device}` };
77
- }
78
39
  async function defaultDarwinAvailableMemory() {
79
40
  try {
80
41
  const { stdout } = await execFileAsync('/usr/bin/vm_stat', [], { timeout: 2_000 });
@@ -216,7 +177,7 @@ export class SystemMetricsSampler {
216
177
  readFile: readFileSync,
217
178
  statfs: statfsSync,
218
179
  stat: statSync,
219
- identifyStorage: (cwd, device) => defaultIdentifyStorage(process.platform, cwd, device),
180
+ discoverStorage: () => discoverStoragePools(process.platform, workspaces),
220
181
  darwinAvailableMemory: defaultDarwinAvailableMemory,
221
182
  darwinMemoryPressure: defaultDarwinMemoryPressure,
222
183
  probeGpus: () => defaultProbeGpus(process.platform),
@@ -261,20 +222,23 @@ export class SystemMetricsSampler {
261
222
  : (this.deps.platform === 'darwin' ? this.darwinMemoryPressure : null);
262
223
  const pools = new Map();
263
224
  for (const pool of this.storage) {
264
- try {
265
- const stats = this.deps.statfs(pool.cwd);
266
- const totalBytes = Number(stats.blocks) * Number(stats.bsize);
267
- const availableBytes = Number(stats.bavail) * Number(stats.bsize);
268
- pools.set(pool.key, {
269
- id: stablePoolId(pool.key),
270
- label: pool.label,
271
- workspaces: pool.workspaces,
272
- totalBytes,
273
- availableBytes,
274
- });
275
- }
276
- catch {
277
- // A removed/unmounted workspace must not suppress CPU and memory metrics.
225
+ for (const cwd of pool.paths) {
226
+ try {
227
+ if (String(this.deps.stat(cwd).dev) !== pool.devices[cwd])
228
+ continue;
229
+ const stats = this.deps.statfs(cwd);
230
+ const totalBytes = Number(stats.blocks) * Number(stats.bsize);
231
+ const availableBytes = Number(stats.bavail) * Number(stats.bsize);
232
+ pools.set(pool.key, {
233
+ id: stablePoolId(pool.key),
234
+ label: pool.label,
235
+ workspaces: pool.workspaces,
236
+ totalBytes,
237
+ availableBytes,
238
+ });
239
+ break;
240
+ }
241
+ catch { }
278
242
  }
279
243
  }
280
244
  return {
@@ -338,43 +302,31 @@ export class SystemMetricsSampler {
338
302
  this.darwinMemoryPressure = pressure;
339
303
  }
340
304
  async refreshTopology() {
341
- const candidates = this.workspaces.map(({ name, cwd }) => {
342
- try {
343
- this.deps.statfs(cwd);
344
- const device = String(this.deps.stat(cwd).dev);
345
- return { name, cwd, device };
346
- }
347
- catch {
348
- return null;
349
- }
350
- }).filter((candidate) => candidate !== null);
351
- const representatives = new Map();
352
- candidates.forEach(({ cwd, device }) => {
353
- if (!representatives.has(device))
354
- representatives.set(device, { cwd, device });
355
- });
356
- const identities = new Map(await Promise.all([...representatives.values()].map(async ({ cwd, device }) => [
357
- device,
358
- await this.deps.identifyStorage(cwd, device).catch(() => null),
359
- ])));
360
- const identified = candidates.map(({ name, cwd, device }) => {
361
- try {
362
- const identity = identities.get(device);
363
- const key = identity?.key ?? `dev:${device}`;
364
- return { name, cwd, key, label: identity?.label || path.parse(cwd).root || cwd };
365
- }
366
- catch {
367
- return null;
368
- }
369
- });
370
- const grouped = new Map();
371
- for (const entry of identified.filter((candidate) => candidate !== null)) {
372
- const existing = grouped.get(entry.key);
373
- if (existing)
374
- existing.workspaces.push(entry.name);
375
- else
376
- grouped.set(entry.key, { cwd: entry.cwd, key: entry.key, label: entry.label, workspaces: [entry.name] });
305
+ try {
306
+ const pools = await this.deps.discoverStorage();
307
+ this.storage = pools.map((pool) => {
308
+ const devices = Object.fromEntries(pool.paths.flatMap((cwd) => {
309
+ try {
310
+ return [[cwd, String(this.deps.stat(cwd).dev)]];
311
+ }
312
+ catch {
313
+ return [];
314
+ }
315
+ }));
316
+ const poolDevices = new Set(Object.values(devices));
317
+ const workspaces = this.workspaces.filter(({ cwd }) => {
318
+ try {
319
+ return poolDevices.has(String(this.deps.stat(cwd).dev));
320
+ }
321
+ catch {
322
+ return false;
323
+ }
324
+ }).map(({ name }) => name);
325
+ return { ...pool, workspaces, devices };
326
+ });
327
+ }
328
+ catch {
329
+ return;
377
330
  }
378
- this.storage = [...grouped.values()];
379
331
  }
380
332
  }
@@ -0,0 +1,94 @@
1
+ import { execFile } from 'node:child_process';
2
+ import path from 'node:path';
3
+ const PROBE_TIMEOUT_MS = 10_000;
4
+ const VERIFY_TIMEOUT_MS = 3_000;
5
+ /** Interpreters rarely appear or vanish; a slow refresh keeps the heartbeat itself free of any spawning. */
6
+ export const TOOLCHAIN_REFRESH_MS = 15 * 60_000;
7
+ /**
8
+ * Shells whose `-lc` runs a POSIX-ish command string with `command -v` (fish ≥ 3 included). Any other shell (csh, tcsh,
9
+ * nu, pwsh, …) is not probed: its answer is unknown, never "missing".
10
+ */
11
+ const PROBED_SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh', 'mksh', 'fish']);
12
+ /** Separates the probe's answers; no leading `=` (zsh would expand a bare `=word`). */
13
+ const SPLIT = 'AXOM-PROBE-SPLIT';
14
+ const DONE = 'AXOM-PROBE-DONE';
15
+ const NAMES = ['python3', 'python', 'node'];
16
+ /**
17
+ * One `<shell> -lc` run of `command -v` for each interpreter, the way commands run on this workstation (exec spawns the
18
+ * same login shell), so a Python found only through the user's profile counts. Each answer is the last absolute-path
19
+ * line of its segment (a login profile may print its own lines first). Null = unknown: a shell we do not probe, a shell
20
+ * that could not run, or a run that did not reach its end marker.
21
+ */
22
+ export async function probeToolchain(shell, env = process.env, options = {}) {
23
+ if (!PROBED_SHELLS.has(path.basename(shell)))
24
+ return null;
25
+ const script = [...NAMES.map((name) => `command -v ${name}`), `echo ${DONE}`].join(`; echo ${SPLIT}; `);
26
+ const stdout = await run(shell, ['-lc', script], env, PROBE_TIMEOUT_MS);
27
+ if (stdout === undefined)
28
+ return null;
29
+ const lines = stdout.split('\n').map((line) => line.trim());
30
+ const doneAt = lines.lastIndexOf(DONE);
31
+ if (doneAt < 0)
32
+ return null;
33
+ const segments = [[]];
34
+ for (const line of lines.slice(0, doneAt)) {
35
+ if (line === SPLIT)
36
+ segments.push([]);
37
+ else
38
+ segments[segments.length - 1].push(line);
39
+ }
40
+ // `command -v python3; echo SPLIT; …; echo SPLIT; echo DONE`: one segment per name, then an empty one. A login
41
+ // profile's own output lands in the first segment, before the answer, so each answer is its segment's last path.
42
+ if (segments.length !== NAMES.length + 1)
43
+ return null;
44
+ const answers = segments.slice(0, NAMES.length);
45
+ const found = (index) => [...answers[index]].reverse().find((line) => path.isAbsolute(line)) ?? null;
46
+ let python3 = found(0);
47
+ if (python3 === '/usr/bin/python3' && (options.platform ?? process.platform) === 'darwin') {
48
+ if (!(await (options.macStubRuns ?? macCommandLineToolsInstalled)()))
49
+ python3 = null;
50
+ }
51
+ return { python: python3 ?? found(1), node: found(2) };
52
+ }
53
+ async function macCommandLineToolsInstalled() {
54
+ return (await run('/usr/bin/xcode-select', ['-p'], process.env, VERIFY_TIMEOUT_MS, true)) !== undefined;
55
+ }
56
+ /** The command's stdout; undefined = it did not run, timed out, or (with [strict]) exited non-zero. */
57
+ function run(file, args, env, timeout, strict = false) {
58
+ return new Promise((resolve) => {
59
+ execFile(file, args, { env, timeout, windowsHide: true }, (error, stdout) => {
60
+ const code = error?.code;
61
+ if (error && (strict || typeof code !== 'number')) {
62
+ resolve(undefined);
63
+ return;
64
+ }
65
+ resolve(String(stdout));
66
+ });
67
+ });
68
+ }
69
+ /** Holds the latest probe; the first answer arrives shortly after start, until then the heartbeat simply omits it. */
70
+ export class ToolchainProbe {
71
+ shell;
72
+ env;
73
+ latest = null;
74
+ timer;
75
+ constructor(shell, env = () => process.env) {
76
+ this.shell = shell;
77
+ this.env = env;
78
+ }
79
+ current() { return this.latest; }
80
+ start() {
81
+ if (process.platform === 'win32' || this.timer)
82
+ return;
83
+ const refresh = async () => {
84
+ const found = await probeToolchain(this.shell(), this.env()).catch(() => null);
85
+ if (found)
86
+ this.latest = found;
87
+ };
88
+ void refresh();
89
+ this.timer = setInterval(() => { void refresh(); }, TOOLCHAIN_REFRESH_MS);
90
+ this.timer.unref();
91
+ }
92
+ stop() { if (this.timer)
93
+ clearInterval(this.timer); this.timer = undefined; }
94
+ }