@platless/axom-cli 0.1.0

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 (90) hide show
  1. package/README.md +64 -0
  2. package/dist/args.js +29 -0
  3. package/dist/cli.js +76 -0
  4. package/dist/commands/auth.js +52 -0
  5. package/dist/commands/execute.js +40 -0
  6. package/dist/commands/guide.js +38 -0
  7. package/dist/commands/record.js +99 -0
  8. package/dist/commands/skills.js +69 -0
  9. package/dist/commands/struct.js +50 -0
  10. package/dist/commands/update.js +88 -0
  11. package/dist/commands/whoami.js +8 -0
  12. package/dist/commands/workstation.js +171 -0
  13. package/dist/config.js +131 -0
  14. package/dist/credentialBroker.js +340 -0
  15. package/dist/credentials.js +211 -0
  16. package/dist/install/agentsDir.js +35 -0
  17. package/dist/install/claude/commands.js +89 -0
  18. package/dist/install/claude/materialize.js +42 -0
  19. package/dist/install/installer.js +45 -0
  20. package/dist/install/targets/claude.js +154 -0
  21. package/dist/mcp.js +152 -0
  22. package/dist/output.js +37 -0
  23. package/dist/workstation/acp/acpAgents.js +1287 -0
  24. package/dist/workstation/acp/acpClient.js +691 -0
  25. package/dist/workstation/acp/acpSession.js +1047 -0
  26. package/dist/workstation/acp/adoption.js +396 -0
  27. package/dist/workstation/acp/auth.js +80 -0
  28. package/dist/workstation/acp/authUrlSniffer.js +52 -0
  29. package/dist/workstation/acp/binaryInstall.js +411 -0
  30. package/dist/workstation/acp/configOptions.js +257 -0
  31. package/dist/workstation/acp/contract.js +494 -0
  32. package/dist/workstation/acp/draftInterceptor.js +154 -0
  33. package/dist/workstation/acp/draftSubagents.js +38 -0
  34. package/dist/workstation/acp/installErrors.js +89 -0
  35. package/dist/workstation/acp/installProbe.js +363 -0
  36. package/dist/workstation/acp/permission.js +327 -0
  37. package/dist/workstation/acp/registry/catalog.js +29 -0
  38. package/dist/workstation/acp/registry/launch.js +128 -0
  39. package/dist/workstation/acp/registry/registryClient.js +160 -0
  40. package/dist/workstation/acp/registry/registryTypes.js +8 -0
  41. package/dist/workstation/acp/sessionUpdateMapper.js +602 -0
  42. package/dist/workstation/acp/test/fakeAgent.js +435 -0
  43. package/dist/workstation/acp/test/sessionUpdateFixtures.js +165 -0
  44. package/dist/workstation/acp/timedCommand.js +47 -0
  45. package/dist/workstation/acp/uvxInstall.js +248 -0
  46. package/dist/workstation/acp/validate.js +134 -0
  47. package/dist/workstation/agentSessions.js +1628 -0
  48. package/dist/workstation/channels/agent.js +96 -0
  49. package/dist/workstation/channels/computer.js +91 -0
  50. package/dist/workstation/channels/management.js +231 -0
  51. package/dist/workstation/channels/url.js +42 -0
  52. package/dist/workstation/config.js +47 -0
  53. package/dist/workstation/connection/encryptedChannel.js +261 -0
  54. package/dist/workstation/connection/retryPolicy.js +14 -0
  55. package/dist/workstation/connection/supervisor.js +353 -0
  56. package/dist/workstation/crypto.js +63 -0
  57. package/dist/workstation/daemon.js +225 -0
  58. package/dist/workstation/daemonctl.js +95 -0
  59. package/dist/workstation/exec.js +112 -0
  60. package/dist/workstation/journal.js +257 -0
  61. package/dist/workstation/names.js +17 -0
  62. package/dist/workstation/oauth/browser.js +24 -0
  63. package/dist/workstation/oauth/deviceAuthorization.js +198 -0
  64. package/dist/workstation/osinfo.js +4 -0
  65. package/dist/workstation/protocol/agent.js +606 -0
  66. package/dist/workstation/protocol/common.js +136 -0
  67. package/dist/workstation/protocol/computer.js +31 -0
  68. package/dist/workstation/protocol/management.js +116 -0
  69. package/dist/workstation/register.js +42 -0
  70. package/dist/workstation/service/installer.js +362 -0
  71. package/dist/workstation/service/launchd.js +46 -0
  72. package/dist/workstation/service/loginShell.js +36 -0
  73. package/dist/workstation/service/systemd.js +48 -0
  74. package/dist/workstation/service/types.js +73 -0
  75. package/dist/workstation/supervision.js +52 -0
  76. package/dist/workstation/systemMetrics.js +271 -0
  77. package/dist/workstation/update/artifact.js +61 -0
  78. package/dist/workstation/update/coordinator.js +240 -0
  79. package/dist/workstation/update/npmInstaller.js +91 -0
  80. package/dist/workstation/update/redact.js +36 -0
  81. package/dist/workstation/update/runUpdate.js +215 -0
  82. package/dist/workstation/update/stateStore.js +87 -0
  83. package/dist/workstation/update/taskRegistry.js +142 -0
  84. package/dist/workstation/update/types.js +3 -0
  85. package/dist/workstation/update/watchdog.js +311 -0
  86. package/dist/workstation/version.js +12 -0
  87. package/package.json +39 -0
  88. package/skills/axom/SKILL.md +83 -0
  89. package/skills/axom-definition/SKILL.md +74 -0
  90. package/skills/axom-platform/SKILL.md +78 -0
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Absolute paths, as they appear inside an error message.
3
+ *
4
+ * Two things the previous pattern got wrong. It matched every non-space, non-colon character, so
5
+ * `open '/a/b/c'` lost its closing quote along with the path and the operator saw a message that
6
+ * ended mid-quote. And it had no left boundary, so the `/b` inside a relative `a/b` was redacted as
7
+ * though it were absolute -- the lookbehind requires the slash to follow something that cannot be
8
+ * part of a path segment.
9
+ */
10
+ const ABSOLUTE_PATH = /(?<![\w.~-])(?:\/[^\s:'"()[\]]+)+\/?/g;
11
+ /**
12
+ * Replaces the directories of an absolute path with `[path]`, keeping the final segment.
13
+ *
14
+ * Redaction is here so a workstation's status field cannot publish the daemon's filesystem layout
15
+ * to the server. Replacing the *whole* path also removes the one thing that makes such a message
16
+ * actionable: on 2026-09-06 a container with a deleted `/data/ws` mount reported
17
+ * `ENOENT: no such file or directory, open '[path]` for every failed action, which named neither
18
+ * the file nor anything that pointed at the mount. `[path]/.update-state.json...tmp` would have.
19
+ *
20
+ * The file name is the diagnostic; the directories are the layout. This keeps the first and drops
21
+ * the second.
22
+ */
23
+ export function redactPaths(value) {
24
+ return value.replace(ABSOLUTE_PATH, (match) => {
25
+ if (match.endsWith('/'))
26
+ return '[path]/';
27
+ const name = match.slice(match.lastIndexOf('/') + 1);
28
+ return name ? `[path]/${name}` : '[path]';
29
+ });
30
+ }
31
+ /** Credentials that must never reach a status field, whatever else a message carries. */
32
+ export function redactSecrets(value) {
33
+ return value
34
+ .replace(/https?:\/\/[^\s/@]+:[^\s/@]+@[^\s/]+[^\s]*/gi, '[redacted-url]')
35
+ .replace(/(?:token|password|secret)=\S+/gi, '$1=[redacted]');
36
+ }
@@ -0,0 +1,215 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, openSync } from 'node:fs';
3
+ import { downloadVerified } from './artifact.js';
4
+ import { UpdateCoordinator } from './coordinator.js';
5
+ import { NpmInstaller } from './npmInstaller.js';
6
+ import { UpdateStateStore } from './stateStore.js';
7
+ import { TaskRegistry } from './taskRegistry.js';
8
+ import { LastKnownGoodCache, SERVICE_UNREGISTERED_REMEDIATION, writeReadinessMarker } from './watchdog.js';
9
+ import { updateServiceContext } from '../config.js';
10
+ import { CLIENT_VERSION } from '../version.js';
11
+ /**
12
+ * The argv `provisionService` spawns the installed CLI with. Exported so a test can spawn the
13
+ * same command out-of-process and prove it resolves against the unified CLI's actual command
14
+ * surface, instead of only ever seeing it through a mock.
15
+ */
16
+ export const PROVISION_SERVICE_ARGV = ['workstation', 'install'];
17
+ /**
18
+ * How long the update waits for the provisioning child to say whether the OS service is back.
19
+ *
20
+ * The one hard bound: it must exceed the child's own worst case — `axom workstation install`
21
+ * retries a still-transitioning launchd job for the whole of installer.ts's
22
+ * `LAUNCHD_TRANSITION_BACKOFF_MS` schedule (23.75s) plus the `bootout`/`print`/`kickstart`
23
+ * subprocesses around it. A shorter window records "did not confirm" — and rolls the machine
24
+ * back — while the child is seconds from succeeding, which is exactly how two real machines were
25
+ * stranded: this window was 15s, the child's budget 3.25s, and both numbers sat *below* launchd's
26
+ * 20s SIGTERM-to-SIGKILL grace that the transition actually takes.
27
+ *
28
+ * That grace is no longer an upper bound here, and being SIGKILLed mid-wait is the *designed*
29
+ * outcome of the slow-success path, not a failure: on macOS the child's `bootout` SIGTERMs this
30
+ * very process, launchd keeps the job in transition while we linger observing the child, and
31
+ * kills us at 20s — which is the moment the transition ends and the child's next attempt can
32
+ * bootstrap the replacement. Dying here records nothing, deliberately: the state is already the
33
+ * durably persisted RESTARTING, the new daemon's authenticated Ready proves success, and the
34
+ * armed watchdog's 120s readiness deadline owns the failure side. What this window still catches
35
+ * is the child failing outright (fast non-zero exit — recorded within seconds) or hanging without
36
+ * ever having booted us out.
37
+ */
38
+ export const PROVISION_CONFIRM_TIMEOUT_MS = 30_000;
39
+ /**
40
+ * Appended to every provisioning failure, so the one line a person reads in `daemon.log` — and the
41
+ * `error` the server shows on the failed update — names the command that repairs the machine.
42
+ *
43
+ * This is not decoration. When provisioning fails after `install` has already booted the old job
44
+ * out, the end state is *worse* than a rolled-back update: the service is gone from launchd
45
+ * entirely, so nothing will ever restart the workstation on its own.
46
+ *
47
+ * The wording lives in `watchdog.ts` because the rollback path reaches the same end state from the
48
+ * other side and has to say the same thing. Kept as an alias rather than folded into its call sites
49
+ * so this file's own tests and readers keep the name that describes *when* it is used.
50
+ */
51
+ export const PROVISION_REMEDIATION = SERVICE_UNREGISTERED_REMEDIATION;
52
+ /**
53
+ * Resolves only when the provisioning child has *proved* it re-provisioned the service, rejects
54
+ * otherwise — including when it simply has not answered in time.
55
+ *
56
+ * The child stays `detached` and is deliberately not killed on timeout: it may well be seconds away
57
+ * from succeeding, and killing it mid-`bootstrap` is how a machine loses its service definition.
58
+ * What is bounded is only how long the *update* is willing to be held open by it. An unconfirmed
59
+ * outcome is reported as a failure rather than as success: on the real machines this fix comes
60
+ * from, "we never looked" was rendered as SUCCEEDED while the workstation was offline and
61
+ * unregistered, and a false alarm costs an idempotent `axom workstation install` while a false
62
+ * SUCCEEDED costs a machine nobody knows is broken.
63
+ */
64
+ export function awaitProvisionOutcome(child, timeoutMs = PROVISION_CONFIRM_TIMEOUT_MS) {
65
+ return new Promise((resolve, reject) => {
66
+ let settled = false;
67
+ const settle = (error) => {
68
+ if (settled)
69
+ return;
70
+ settled = true;
71
+ clearTimeout(timer);
72
+ child.removeListener('exit', onExit);
73
+ child.removeListener('error', onError);
74
+ if (error)
75
+ reject(error);
76
+ else
77
+ resolve();
78
+ };
79
+ const onExit = (code, signal) => {
80
+ if (code === 0)
81
+ return settle();
82
+ settle(new Error(`service re-provisioning ${signal ? `was killed by ${signal}` : `exited with code ${code ?? 'unknown'}`}`
83
+ + ` (see daemon.log for what \`axom ${PROVISION_SERVICE_ARGV.join(' ')}\` reported). ${PROVISION_REMEDIATION}`));
84
+ };
85
+ const onError = (error) => {
86
+ settle(new Error(`service re-provisioning could not be started: ${error.message}. ${PROVISION_REMEDIATION}`));
87
+ };
88
+ const timer = setTimeout(() => {
89
+ // Stop holding this process's event loop open for a child we are no longer waiting on.
90
+ child.unref();
91
+ settle(new Error(`service re-provisioning did not confirm within ${timeoutMs}ms. ${PROVISION_REMEDIATION}`));
92
+ }, timeoutMs);
93
+ child.once('exit', onExit);
94
+ child.once('error', onError);
95
+ });
96
+ }
97
+ /**
98
+ * What each `serviceLifecycle` permits, computed once so `createUpdateCoordinator` has a single
99
+ * place deciding it and a test can pin the whole table without reaching into
100
+ * `UpdateCoordinator`'s private options.
101
+ */
102
+ export function lifecyclePolicy(lifecycle) {
103
+ return {
104
+ provisions: lifecycle === 'provision-and-arm',
105
+ rollsBack: lifecycle === 'provision-and-arm',
106
+ cachesRelease: lifecycle === 'provision-and-arm',
107
+ reconcilesOnBoot: lifecycle === 'container',
108
+ restartsOnHandoff: lifecycle !== 'none',
109
+ logsPhases: lifecycle === 'container',
110
+ };
111
+ }
112
+ /**
113
+ * Assembles the one `UpdateCoordinator` every caller runs — the management-channel handler
114
+ * (daemon.ts), the bare `axom workstation` foreground daemon, and the manual `axom update`
115
+ * command alike — same npm installer, same last-known-good rollback cache, same on-disk state
116
+ * file. There is deliberately no second implementation of this assembly.
117
+ *
118
+ * Callers differ only in `serviceLifecycle`: `'provision-and-arm'` for the OS service manager's own
119
+ * supervised process, `'container'` for a detected Docker `ENTRYPOINT`, and `'none'` for everything
120
+ * else — which includes both the one-shot manual `axom update` command and a long-lived foreground
121
+ * daemon that is neither service-supervised nor a container (see the field's doc for why `'none'`
122
+ * does no provisioning, no rollback and no boot reconciliation).
123
+ */
124
+ export function createUpdateCoordinator(deps) {
125
+ const policy = lifecyclePolicy(deps.serviceLifecycle);
126
+ const updateContext = updateServiceContext(deps.dir);
127
+ const npm = new NpmInstaller(updateContext);
128
+ // This cache also resumes the restart handshake after service supervision starts a new daemon.
129
+ const releaseCache = new LastKnownGoodCache({
130
+ ...updateContext, runningVersion: CLIENT_VERSION, pack: (root, destination) => npm.pack(root, destination),
131
+ download: (release) => downloadVerified(release, { server: deps.server }),
132
+ // The same `cli.js` `provisionService` below spawns. It is staged into the watchdog's plan so a
133
+ // rollback can re-provision with it; the watchdog runs from a copy outside the package and has
134
+ // no way to find it otherwise.
135
+ cliPath: deps.cliPath,
136
+ });
137
+ return new UpdateCoordinator({
138
+ tasks: deps.tasks ?? new TaskRegistry(), installer: npm, state: new UpdateStateStore(deps.dir),
139
+ drainTimeoutMs: deps.drainTimeoutMs,
140
+ // Without an artifact source the coordinator cannot fetch the release at all: every update and
141
+ // reinstall fails at DOWNLOADING with "requires the configured server URL". The artifact is
142
+ // served by the same authenticated server this call is already connected to — which is why
143
+ // the rollback cache constructed just above could download while the update itself could not.
144
+ artifact: { server: deps.server },
145
+ // Only armed for the caller that declared 'provision-and-arm' — see UpdateCoordinatorDeps.
146
+ rollback: policy.rollsBack ? releaseCache : undefined,
147
+ releaseCache: policy.cachesRelease ? releaseCache : undefined,
148
+ readiness: { clientVersion: CLIENT_VERSION, write: async (record) => { await writeReadinessMarker(deps.dir, record); } },
149
+ /**
150
+ * Re-writes the service definition from the package that was just installed.
151
+ *
152
+ * Has to be a detached child rather than an in-process call: `install()` boots the service out
153
+ * of launchd's domain before bootstrapping the replacement, and this process *is* that
154
+ * service — an in-process call would die at the bootout and never reach the bootstrap, leaving
155
+ * the daemon down. The child outlives us on purpose, and re-running the same hardened
156
+ * `workstation install` path a person would run keeps one implementation of the lifecycle
157
+ * races.
158
+ *
159
+ * On macOS this is also the only thing that makes a changed definition take effect at all: a
160
+ * bootstrapped job keeps the environment it was loaded with, so writing the plist and exiting
161
+ * would restart the daemon with the *old* environment until the next login.
162
+ *
163
+ * stdio is redirected to the same `daemon.log` the service definition itself points
164
+ * `StandardOutPath`/`StandardErrorPath` at — not discarded — so an `unknown command` or any
165
+ * other failure from this child leaves a trace instead of vanishing silently.
166
+ *
167
+ * Detached, but **awaited** for a bounded window (see {@link PROVISION_CONFIRM_TIMEOUT_MS}).
168
+ * The original `spawn(); unref(); resolve()` made provisioning failure structurally
169
+ * unobservable — the promise could not reject, so `onProvisionFailure` was dead code and a
170
+ * machine whose service had just been booted out and never bootstrapped reported SUCCEEDED.
171
+ * Not waiting also *caused* that report: this process exited immediately afterwards, launchd's
172
+ * KeepAlive started a replacement daemon, whose heartbeat of the persisted RESTARTING is what
173
+ * the server promotes to SUCCEEDED — and only then did the child boot that replacement out.
174
+ * Waiting first removes the race as well as the blindness.
175
+ *
176
+ * Only wired up for the caller that declared 'provision-and-arm'. On the manual path a service
177
+ * definition that needs to change is left for the next server-driven update to rewrite.
178
+ */
179
+ provisionService: policy.provisions ? async () => {
180
+ const log = openSync(`${deps.dir}/daemon.log`, 'a');
181
+ let child;
182
+ try {
183
+ child = spawn(process.execPath, [deps.cliPath, ...PROVISION_SERVICE_ARGV], {
184
+ detached: true,
185
+ stdio: ['ignore', log, log],
186
+ });
187
+ }
188
+ finally {
189
+ closeSync(log);
190
+ }
191
+ await awaitProvisionOutcome(child);
192
+ } : undefined,
193
+ /**
194
+ * Now genuinely reachable (it never was before), and the only place this message reaches a
195
+ * person on the machine: `console.error` lands in the same `daemon.log` the plist points at.
196
+ * The coordinator additionally fails the update, which is how the server gets to see it.
197
+ */
198
+ onProvisionFailure: policy.provisions ? (error) => {
199
+ console.error(`service re-provisioning failed: ${error instanceof Error ? error.message : String(error)}`);
200
+ } : undefined,
201
+ // Gated here, alongside the other three policy fields, rather than at daemon.ts's call site:
202
+ // `runDaemon` passes `() => process.exit(0)` for every lifecycle, and a caller that has not
203
+ // thought about which mode it is in must not be able to exit a daemon nothing will restart.
204
+ restart: policy.restartsOnHandoff ? deps.restart : undefined,
205
+ // Only the container lifecycle needs this — see reconcilesOnBoot's doc above and
206
+ // UpdateCoordinatorOptions.reconcileRestartOnBoot in coordinator.ts.
207
+ reconcileRestartOnBoot: policy.reconcilesOnBoot ? { runningVersion: CLIENT_VERSION } : undefined,
208
+ // See logsPhases' doc: in a container this is the only trace an update leaves outside the
209
+ // volume. `version` is on every phase but the initial SUCCEEDED, and `error` only on FAILED.
210
+ onPhase: policy.logsPhases ? (status) => {
211
+ const detail = [status.version && `version ${status.version}`, status.error].filter(Boolean).join(': ');
212
+ console.log(`[update] ${status.phase}${detail ? ` (${detail})` : ''}`);
213
+ } : undefined,
214
+ });
215
+ }
@@ -0,0 +1,87 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ const STATE_FILE = 'update-state.json';
4
+ const PHASES = new Set([
5
+ 'REQUESTED', 'DRAINING', 'BUSY', 'DOWNLOADING', 'VERIFYING', 'INSTALLING', 'RESTARTING',
6
+ 'SUCCEEDED', 'FAILED', 'ROLLED_BACK', 'UPDATE_IN_PROGRESS',
7
+ ]);
8
+ function isUpdateStatus(value) {
9
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
10
+ return false;
11
+ const status = value;
12
+ if (typeof status.phase !== 'string' || !PHASES.has(status.phase))
13
+ return false;
14
+ for (const field of ['commandId', 'version', 'releaseSha512', 'error'])
15
+ if (status[field] !== undefined && typeof status[field] !== 'string')
16
+ return false;
17
+ for (const field of ['force'])
18
+ if (status[field] !== undefined && typeof status[field] !== 'boolean')
19
+ return false;
20
+ for (const field of ['activeShellTasks', 'activeAgentTasks'])
21
+ if (status[field] !== undefined && (!Number.isSafeInteger(status[field]) || status[field] < 0))
22
+ return false;
23
+ return true;
24
+ }
25
+ /** Persists only non-secret update progress. Atomic rename makes a crash recoverable. */
26
+ export class UpdateStateStore {
27
+ directory;
28
+ path;
29
+ constructor(directory) {
30
+ this.directory = directory;
31
+ this.path = path.join(directory, STATE_FILE);
32
+ }
33
+ async load() {
34
+ try {
35
+ const parsed = JSON.parse(await fs.readFile(this.path, 'utf8'));
36
+ return isUpdateStatus(parsed) ? parsed : null;
37
+ }
38
+ catch (error) {
39
+ if (error.code === 'ENOENT')
40
+ return null;
41
+ // A partial/corrupt state must not make the daemon unavailable. The former command can be
42
+ // retried; a later watchdog task owns recovery for an already-installed package.
43
+ return null;
44
+ }
45
+ }
46
+ /**
47
+ * Proves the state directory can actually be written, and leaves nothing behind.
48
+ *
49
+ * A real create-and-delete, not `stat` or `access`: a directory can resolve and still refuse
50
+ * `open`. That is precisely what a bind-mount source deleted on the host does -- the container
51
+ * keeps resolving the mount to an inode with `nlink=0`, so `ls` succeeds, `stat` succeeds, and
52
+ * every write returns ENOENT. On 2026-09-06 a workstation in that state reported ONLINE with all
53
+ * three channels connected while it had not persisted a byte for hours.
54
+ *
55
+ * Throws with the directory named, because the errno alone ("ENOENT ... open") tells an operator
56
+ * nothing about which directory has gone.
57
+ */
58
+ async assertWritable() {
59
+ const probe = path.join(this.directory, `.writable-probe.${process.pid}.${Date.now()}`);
60
+ try {
61
+ await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
62
+ await fs.writeFile(probe, '', { encoding: 'utf8', mode: 0o600, flag: 'wx' });
63
+ }
64
+ catch (error) {
65
+ const code = error.code ?? 'unknown error';
66
+ throw new Error(`workstation state directory is not writable: ${this.directory} (${code})`);
67
+ }
68
+ finally {
69
+ await fs.rm(probe, { force: true }).catch(() => undefined);
70
+ }
71
+ }
72
+ async save(status) {
73
+ await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
74
+ const temporary = path.join(this.directory, `.${STATE_FILE}.${process.pid}.${Date.now()}.tmp`);
75
+ try {
76
+ await fs.writeFile(temporary, `${JSON.stringify(status)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
77
+ await fs.chmod(temporary, 0o600);
78
+ await fs.rename(temporary, this.path);
79
+ // Existing state files might have been created by an older client under a permissive umask.
80
+ await fs.chmod(this.path, 0o600);
81
+ }
82
+ catch (error) {
83
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
84
+ throw error;
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Owns the process-lifetime update gate. A graceful update may reopen it after a BUSY timeout; a
3
+ * forced update closes it permanently because this daemon is about to be replaced.
4
+ */
5
+ export class TaskRegistry {
6
+ /** Every running shell's abort handle plus the promise that says its finally cleanup has run. */
7
+ shellTasks = new Map();
8
+ agentTasks = new Set();
9
+ agentOpenReservations = new Map();
10
+ agentSessions = new Map();
11
+ idleWaiters = new Set();
12
+ stopping = null;
13
+ accepting = true;
14
+ snapshot() {
15
+ const activeAgentRefs = new Set(this.agentTasks);
16
+ for (const sessionRef of this.agentOpenReservations.keys())
17
+ activeAgentRefs.add(sessionRef);
18
+ return { activeShellTasks: this.shellTasks.size, activeAgentTasks: activeAgentRefs.size };
19
+ }
20
+ canUpdate() {
21
+ const snapshot = this.snapshot();
22
+ return this.accepting && snapshot.activeShellTasks === 0 && snapshot.activeAgentTasks === 0;
23
+ }
24
+ /** Count-only drain predicate: unlike canUpdate(), closing admission does not make this false. */
25
+ isIdle() {
26
+ const snapshot = this.snapshot();
27
+ return snapshot.activeShellTasks === 0 && snapshot.activeAgentTasks === 0;
28
+ }
29
+ /** Waits until both active counts are zero, or returns false at the bounded drain deadline. */
30
+ waitForIdle(timeoutMs, signal) {
31
+ if (this.isIdle())
32
+ return Promise.resolve(true);
33
+ if (signal?.aborted)
34
+ return Promise.resolve(false);
35
+ return new Promise((resolve) => {
36
+ let timer;
37
+ const finish = (idle) => {
38
+ if (timer === undefined)
39
+ return;
40
+ clearTimeout(timer);
41
+ timer = undefined;
42
+ this.idleWaiters.delete(onIdle);
43
+ signal?.removeEventListener('abort', onAbort);
44
+ resolve(idle);
45
+ };
46
+ const onIdle = () => finish(true);
47
+ const onAbort = () => finish(false);
48
+ this.idleWaiters.add(onIdle);
49
+ signal?.addEventListener('abort', onAbort, { once: true });
50
+ timer = setTimeout(() => finish(false), timeoutMs);
51
+ });
52
+ }
53
+ notifyIfIdle() {
54
+ if (!this.isIdle())
55
+ return;
56
+ for (const waiter of [...this.idleWaiters])
57
+ waiter();
58
+ }
59
+ /** Run one shell task under an abort signal which force-update owns. */
60
+ async withShellTask(run) {
61
+ if (!this.accepting)
62
+ throw new Error('Workstation is not accepting new work');
63
+ const controller = new AbortController();
64
+ let complete;
65
+ const cleanedUp = new Promise((resolve) => { complete = resolve; });
66
+ this.shellTasks.set(controller, cleanedUp);
67
+ try {
68
+ return await run(controller.signal);
69
+ }
70
+ finally {
71
+ this.shellTasks.delete(controller);
72
+ complete();
73
+ this.notifyIfIdle();
74
+ }
75
+ }
76
+ /** Register a live native agent for force-stop; idle sessions deliberately do not count as busy. */
77
+ registerAgentSession(sessionRef, close) {
78
+ this.agentSessions.set(sessionRef, close);
79
+ }
80
+ /** Atomically admits and counts an asynchronous open before any journal or subprocess await. */
81
+ tryReserveAgentOpen(sessionRef) {
82
+ if (!this.accepting)
83
+ return false;
84
+ this.agentOpenReservations.set(sessionRef, (this.agentOpenReservations.get(sessionRef) ?? 0) + 1);
85
+ return true;
86
+ }
87
+ agentOpenFinished(sessionRef) {
88
+ const reservations = this.agentOpenReservations.get(sessionRef) ?? 0;
89
+ if (reservations <= 1)
90
+ this.agentOpenReservations.delete(sessionRef);
91
+ else
92
+ this.agentOpenReservations.set(sessionRef, reservations - 1);
93
+ this.notifyIfIdle();
94
+ }
95
+ /** Graceful drain closes admission too; only this flag authorizes destructive late-open cleanup. */
96
+ isForceStopping() {
97
+ return this.stopping !== null;
98
+ }
99
+ unregisterAgentSession(sessionRef) {
100
+ this.agentSessions.delete(sessionRef);
101
+ this.agentTasks.delete(sessionRef);
102
+ this.notifyIfIdle();
103
+ }
104
+ /** Returns false whenever an update has closed the new-turn admission gate. */
105
+ agentTurnStarted(sessionRef) {
106
+ if (!this.accepting)
107
+ return false;
108
+ this.agentTasks.add(sessionRef);
109
+ return true;
110
+ }
111
+ agentTurnFinished(sessionRef) {
112
+ this.agentTasks.delete(sessionRef);
113
+ this.notifyIfIdle();
114
+ }
115
+ /**
116
+ * Atomically refuse future work, signal every shell process group through its owning executor,
117
+ * and close every native agent. All closes are attempted even when one bridge fails.
118
+ */
119
+ async stopAll() {
120
+ if (this.stopping)
121
+ return this.stopping;
122
+ this.accepting = false;
123
+ this.stopping = (async () => {
124
+ // Snapshot before aborting: an executor may synchronously observe abort and finish, deleting
125
+ // itself from the live map. The snapshot's cleanup promises are the authoritative barrier.
126
+ const shells = [...this.shellTasks.entries()];
127
+ for (const [task] of shells)
128
+ task.abort();
129
+ this.agentTasks.clear();
130
+ this.agentOpenReservations.clear();
131
+ this.notifyIfIdle();
132
+ const [outcomes] = await Promise.all([
133
+ Promise.allSettled([...this.agentSessions.values()].map(async (close) => close())),
134
+ Promise.all(shells.map(([, cleanedUp]) => cleanedUp)),
135
+ ]);
136
+ const failures = outcomes.filter((outcome) => outcome.status === 'rejected');
137
+ if (failures.length > 0)
138
+ throw new AggregateError(failures.map((failure) => failure.reason), 'Unable to close all agent sessions');
139
+ })();
140
+ return this.stopping;
141
+ }
142
+ }
@@ -0,0 +1,3 @@
1
+ export function isTerminalPhase(phase) {
2
+ return phase === 'BUSY' || phase === 'SUCCEEDED' || phase === 'FAILED' || phase === 'ROLLED_BACK';
3
+ }