@ravefox/space 0.2.1 → 0.4.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 (167) hide show
  1. package/LICENSE +21 -205
  2. package/README.md +104 -31
  3. package/bin/space.js +113 -0
  4. package/lib/agents.js +181 -0
  5. package/lib/boardTools.js +89 -0
  6. package/lib/credentials.js +95 -0
  7. package/lib/daemon.js +294 -0
  8. package/lib/harness.js +166 -0
  9. package/lib/http.js +101 -0
  10. package/lib/login.js +68 -0
  11. package/lib/mcp.js +39 -0
  12. package/lib/runtime.js +253 -0
  13. package/lib/store.js +96 -0
  14. package/lib/tools.js +93 -0
  15. package/lib/workspaceSandbox.js +77 -0
  16. package/lib/workspaceTools.js +160 -0
  17. package/lib/workspaceWorker.js +17 -0
  18. package/package.json +27 -46
  19. package/NOTICE +0 -55
  20. package/assets/webhook-sdk/python/bot.py +0 -63
  21. package/assets/webhook-sdk/python/commonly.py +0 -232
  22. package/node_modules/@commonlyai/seats/README.md +0 -25
  23. package/node_modules/@commonlyai/seats/package.json +0 -22
  24. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +0 -264
  25. package/node_modules/@commonlyai/seats/src/adapters/stub.js +0 -24
  26. package/node_modules/@commonlyai/seats/src/contract.js +0 -48
  27. package/node_modules/@commonlyai/seats/src/failures.js +0 -44
  28. package/node_modules/@commonlyai/seats/src/harness-plan.js +0 -329
  29. package/node_modules/@commonlyai/seats/src/index.js +0 -22
  30. package/node_modules/@commonlyai/seats/src/process-group.js +0 -269
  31. package/node_modules/@commonlyai/seats/src/record/format.js +0 -737
  32. package/node_modules/@commonlyai/seats/src/record/otel.js +0 -238
  33. package/node_modules/@commonlyai/seats/src/runtime-observation.js +0 -29
  34. package/node_modules/@commonlyai/seats/src/seat-model.js +0 -17
  35. package/node_modules/@commonlyai/seats/src/supervisor.js +0 -308
  36. package/node_modules/@commonlyai/seats/src/trace/events.js +0 -46
  37. package/node_modules/@commonlyai/seats/src/trace/store.js +0 -276
  38. package/node_modules/@commonlyai/seats/src/turn-processes.js +0 -226
  39. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +0 -418
  40. package/node_modules/@commonlyai/seats/src/valet/env.js +0 -201
  41. package/node_modules/@commonlyai/seats/src/valet/grant.js +0 -123
  42. package/node_modules/@commonlyai/seats/src/valet/permissions.js +0 -213
  43. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +0 -529
  44. package/skills/commonly/SKILL.md +0 -233
  45. package/src/commands/agent.js +0 -6334
  46. package/src/commands/daemon.js +0 -933
  47. package/src/commands/dev.js +0 -153
  48. package/src/commands/doctor.js +0 -141
  49. package/src/commands/inbox.js +0 -330
  50. package/src/commands/login.js +0 -194
  51. package/src/commands/mcp.js +0 -56
  52. package/src/commands/pod.js +0 -120
  53. package/src/commands/task.js +0 -279
  54. package/src/commands/verify.js +0 -48
  55. package/src/index.js +0 -99
  56. package/src/lib/adapter-login.js +0 -76
  57. package/src/lib/adapters/broker-bridge-entry.js +0 -76
  58. package/src/lib/adapters/claude.js +0 -2207
  59. package/src/lib/adapters/codex-broker-bridge.mjs +0 -298
  60. package/src/lib/adapters/codex.js +0 -1898
  61. package/src/lib/adapters/dsh.js +0 -513
  62. package/src/lib/adapters/goose.js +0 -510
  63. package/src/lib/adapters/hermes.js +0 -18
  64. package/src/lib/adapters/index.js +0 -68
  65. package/src/lib/adapters/opencode.js +0 -1681
  66. package/src/lib/adapters/pi-commonly-mcp.mjs +0 -71
  67. package/src/lib/adapters/pi-mcp-client.mjs +0 -617
  68. package/src/lib/adapters/pi-shell-env.mjs +0 -41
  69. package/src/lib/adapters/pi.js +0 -1025
  70. package/src/lib/adapters/qwen.js +0 -523
  71. package/src/lib/adapters/seat-model.js +0 -8
  72. package/src/lib/adapters/stub.js +0 -1
  73. package/src/lib/adapters/tested-version.js +0 -106
  74. package/src/lib/api.js +0 -205
  75. package/src/lib/claim-outcome.js +0 -128
  76. package/src/lib/claimed-work-gate.js +0 -73
  77. package/src/lib/cli-hint.js +0 -33
  78. package/src/lib/commit-attribution.js +0 -391
  79. package/src/lib/config.js +0 -155
  80. package/src/lib/content-refusal.js +0 -94
  81. package/src/lib/credential-file.js +0 -208
  82. package/src/lib/daemon-agent-states.js +0 -95
  83. package/src/lib/daemon-hint.js +0 -18
  84. package/src/lib/daemon-logs.js +0 -43
  85. package/src/lib/daemon-service-windows.js +0 -263
  86. package/src/lib/daemon-service.js +0 -486
  87. package/src/lib/daemon-state.js +0 -84
  88. package/src/lib/daemon-store.js +0 -119
  89. package/src/lib/daemon-supervisor.js +0 -629
  90. package/src/lib/daemon-watch.js +0 -115
  91. package/src/lib/declared-mcp-guard.js +0 -284
  92. package/src/lib/default-environment.js +0 -207
  93. package/src/lib/device-login.js +0 -126
  94. package/src/lib/digest-frame.js +0 -151
  95. package/src/lib/doctor.js +0 -291
  96. package/src/lib/egress-proxy.js +0 -160
  97. package/src/lib/enforcement.js +0 -946
  98. package/src/lib/environment.js +0 -681
  99. package/src/lib/first-output-watchdog.js +0 -191
  100. package/src/lib/git-readonly-server.js +0 -138
  101. package/src/lib/git-readonly.js +0 -463
  102. package/src/lib/git-remote-commonly.mjs +0 -100
  103. package/src/lib/grant-broker-guard.js +0 -345
  104. package/src/lib/hooks-config.js +0 -238
  105. package/src/lib/host-gh-broker.js +0 -152
  106. package/src/lib/host-gh-shim.mjs +0 -98
  107. package/src/lib/inbox.js +0 -483
  108. package/src/lib/instance-proxy.js +0 -76
  109. package/src/lib/machine-host.js +0 -29
  110. package/src/lib/mcp-credential-delivery.js +0 -121
  111. package/src/lib/mcp-first-run.js +0 -50
  112. package/src/lib/mcp-home.js +0 -644
  113. package/src/lib/mcp-ready.js +0 -68
  114. package/src/lib/mcp-server-version.js +0 -123
  115. package/src/lib/mcp-token.js +0 -36
  116. package/src/lib/mcp-warm-child.mjs +0 -412
  117. package/src/lib/memory-bridge.js +0 -191
  118. package/src/lib/memory-import.js +0 -156
  119. package/src/lib/missing-harness.js +0 -31
  120. package/src/lib/model-unavailable.js +0 -154
  121. package/src/lib/native-runtime-inventory.js +0 -40
  122. package/src/lib/outsider-grant-mcp.js +0 -82
  123. package/src/lib/own-key-launch.js +0 -42
  124. package/src/lib/pod-catch-up.js +0 -55
  125. package/src/lib/pod-focus.js +0 -224
  126. package/src/lib/pod-tail.js +0 -68
  127. package/src/lib/poll-liveness.js +0 -103
  128. package/src/lib/poll-retry.js +0 -81
  129. package/src/lib/poller.js +0 -200
  130. package/src/lib/provider-proxy.js +0 -42
  131. package/src/lib/public-seat-mcp-flag.js +0 -36
  132. package/src/lib/public-tool-error.js +0 -60
  133. package/src/lib/public-withheld-tools.js +0 -48
  134. package/src/lib/reply-receipt.js +0 -55
  135. package/src/lib/resolve-on-path.js +0 -22
  136. package/src/lib/runtime-inventory.js +0 -56
  137. package/src/lib/runtime-observation.js +0 -37
  138. package/src/lib/sandbox/bwrap.js +0 -275
  139. package/src/lib/sandbox/codex-perimeter.js +0 -16
  140. package/src/lib/sandbox/host-check.js +0 -268
  141. package/src/lib/sandbox/mode.js +0 -49
  142. package/src/lib/sandbox/opencode-loopback.mjs +0 -68
  143. package/src/lib/sandbox/seatbelt.js +0 -684
  144. package/src/lib/sandbox/turn-dir.js +0 -56
  145. package/src/lib/sandbox/windows-unsandboxed.js +0 -183
  146. package/src/lib/sandbox/workspace-git-config.js +0 -243
  147. package/src/lib/sandbox/workspace-pin.js +0 -158
  148. package/src/lib/seat-runtime-controls.js +0 -61
  149. package/src/lib/seat-stop.js +0 -97
  150. package/src/lib/server-skills.js +0 -32
  151. package/src/lib/session-recycle.js +0 -219
  152. package/src/lib/session-store.js +0 -215
  153. package/src/lib/skill-prompt.js +0 -74
  154. package/src/lib/skills-import.js +0 -113
  155. package/src/lib/spawn-credential.js +0 -402
  156. package/src/lib/spawn-retry.js +0 -311
  157. package/src/lib/stable-install.js +0 -176
  158. package/src/lib/supervised-turn.js +0 -352
  159. package/src/lib/task.js +0 -235
  160. package/src/lib/turn-failure-notice.js +0 -177
  161. package/src/lib/unconfined-seat-guard.js +0 -94
  162. package/src/lib/update-check.js +0 -192
  163. package/src/lib/upstream-refusal.js +0 -1
  164. package/src/lib/webhook-server.js +0 -109
  165. package/src/lib/windows-command.js +0 -393
  166. package/src/lib/workspace-git.js +0 -87
  167. package/src/lib/workspace-trust.js +0 -162
@@ -1,629 +0,0 @@
1
- import { isDeepStrictEqual } from 'node:util';
2
- import { homedir } from 'node:os';
3
- import { isAbsolute, resolve as pathResolve } from 'node:path';
4
- import {
5
- auditDeclaredMcp,
6
- installedStdioEntries,
7
- } from './declared-mcp-guard.js';
8
-
9
- import { seatBaseline } from './default-environment.js';
10
- import { getAdapter } from './adapters/index.js';
11
- import { providerRefusal } from './environment.js';
12
- import { withholdGrantBroker } from './grant-broker-guard.js';
13
-
14
- /**
15
- * ADR-026 Phase 2, slice 2: the resident supervision loop behind
16
- * `commonly daemon run`.
17
- *
18
- * The server's work list (GET /api/agent-binding/assigned) is the source of
19
- * truth (D2): a row `requested` gets adopted (the D3 CAS — the server refuses
20
- * the loser of a race cleanly), a row `bound` gets provisioned (token file)
21
- * and supervised (a `commonly agent run <name>` child), and a supervised
22
- * agent that leaves the list gets stopped. Per-agent state rides every
23
- * machine heartbeat (D5).
24
- *
25
- * D6 discipline: a replacement child is only ever scheduled from the previous
26
- * child's 'exit' event — there is no code path that spawns a second runner
27
- * for an agent whose child has not exited.
28
- *
29
- * All side effects (client, spawn, token file I/O, adapter detection, timers)
30
- * are injected so the loop's decisions are testable without processes.
31
- */
32
-
33
- export const DEFAULT_POLL_MS = 30_000;
34
- export const DEFAULT_HEARTBEAT_MS = 30_000;
35
- export const BACKOFF_BASE_MS = 5_000;
36
- export const BACKOFF_MAX_MS = 60_000;
37
- export const MAX_CONCURRENT_AGENT_STARTS = 3;
38
- export const AGENT_START_WINDOW_MS = 5_000;
39
- export const RECONNECT_MAX_MS = 60_000;
40
-
41
- export const reconnectDelayMs = (failures, random = Math.random) => {
42
- const ceiling = Math.min(RECONNECT_MAX_MS, 1_000 * 2 ** Math.min(failures, 6));
43
- return Math.floor(1_000 + random() * (ceiling - 1_000));
44
- };
45
-
46
- const workspacePathFor = (environment) => {
47
- const declared = environment?.workspace?.path;
48
- if (typeof declared !== 'string' || !declared.trim()) return null;
49
- const expanded = declared === '~'
50
- ? homedir()
51
- : (declared.startsWith('~/') ? `${homedir()}/${declared.slice(2)}` : declared);
52
- return isAbsolute(expanded) ? expanded : pathResolve(expanded);
53
- };
54
-
55
- export const backoffMs = (restarts) => Math.min(
56
- BACKOFF_MAX_MS,
57
- BACKOFF_BASE_MS * 2 ** Math.max(0, Math.min(restarts, 10)),
58
- );
59
-
60
- const identityKey = (agentName, instanceId) => `${agentName} ${instanceId || 'default'}`;
61
-
62
- // An instance id becomes part of a local file name.
63
- const INSTANCE_ID_PATTERN = /^[A-Za-z0-9._-]{1,64}$/;
64
- const isDefaultInstance = (instanceId) => String(instanceId || 'default').toLowerCase() === 'default';
65
- // Instance ids compare case-insensitively, as `agent run` and the server do.
66
- const sameInstance = (a, b) => String(a || 'default').toLowerCase() === String(b || 'default').toLowerCase();
67
-
68
- /**
69
- * The local token record a seat lives in, as `agent run <key>` resolves it
70
- * (F306, F427): the default instance is `<name>`, another is `<name>@<id>`,
71
- * except a key an older CLI saved for that instance under the bare name, which
72
- * is used where it is. So a second instance placed on a computer that already
73
- * runs the default gets its own record, and neither overwrites the other.
74
- * Null for an instance id that can't be a file name.
75
- */
76
- export const recordKeyFor = (row, loadToken) => {
77
- const { agentName } = row;
78
- const instanceId = row.instanceId || 'default';
79
- if (isDefaultInstance(instanceId)) return agentName;
80
- if (!INSTANCE_ID_PATTERN.test(instanceId)) return null;
81
- const scoped = `${agentName}@${instanceId}`;
82
- if (loadToken(scoped)) return scoped;
83
- const legacy = loadToken(agentName);
84
- if (legacy && sameInstance(legacy.instanceId, instanceId)) return agentName;
85
- return scoped;
86
- };
87
-
88
- export const createDaemonSupervisor = ({
89
- record,
90
- client,
91
- spawnChild, // (agentName) => child emitting 'exit'; must expose .kill()
92
- loadToken, // (agentName) => token record | null
93
- saveToken, // (agentName, record) => void
94
- runtimeInventory = async () => undefined,
95
- // Machine-level facts for the check-in, read locally each beat (D245: the
96
- // Windows unsandboxed opt-in). Never derived from anything the server sent.
97
- machineReport = () => ({}),
98
- resolveAdapter, // async (runtime) => adapter name for THIS machine
99
- persistState = () => {}, // (agentStates) => void; must not persist secrets
100
- log = () => {},
101
- isTerminalAuthError = () => false,
102
- onTerminalAuthError = () => {},
103
- now = Date.now,
104
- random = Math.random,
105
- setTimeoutFn = setTimeout,
106
- clearTimeoutFn = clearTimeout,
107
- }) => {
108
- // key → { agentName, instanceId, child, state, restarts, backoffTimer, desired }
109
- const seats = new Map();
110
- let stopped = false;
111
- let reconnectFailures = 0;
112
- let reconnectAfter = 0;
113
- const recentStarts = [];
114
-
115
- const connectionFailure = (error, label) => {
116
- if (isTerminalAuthError(error)) {
117
- stopped = true;
118
- onTerminalAuthError(error);
119
- return;
120
- }
121
- reconnectFailures += 1;
122
- reconnectAfter = now() + reconnectDelayMs(reconnectFailures, random);
123
- log(`${label}: ${error.message}; reconnect after ${Math.round((reconnectAfter - now()) / 1000)}s`);
124
- };
125
-
126
- const connectionRestored = () => {
127
- reconnectFailures = 0;
128
- reconnectAfter = 0;
129
- };
130
-
131
- const persist = () => {
132
- try {
133
- // The local record key too, for this host's own readouts (the last turn
134
- // is kept per record); the heartbeat reports agentStates() without it.
135
- const keys = Array.from(seats.values()).map((s) => s.recordKey || s.agentName);
136
- persistState(agentStates().map((state, i) => ({ ...state, recordKey: keys[i] })));
137
- } catch (error) {
138
- // A diagnostic state file must never take the daemon down. The log is
139
- // still useful, and the next state transition retries the write.
140
- log(`could not persist local daemon state: ${error.message}`);
141
- }
142
- };
143
-
144
- const agentStates = () => Array.from(seats.values()).map((s) => ({
145
- agentName: s.agentName,
146
- instanceId: s.instanceId,
147
- state: s.state,
148
- restarts: s.restarts,
149
- adapter: s.adapter || null,
150
- model: s.model || null,
151
- effort: s.effort || null,
152
- pid: Number.isInteger(s.pid) ? s.pid : null,
153
- lastTurnAt: s.lastTurnAt || null,
154
- lastError: s.lastError || null,
155
- }));
156
-
157
- const startChild = (seat) => {
158
- if (stopped || !seat.desired || seat.child || seat.startDelayTimer) return;
159
- const at = now();
160
- while (recentStarts.length && recentStarts[0] + AGENT_START_WINDOW_MS <= at) {
161
- recentStarts.shift();
162
- }
163
- if (recentStarts.length >= MAX_CONCURRENT_AGENT_STARTS) {
164
- seat.startDelayTimer = setTimeoutFn(() => {
165
- seat.startDelayTimer = null;
166
- startChild(seat);
167
- }, Math.max(1, recentStarts[0] + AGENT_START_WINDOW_MS - at));
168
- return;
169
- }
170
- recentStarts.push(at);
171
- seat.child = spawnChild(seat.recordKey || seat.agentName);
172
- seat.state = 'running';
173
- seat.pid = Number.isInteger(seat.child?.pid) ? seat.child.pid : null;
174
- seat.lastTurnAt = new Date().toISOString();
175
- seat.lastError = null;
176
- persist();
177
- log(`[${seat.recordKey || seat.agentName}] supervising (restarts so far: ${seat.restarts})`);
178
- seat.child.on('exit', (code) => {
179
- seat.child = null;
180
- seat.pid = null;
181
- if (stopped || !seat.desired) {
182
- seat.state = 'stopped';
183
- persist();
184
- return;
185
- }
186
- seat.state = code === 0 ? 'stopped' : 'crashed';
187
- seat.lastError = code === 0 ? null : `child exited with code ${code}`;
188
- seat.restarts += 1;
189
- const delay = backoffMs(seat.restarts - 1);
190
- log(`[${seat.recordKey || seat.agentName}] exited (code ${code}) — respawn in ${Math.round(delay / 1000)}s`);
191
- seat.backoffTimer = setTimeoutFn(() => {
192
- seat.backoffTimer = null;
193
- startChild(seat);
194
- }, delay);
195
- persist();
196
- });
197
- };
198
-
199
- const stopSeat = (seat) => {
200
- seat.desired = false;
201
- if (seat.startDelayTimer) {
202
- clearTimeoutFn(seat.startDelayTimer);
203
- seat.startDelayTimer = null;
204
- }
205
- if (seat.backoffTimer) {
206
- clearTimeoutFn(seat.backoffTimer);
207
- seat.backoffTimer = null;
208
- }
209
- if (seat.child) {
210
- log(`[${seat.recordKey || seat.agentName}] no longer assigned here — stopping`);
211
- seat.child.kill('SIGTERM');
212
- } else {
213
- seat.state = 'stopped';
214
- seat.pid = null;
215
- persist();
216
- }
217
- };
218
-
219
- // Preserve the complete ADR-008 environment when the daemon receives it.
220
- // Older installs only expose runtime.model/effort; those fields are a
221
- // compatibility overlay and must merge into an existing local environment
222
- // rather than erasing its workspace, skills, or MCP declarations.
223
- const environmentFor = (row) => {
224
- const declared = row.environment && typeof row.environment === 'object'
225
- && !Array.isArray(row.environment) ? { ...row.environment } : null;
226
- const runtime = row.runtime && typeof row.runtime === 'object' ? row.runtime : {};
227
- if (declared) {
228
- if (runtime.model && declared.model === undefined) declared.model = String(runtime.model);
229
- if (runtime.effort && declared.effort === undefined) declared.effort = String(runtime.effort);
230
- return { value: declared, declared: true };
231
- }
232
- const fallback = {};
233
- if (runtime.model) fallback.model = String(runtime.model);
234
- if (runtime.effort) fallback.effort = String(runtime.effort);
235
- return Object.keys(fallback).length ? { value: fallback, declared: false } : null;
236
- };
237
-
238
- // `provider` is host-local authority: /assigned intentionally omits it.
239
- // Ignore a provider field even if a future or malformed row carries one,
240
- // then restore only the local token record's value during adoption.
241
- const withoutServerProvider = (environment) => {
242
- const safe = { ...(environment || {}) };
243
- delete safe.provider;
244
- return safe;
245
- };
246
-
247
- // A declared environment runs on THIS machine as the operator. Refuse any
248
- // declared stdio command that is not the shipped commonly MCP server or one
249
- // the operator already installed here, and any http server that would
250
- // receive the seat token off-origin. Provider blocks are ignored from rows;
251
- // the host-local token record is their only source.
252
- const admitDeclared = (row, environment, existing) => {
253
- const mcpAudit = auditDeclaredMcp(environment, {
254
- instanceUrl: existing?.instanceUrl || record.instanceUrl,
255
- allowedStdioEntries: installedStdioEntries(existing),
256
- });
257
- if (mcpAudit.refusals.length === 0) return true;
258
- log(`[${row.agentName}] refusing the declared environment — it would not stay on this machine's terms:`);
259
- for (const refusal of mcpAudit.refusals) log(`[${row.agentName}] ${refusal}`);
260
- return false;
261
- };
262
-
263
- // The mint refuses to clobber an existing token (409 token_exists); the
264
- // binding to THIS machine is the owner's explicit takeover choice (D3), so
265
- // that refusal is answered with rotate:true — loudly.
266
- // Returns 'ready' | 'changed' (record updated — the seat must restart to
267
- // load it) | false.
268
- const ensureToken = async (row, recordKey) => {
269
- // One derive for this seat, so the broker refusal cannot be applied at three
270
- // of four sites: `seatBaseline` is where an environment becomes the one the
271
- // seat RUNS under, and `withholdGrantBroker` asks the same question the
272
- // server asks (`can this seat confine a granter's authority?`) for the cases
273
- // the server cannot see — a row naming no adapter, a backend older than the
274
- // refusal, a record written by hand. Entry-level: the broker
275
- // entry is withheld and the seat still starts, because it was never promised
276
- // confinement. `record.instanceUrl` is what makes the injected url
277
- // identifiable at all — the record holds the UNRESOLVED
278
- // `${COMMONLY_API_URL}/api/mcp/grants/<id>` placeholder.
279
- const derive = (environment, adapter, options) => withholdGrantBroker(
280
- seatBaseline(environment, adapter, options),
281
- adapter,
282
- {
283
- instanceUrl: record.instanceUrl,
284
- onRefuse: (refusal, names) => log(
285
- `[${recordKey}] ${refusal.code} (${refusal.reason}) — withholding ${names.join(', ')}: ${refusal.detail}`,
286
- ),
287
- },
288
- );
289
-
290
- const existing = loadToken(recordKey);
291
- if (existing) {
292
- // recordKeyFor gives each instance its own record; a record that still
293
- // names another instance (written by hand, or the bare name holding a
294
- // non-default instance when the default is placed) is never rotated or
295
- // launched as this one, which would overwrite that seat's bearer.
296
- if (!sameInstance(existing.instanceId, row.instanceId)) {
297
- log(`[${recordKey}] local token belongs to instance '${existing.instanceId || 'default'}'; refusing '${row.instanceId || 'default'}'`);
298
- return false;
299
- }
300
- // A human owner can request a fresh key from Settings after revocation.
301
- // The daemon owns its local token file, so the browser only stamps the
302
- // directive; this host rotates the key and restarts its child once.
303
- const requestedAt = row.reconnectRequestedAt && new Date(row.reconnectRequestedAt).getTime();
304
- const appliedAt = existing.reconnectRequestAppliedAt
305
- && new Date(existing.reconnectRequestAppliedAt).getTime();
306
- if (Number.isFinite(requestedAt) && requestedAt > (appliedAt || 0)) {
307
- try {
308
- const minted = await client.post('/api/agent-binding/runtime-token', {
309
- agentName: row.agentName, instanceId: row.instanceId, rotate: true,
310
- });
311
- if (!minted?.token) throw new Error('mint returned no token');
312
- saveToken(recordKey, {
313
- ...existing,
314
- runtimeToken: minted.token,
315
- reconnectRequestAppliedAt: row.reconnectRequestedAt,
316
- });
317
- log(`[${recordKey}] owner-requested key rotation applied; restarting the seat`);
318
- return 'changed';
319
- } catch (error) {
320
- log(`[${recordKey}] owner-requested key rotation failed: ${error.message}`);
321
- return false;
322
- }
323
- }
324
- // A model changed in the UI reaches the seat here: update the record,
325
- // and let the caller restart the child (`agent run` reads its record
326
- // once at boot). A row with NO declared model leaves the record alone —
327
- // never strip an operator's hand-set environment.
328
- const wanted = environmentFor(row);
329
- if (wanted?.declared && !admitDeclared(row, wanted.value, existing)) return false;
330
- const declaredAdapter = row.runtime && typeof row.runtime === 'object'
331
- && typeof row.runtime.adapter === 'string'
332
- ? row.runtime.adapter.trim().toLowerCase()
333
- : null;
334
- let adapterChanged = false;
335
- let nextAdapter = existing.adapter;
336
- if (declaredAdapter) {
337
- const detectedAdapter = await resolveAdapter(row.runtime || null);
338
- // resolveAdapterForRuntime historically probes fallbacks when a
339
- // declared adapter is absent. A configuration edit must never accept
340
- // that fallback: it would report claude while running codex (or vice
341
- // versa). Keep the existing child/token untouched until the exact
342
- // requested adapter is detected locally.
343
- if (detectedAdapter !== declaredAdapter) {
344
- log(`[${recordKey}] requested adapter '${declaredAdapter}' is not available on this machine — keeping the current seat`);
345
- return false;
346
- }
347
- nextAdapter = declaredAdapter;
348
- adapterChanged = existing.adapter !== nextAdapter;
349
- }
350
- const providerRefused = existing.environment?.provider
351
- ? providerRefusal(existing.environment, getAdapter(nextAdapter)) : null;
352
- if (providerRefused) {
353
- // The record stays as it is, and the seat is not started: a retained
354
- // adapter that needs a server binding (opencode) refuses to run under
355
- // the server's new one, so starting it would only crash-loop. The
356
- // owner sets the adapter back, or moves the provider to one it speaks.
357
- log(`[${recordKey}] host-local provider: ${providerRefused} — keeping the current record and not starting it; `
358
- + `set the adapter back to ${existing.adapter || 'the previous one'} in Space, or change the provider`);
359
- return false;
360
- }
361
- if (wanted) {
362
- const serverEnvironment = withoutServerProvider(wanted.value);
363
- const merged = wanted.declared
364
- ? serverEnvironment
365
- : { ...(existing.environment || {}), ...serverEnvironment };
366
- // The provider chooses a network endpoint and reads a host key file,
367
- // so it is authored on this host. /assigned omits it; rehydrate the
368
- // local block after applying the server's declared environment.
369
- if (existing.environment?.provider) {
370
- merged.provider = existing.environment.provider;
371
- }
372
- // A server-declared environment replaces the local one, so a seat whose
373
- // declaration carries no mcp[] would come back tool-less. Re-apply the
374
- // shipped default here and below, or the seat silently loses every
375
- // commonly_* tool the next time the UI edits its model (TASK-048).
376
- //
377
- // A DECLARED environment also gets the sandbox default when it names
378
- // none: the declaration is not the operator's, and `sandbox.mode`
379
- // defaults to 'none' in the adapters, so an omitted block is an
380
- // unconfined seat (TASK-052). A local record with no environment at
381
- // all is in the same position — nobody has authored anything.
382
- const nextEnvironment = derive(merged, nextAdapter, {
383
- sandbox: wanted.declared || !existing.environment,
384
- });
385
- const workspacePath = workspacePathFor(nextEnvironment);
386
- const nextRecord = {
387
- ...existing,
388
- machineId: record.machineId,
389
- ...(adapterChanged ? { adapter: nextAdapter } : {}),
390
- environment: nextEnvironment,
391
- ...(workspacePath ? { workspacePath } : {}),
392
- };
393
- if (adapterChanged
394
- || !isDeepStrictEqual(existing.environment || null, nextEnvironment)
395
- || (workspacePath && existing.workspacePath !== workspacePath)) {
396
- saveToken(recordKey, nextRecord);
397
- log('runtime config changed — restarting the seat to load it');
398
- return 'changed';
399
- }
400
- }
401
- if (adapterChanged) {
402
- // An adapter that consumes mcp[] must not be started on a record that
403
- // declares none, even when only the adapter itself changed.
404
- const nextEnvironment = derive(existing.environment, nextAdapter, {
405
- sandbox: !existing.environment,
406
- });
407
- saveToken(recordKey, {
408
- ...existing,
409
- adapter: nextAdapter,
410
- ...(nextEnvironment ? { environment: nextEnvironment } : {}),
411
- });
412
- log('runtime adapter changed — restarting the seat to load it');
413
- return 'changed';
414
- }
415
- if (!wanted) {
416
- // A record written by a CLI older than the shipped default carries no
417
- // mcp[] — the c4-smoke record was exactly this, and the operator had to
418
- // hand-add the entry. Behind a row that declares nothing there is no
419
- // other write path: the record is never touched again, so the seat
420
- // stays tool-less for as long as it runs. Heal it here, and only when
421
- // the environment actually changed — otherwise every tick rewrites the
422
- // file and restarts the seat forever.
423
- const nextEnvironment = derive(existing.environment, existing.adapter, {
424
- sandbox: !existing.environment,
425
- });
426
- if (!isDeepStrictEqual(existing.environment || null, nextEnvironment || null)) {
427
- saveToken(recordKey, { ...existing, environment: nextEnvironment });
428
- log('record predates the commonly MCP baseline — restarting the seat to load it');
429
- return 'changed';
430
- }
431
- }
432
- return 'ready';
433
- }
434
- const requestedAdapter = row.runtime && typeof row.runtime === 'object'
435
- && typeof row.runtime.adapter === 'string'
436
- ? row.runtime.adapter.trim().toLowerCase()
437
- : null;
438
- let adapter = null;
439
- if (requestedAdapter) {
440
- adapter = await resolveAdapter(row.runtime || null);
441
- if (adapter !== requestedAdapter) {
442
- log(`[${recordKey}] requested adapter '${requestedAdapter}' is not available on this machine — skipping token mint`);
443
- return false;
444
- }
445
- }
446
- const declaredAtMint = environmentFor(row);
447
- if (declaredAtMint?.declared && !admitDeclared(row, declaredAtMint.value, null)) return false;
448
- const body = { agentName: row.agentName, instanceId: row.instanceId };
449
- let minted;
450
- try {
451
- minted = await client.post('/api/agent-binding/runtime-token', body);
452
- } catch (error) {
453
- if (error?.status === 409 && error?.body?.code === 'token_exists') {
454
- log(`[${recordKey}] a runtime token exists elsewhere — rotating it to this machine (the old token stops working)`);
455
- try {
456
- minted = await client.post('/api/agent-binding/runtime-token', { ...body, rotate: true });
457
- } catch (rotateError) {
458
- log(`[${recordKey}] token rotation failed: ${rotateError.message}`);
459
- return false;
460
- }
461
- } else {
462
- log(`[${recordKey}] token mint failed: ${error.message}`);
463
- return false;
464
- }
465
- }
466
- if (!minted?.token) {
467
- log(`[${recordKey}] mint returned no token — skipping`);
468
- return false;
469
- }
470
- if (!adapter) adapter = await resolveAdapter(row.runtime || null);
471
- if (!adapter) {
472
- log(`[${recordKey}] no usable CLI adapter on this machine — install claude, codex, opencode, qwen, or dsh, or attach manually`);
473
- return false;
474
- }
475
- const environment = environmentFor(row);
476
- // A seat installed server-side (no local `agent attach`) arrives with no
477
- // mcp[] at all: without the default it spawns a CLI that has no commonly_*
478
- // tools and cannot post. See lib/default-environment.js. Nothing here is
479
- // operator-authored, so this seat also gets the sandbox default — the
480
- // self-serve install's seat used to be born unconfined (TASK-052).
481
- const recordEnvironment = derive(
482
- environment ? withoutServerProvider(environment.value) : null,
483
- adapter,
484
- { sandbox: true },
485
- );
486
- saveToken(recordKey, {
487
- agentName: row.agentName,
488
- instanceId: row.instanceId,
489
- runtimeToken: minted.token,
490
- instanceUrl: record.instanceUrl,
491
- machineId: record.machineId,
492
- podId: row.podIds?.[0] || null,
493
- adapter,
494
- ...(recordEnvironment ? { environment: recordEnvironment } : {}),
495
- ...(recordEnvironment ? (() => {
496
- const workspacePath = workspacePathFor(recordEnvironment);
497
- return workspacePath ? { workspacePath } : {};
498
- })() : {}),
499
- });
500
- log(`[${recordKey}] provisioned runtime token (adapter: ${adapter}${environment?.value?.model ? `, model: ${environment.value.model}` : ''})`);
501
- return 'ready';
502
- };
503
-
504
- const tick = async () => {
505
- if (stopped || now() < reconnectAfter) return;
506
- let assigned;
507
- try {
508
- assigned = await client.get('/api/agent-binding/assigned');
509
- } catch (error) {
510
- connectionFailure(error, 'work-list fetch failed');
511
- return;
512
- }
513
- connectionRestored();
514
- const rows = Array.isArray(assigned?.agents) ? assigned.agents : [];
515
-
516
- const bound = [];
517
- for (const row of rows) {
518
- if (row.state === 'requested') {
519
- try {
520
- // eslint-disable-next-line no-await-in-loop
521
- await client.post('/api/agent-binding/adopt', {
522
- agentName: row.agentName, instanceId: row.instanceId,
523
- });
524
- log(`[${row.agentName}] adopted onto this machine`);
525
- bound.push(row);
526
- } catch (error) {
527
- // A clean CAS refusal (409) means another machine won — drop it.
528
- log(`[${row.agentName}] adopt refused: ${error.message}`);
529
- }
530
- } else {
531
- bound.push(row);
532
- }
533
- }
534
-
535
- const desiredKeys = new Set();
536
- for (const row of bound) {
537
- const key = identityKey(row.agentName, row.instanceId);
538
- desiredKeys.add(key);
539
- let seat = seats.get(key);
540
- if (!seat) {
541
- seat = {
542
- agentName: row.agentName,
543
- instanceId: row.instanceId || 'default',
544
- child: null,
545
- state: 'stopped',
546
- restarts: 0,
547
- backoffTimer: null,
548
- startDelayTimer: null,
549
- desired: true,
550
- adapter: null,
551
- model: null,
552
- effort: null,
553
- pid: null,
554
- lastTurnAt: null,
555
- lastError: null,
556
- };
557
- seats.set(key, seat);
558
- }
559
- seat.desired = true;
560
- const recordKey = recordKeyFor(row, loadToken);
561
- if (!recordKey) {
562
- log(`[${row.agentName}] instance id '${row.instanceId}' is not a plain id (letters, digits, '.', '_' or '-'); not starting it`);
563
- continue;
564
- }
565
- seat.recordKey = recordKey;
566
- // eslint-disable-next-line no-await-in-loop
567
- const ready = await ensureToken(row, recordKey);
568
- // Report what the seat actually RUNS, which is the record: `agent run`
569
- // reads it once at boot, and ensureToken above may have just rewritten it
570
- // (a mint, or a config change). Reading it AFTER ensureToken is the point
571
- // — the same tick's write is the record the seat starts from, so the very
572
- // first heartbeat is already about the running seat.
573
- //
574
- // The row's runtime is a COMPATIBILITY OVERLAY for a seat with no record
575
- // at all, never an override of a declared environment (see the overlay
576
- // comment above ensureToken): leading with it here reported a model the
577
- // seat was not running, because the spawn path gives the declared
578
- // environment precedence over runtime.model/effort (environmentFor, and
579
- // the TASK-065 measurement). ensureToken also REFUSES a change it cannot
580
- // apply — a requested adapter this machine does not have — so the record
581
- // is the honest answer there too: it names the adapter that kept running.
582
- const current = loadToken(recordKey);
583
- seat.adapter = current ? (current.adapter || null) : (row.runtime?.adapter || null);
584
- seat.model = current ? (current.environment?.model || null) : (row.runtime?.model || null);
585
- seat.effort = current ? (current.environment?.effort || null) : (row.runtime?.effort || null);
586
- if (ready === 'changed' && seat.child) {
587
- // desired stays true, so the exit handler respawns with the updated
588
- // record — the restart path IS the D6 path, no second spawner.
589
- seat.child.kill('SIGTERM');
590
- } else if (ready && !seat.child && !seat.backoffTimer) {
591
- startChild(seat);
592
- }
593
- persist();
594
- }
595
-
596
- for (const [key, seat] of seats) {
597
- if (!desiredKeys.has(key) && seat.desired) stopSeat(seat);
598
- }
599
- persist();
600
- };
601
-
602
- const heartbeat = async () => {
603
- if (stopped || now() < reconnectAfter) return;
604
- try {
605
- await client.post(`/api/machines/${record.machineDbId}/heartbeat`, {
606
- ...machineReport(),
607
- agents: agentStates(),
608
- runtimeInventory: await runtimeInventory(),
609
- });
610
- connectionRestored();
611
- } catch (error) {
612
- connectionFailure(error, 'heartbeat failed');
613
- }
614
- };
615
-
616
- const stop = () => {
617
- stopped = true;
618
- for (const seat of seats.values()) {
619
- stopSeat(seat);
620
- seat.state = 'stopped';
621
- seat.pid = null;
622
- }
623
- persist();
624
- };
625
-
626
- return {
627
- tick, heartbeat, stop, agentStates,
628
- };
629
- };