@ravefox/space 0.0.0-stage → 0.2.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 (152) hide show
  1. package/LICENSE +203 -0
  2. package/NOTICE +44 -0
  3. package/README.md +49 -2
  4. package/assets/webhook-sdk/python/bot.py +63 -0
  5. package/assets/webhook-sdk/python/commonly.py +232 -0
  6. package/node_modules/@commonlyai/seats/README.md +25 -0
  7. package/node_modules/@commonlyai/seats/package.json +22 -0
  8. package/node_modules/@commonlyai/seats/src/adapters/hermes.js +264 -0
  9. package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
  10. package/node_modules/@commonlyai/seats/src/contract.js +48 -0
  11. package/node_modules/@commonlyai/seats/src/failures.js +44 -0
  12. package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
  13. package/node_modules/@commonlyai/seats/src/index.js +22 -0
  14. package/node_modules/@commonlyai/seats/src/process-group.js +269 -0
  15. package/node_modules/@commonlyai/seats/src/record/format.js +737 -0
  16. package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
  17. package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
  18. package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
  19. package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
  20. package/node_modules/@commonlyai/seats/src/trace/events.js +46 -0
  21. package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
  22. package/node_modules/@commonlyai/seats/src/turn-processes.js +226 -0
  23. package/node_modules/@commonlyai/seats/src/upstream-refusal.js +418 -0
  24. package/node_modules/@commonlyai/seats/src/valet/env.js +201 -0
  25. package/node_modules/@commonlyai/seats/src/valet/grant.js +123 -0
  26. package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
  27. package/node_modules/@commonlyai/seats/src/wire/provider-proxy.js +529 -0
  28. package/package.json +56 -4
  29. package/skills/commonly/SKILL.md +233 -0
  30. package/src/commands/agent.js +6334 -0
  31. package/src/commands/daemon.js +933 -0
  32. package/src/commands/dev.js +153 -0
  33. package/src/commands/doctor.js +141 -0
  34. package/src/commands/inbox.js +330 -0
  35. package/src/commands/login.js +194 -0
  36. package/src/commands/mcp.js +56 -0
  37. package/src/commands/pod.js +120 -0
  38. package/src/commands/task.js +279 -0
  39. package/src/commands/verify.js +48 -0
  40. package/src/index.js +99 -0
  41. package/src/lib/adapter-login.js +76 -0
  42. package/src/lib/adapters/broker-bridge-entry.js +76 -0
  43. package/src/lib/adapters/claude.js +2207 -0
  44. package/src/lib/adapters/codex-broker-bridge.mjs +298 -0
  45. package/src/lib/adapters/codex.js +1898 -0
  46. package/src/lib/adapters/dsh.js +513 -0
  47. package/src/lib/adapters/goose.js +510 -0
  48. package/src/lib/adapters/hermes.js +18 -0
  49. package/src/lib/adapters/index.js +68 -0
  50. package/src/lib/adapters/opencode.js +1681 -0
  51. package/src/lib/adapters/pi-commonly-mcp.mjs +71 -0
  52. package/src/lib/adapters/pi-mcp-client.mjs +617 -0
  53. package/src/lib/adapters/pi-shell-env.mjs +41 -0
  54. package/src/lib/adapters/pi.js +1025 -0
  55. package/src/lib/adapters/qwen.js +523 -0
  56. package/src/lib/adapters/seat-model.js +8 -0
  57. package/src/lib/adapters/stub.js +1 -0
  58. package/src/lib/adapters/tested-version.js +106 -0
  59. package/src/lib/api.js +205 -0
  60. package/src/lib/claim-outcome.js +128 -0
  61. package/src/lib/claimed-work-gate.js +73 -0
  62. package/src/lib/cli-hint.js +33 -0
  63. package/src/lib/commit-attribution.js +391 -0
  64. package/src/lib/config.js +155 -0
  65. package/src/lib/content-refusal.js +94 -0
  66. package/src/lib/credential-file.js +208 -0
  67. package/src/lib/daemon-agent-states.js +95 -0
  68. package/src/lib/daemon-hint.js +18 -0
  69. package/src/lib/daemon-logs.js +43 -0
  70. package/src/lib/daemon-service-windows.js +263 -0
  71. package/src/lib/daemon-service.js +486 -0
  72. package/src/lib/daemon-state.js +84 -0
  73. package/src/lib/daemon-store.js +119 -0
  74. package/src/lib/daemon-supervisor.js +629 -0
  75. package/src/lib/daemon-watch.js +115 -0
  76. package/src/lib/declared-mcp-guard.js +284 -0
  77. package/src/lib/default-environment.js +207 -0
  78. package/src/lib/device-login.js +126 -0
  79. package/src/lib/digest-frame.js +151 -0
  80. package/src/lib/doctor.js +291 -0
  81. package/src/lib/egress-proxy.js +160 -0
  82. package/src/lib/enforcement.js +946 -0
  83. package/src/lib/environment.js +681 -0
  84. package/src/lib/first-output-watchdog.js +191 -0
  85. package/src/lib/git-readonly-server.js +138 -0
  86. package/src/lib/git-readonly.js +463 -0
  87. package/src/lib/git-remote-commonly.mjs +100 -0
  88. package/src/lib/grant-broker-guard.js +345 -0
  89. package/src/lib/hooks-config.js +238 -0
  90. package/src/lib/host-gh-broker.js +152 -0
  91. package/src/lib/host-gh-shim.mjs +98 -0
  92. package/src/lib/inbox.js +483 -0
  93. package/src/lib/instance-proxy.js +76 -0
  94. package/src/lib/machine-host.js +29 -0
  95. package/src/lib/mcp-credential-delivery.js +121 -0
  96. package/src/lib/mcp-first-run.js +50 -0
  97. package/src/lib/mcp-home.js +644 -0
  98. package/src/lib/mcp-ready.js +68 -0
  99. package/src/lib/mcp-server-version.js +123 -0
  100. package/src/lib/mcp-token.js +36 -0
  101. package/src/lib/mcp-warm-child.mjs +412 -0
  102. package/src/lib/memory-bridge.js +191 -0
  103. package/src/lib/memory-import.js +156 -0
  104. package/src/lib/missing-harness.js +31 -0
  105. package/src/lib/model-unavailable.js +154 -0
  106. package/src/lib/native-runtime-inventory.js +40 -0
  107. package/src/lib/outsider-grant-mcp.js +82 -0
  108. package/src/lib/own-key-launch.js +42 -0
  109. package/src/lib/pod-catch-up.js +55 -0
  110. package/src/lib/pod-focus.js +224 -0
  111. package/src/lib/pod-tail.js +68 -0
  112. package/src/lib/poll-liveness.js +103 -0
  113. package/src/lib/poll-retry.js +81 -0
  114. package/src/lib/poller.js +200 -0
  115. package/src/lib/provider-proxy.js +42 -0
  116. package/src/lib/public-seat-mcp-flag.js +36 -0
  117. package/src/lib/public-tool-error.js +60 -0
  118. package/src/lib/public-withheld-tools.js +48 -0
  119. package/src/lib/reply-receipt.js +55 -0
  120. package/src/lib/resolve-on-path.js +22 -0
  121. package/src/lib/runtime-inventory.js +56 -0
  122. package/src/lib/runtime-observation.js +37 -0
  123. package/src/lib/sandbox/bwrap.js +275 -0
  124. package/src/lib/sandbox/codex-perimeter.js +16 -0
  125. package/src/lib/sandbox/host-check.js +268 -0
  126. package/src/lib/sandbox/mode.js +49 -0
  127. package/src/lib/sandbox/opencode-loopback.mjs +68 -0
  128. package/src/lib/sandbox/seatbelt.js +684 -0
  129. package/src/lib/sandbox/turn-dir.js +56 -0
  130. package/src/lib/sandbox/windows-unsandboxed.js +183 -0
  131. package/src/lib/sandbox/workspace-git-config.js +243 -0
  132. package/src/lib/sandbox/workspace-pin.js +158 -0
  133. package/src/lib/seat-runtime-controls.js +61 -0
  134. package/src/lib/seat-stop.js +97 -0
  135. package/src/lib/server-skills.js +32 -0
  136. package/src/lib/session-recycle.js +219 -0
  137. package/src/lib/session-store.js +215 -0
  138. package/src/lib/skill-prompt.js +74 -0
  139. package/src/lib/skills-import.js +113 -0
  140. package/src/lib/spawn-credential.js +402 -0
  141. package/src/lib/spawn-retry.js +311 -0
  142. package/src/lib/stable-install.js +176 -0
  143. package/src/lib/supervised-turn.js +352 -0
  144. package/src/lib/task.js +235 -0
  145. package/src/lib/turn-failure-notice.js +177 -0
  146. package/src/lib/unconfined-seat-guard.js +94 -0
  147. package/src/lib/update-check.js +192 -0
  148. package/src/lib/upstream-refusal.js +1 -0
  149. package/src/lib/webhook-server.js +109 -0
  150. package/src/lib/windows-command.js +393 -0
  151. package/src/lib/workspace-git.js +87 -0
  152. package/src/lib/workspace-trust.js +162 -0
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Retry policy for local wrapper event-processing failures (#782).
3
+ *
4
+ * The kernel deliberately re-delivers an event that the wrapper does not
5
+ * acknowledge. That preserves at-least-once handling, but a flat poll loop
6
+ * turns a model-provider outage into repeated subprocess launches. Keep the
7
+ * retry policy here so every adapter gets the same bounded behavior.
8
+ */
9
+
10
+ import { PUBLIC_MCP_NOT_READY_CODE, PUBLIC_TOOL_FAILED_CODE } from './public-tool-error.js';
11
+ import { PUBLIC_CODEX_VERSION_CODE } from './adapters/tested-version.js';
12
+ import { modelUnavailableFailure } from './model-unavailable.js';
13
+
14
+ export const SPAWN_FAILURE_CLASS = Object.freeze({
15
+ // The provider refused the request's CONTENT. Re-sending the same content
16
+ // can only be refused again, so this class is never retried: the run loop
17
+ // acks the event as refused instead (lib/content-refusal.js).
18
+ CONTENT_REFUSED: 'content_refused',
19
+ QUOTA: 'quota',
20
+ RATE_LIMIT: 'rate_limit',
21
+ CONFIGURATION: 'configuration',
22
+ RUNTIME: 'runtime',
23
+ // The spawn never started: no transcript row, stdout or stderr inside the
24
+ // first-output window (lib/first-output-watchdog.js). A host condition such as
25
+ // a locked console, so the circuit opens at once and the probe waits.
26
+ STALLED: 'stalled',
27
+ });
28
+
29
+ // A stalled probe costs one first-output window, so the wait starts short and
30
+ // doubles to the usual ceiling: 2, 4, 8, then 15 minutes.
31
+ export const SPAWN_STALL_RETRY_BASE_MS = 2 * 60 * 1000;
32
+
33
+ export const SPAWN_CIRCUIT_THRESHOLD = 3;
34
+ // Base ceiling before the stable per-agent 0–20% anti-herd offset.
35
+ export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
36
+ export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
37
+
38
+ // This list is a per-provider allowlist, and it only ever grows after an
39
+ // outage has already been misclassified. Twice now:
40
+ // 2026-08-03 codex "Your workspace is out of credits." → `out of credits`
41
+ // 2026-08-18 claude "You've hit your session limit" → `session limit`
42
+ // 2026-08-25 claude "You've hit your weekly limit" → the phrase below
43
+ // The second one is the instructive failure: `usage limit` was already here —
44
+ // it is Claude's OTHER exhaustion wording — so the fleet stalled for an hour on
45
+ // a string one word away from a pattern we had. Both times the miss meant
46
+ // RUNTIME, the weakest class with the shortest backoff, against a provider that
47
+ // was not going to answer for hours.
48
+ //
49
+ // What a miss costs, measured against these constants (intervalMs 5000,
50
+ // jitter 0; `*` = circuit open):
51
+ //
52
+ // quota n=1,2,3 -> 900s* 900s* 900s*
53
+ // configuration n=1,2,3 -> 900s* 900s* 900s*
54
+ // rate_limit n=1,2,3 -> 60s* 120s* 240s*
55
+ // runtime n=1,2,3 -> 5s 10s 60s*
56
+ //
57
+ // So an unmatched wording is not one class off — it is 180x faster on the
58
+ // first retry than the class it belonged in, and the only class that does not
59
+ // open the circuit at n=1. That is the price of a missing string, and it is
60
+ // why the entry below is a list of exact wordings rather than a loose pattern.
61
+ //
62
+ // Note RUNTIME is also `classifySpawnFailure`'s fallthrough, so "unrecognised"
63
+ // and "transient local fault" resolve to the same, most aggressive schedule.
64
+ // That is a structural issue rather than a vocabulary one — see #996.
65
+ //
66
+ // Deliberately NOT loosened to a bare `limit`: QUOTA is tested before
67
+ // RATE_LIMIT, so that would swallow every "rate limit" error into the 15-minute
68
+ // cooldown. Add exact wordings, not looser ones.
69
+ // Measured 2026-10-16 from a codex seat (gpt-6-astra over the ChatGPT login),
70
+ // on a security-gate request carrying SSRF/exfil/ReDoS test cases: "codex turn
71
+ // failed: This content was flagged for possible cybersecurity risk. If this
72
+ // seems wrong, try rephrasing your request. If you're doing authorized security
73
+ // work … apply for Daybreak access …". Classed as `runtime`, it was re-sent on
74
+ // backoff 5 times in 52 minutes, every one refused, and the seat handled
75
+ // nothing else until the kernel retired the event. Only the refusal wordings
76
+ // themselves are matched, so an ordinary failure that mentions a flag is not.
77
+ const CONTENT_REFUSED_RE = /(?:flagged for possible cybersecurity risk|this content was flagged|content[_ ]policy[_ ]violation|cyber[_ ]policy)/i;
78
+ // pi writes its own line for a provider's content stop (pi-ai 0.84.1:
79
+ // openai-completions.js, anthropic-messages.js) as the whole errorMessage, so
80
+ // only the pi failure that IS that line matches. Anywhere else the wording is
81
+ // text someone wrote, such as a seat quoting this file, and a match is terminal.
82
+ const PI_CONTENT_STOP_RE = /^pi exited with code \d+(?: \(upstream \d+\))?: Provider (?:finish_reason: content_filter|stopped with: sensitive)$/;
83
+ // The third miss is why there is now a PHRASE as well as a word list, and it is
84
+ // a different failure from the first two. Measured across the fleet's logs on
85
+ // 2026-08-25: 283 failures reading "You've hit your weekly limit", 55 reading
86
+ // "You've hit your session limit" (matched), and 6 reading "You've reached your
87
+ // Fable 5 limit" (not matched). Three wordings, one sentence shape, and the list
88
+ // had caught exactly one of them. Enumerating per-wording keeps failing because
89
+ // the variable part is a BILLING PERIOD or a MODEL NAME, both of which the
90
+ // providers keep adding, so a list is structurally one release behind.
91
+ //
92
+ // `QUOTA_POSSESSIVE_RE` matches that shape and nothing looser: the subject must
93
+ // be the caller's own allowance ("you've hit/reached YOUR ... limit"), which is
94
+ // what distinguishes exhaustion from a server-side throttle. The negative
95
+ // lookahead keeps "you've hit your rate limit" out — QUOTA is tested first, and
96
+ // without it that string would take the 15-minute cooldown instead of the
97
+ // 60-second rate-limit backoff. The length bound stops it spanning sentences.
98
+ //
99
+ // The character class admits a dot ONLY when a digit follows it, and that
100
+ // carve-out is load-bearing rather than cosmetic (sprint-review, 2026-08-25):
101
+ // a plain `[^.\n]` reads as "stay inside one sentence" and silently also means
102
+ // "no model names". Every model we run is dotted, so "you've reached your Haiku
103
+ // 4.5 limit" fell through to RUNTIME, the shortest backoff — the class excluded
104
+ // the exact variable part this paragraph says keeps changing.
105
+ //
106
+ // Known limit of `\.(?=\d)`, kept rather than papered over: a period with a
107
+ // digit immediately after it is admitted even at a sentence boundary, so
108
+ // "You've reached your goal.5 things have no limit" classes as QUOTA. The space
109
+ // in ordinary prose ("goal. 5 things") is what terminates the match, and
110
+ // adjacent `.`+digit is rare enough in provider copy to accept. The test below
111
+ // pins what the pattern ACTUALLY has — a space after the period ends the match —
112
+ // rather than the stronger property it does not have.
113
+ // The gateway's two budget refusals. A key over its budget answers 422 with this
114
+ // exact text (walked on a real key, 2026-11-06); a model group over its monthly
115
+ // pool answers 429 "crossed budget" (our gateway config). Case-sensitive, so a
116
+ // "thinking budget has been exceeded" stays out. Unmatched, the 422 is RUNTIME
117
+ // and the seat respawns every few seconds against a key that stays refused.
118
+ export const GATEWAY_BUDGET_RE = /Budget has been exceeded!|crossed budget/;
119
+ // `credit_balance_exhausted` is OpenAI's code for a prepaid balance at zero, sent
120
+ // as a 429. `credit balance` has a space, so it needs its own entry. OpenAI:
121
+ // "Retrying billing, spend, or quota errors won't restore API access."
122
+ // This reaches the code only while it's in the text, as a bare code or a body
123
+ // kept whole. A body the keep-list reduces keeps its message and moves the code
124
+ // to `error.providerCode`, which isn't read here because it can come from a
125
+ // tool's JSON. That refusal stays RATE_LIMIT and probes in 60 seconds.
126
+ const QUOTA_RE = /(?:quota|usage limit|session limit|weekly limit|credit balance|credit_balance_exhausted|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
127
+ const QUOTA_POSSESSIVE_RE = /you'?ve (?:hit|reached) your (?!rate[ -]?limit)(?:[^.\n]|\.(?=\d)){0,40}?limit/i;
128
+ // The status code is not a throttle wording: "429: You've reached your Fable 5
129
+ // limit" is an exhausted allowance that a provider reports as HTTP 429.
130
+ const THROTTLE_WORDING_RE = /(?:rate[ -]?limit|too many requests|overloaded|capacity)/i;
131
+ const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
132
+ const CONFIGURATION_RE = /(?:ENOENT|command not found|not on PATH|login required|not logged in|invalid api key|authentication failed|unauthori[sz]ed|forbidden|\b40[13]\b)/i;
133
+
134
+ const CONNECTION_FAULT_RE = /(?:ECONNRESET|ECONNREFUSED|ETIMEDOUT|EPIPE|socket hang up|fetch failed)/;
135
+
136
+ const errorText = (error) => [
137
+ error?.message,
138
+ error?.stderr,
139
+ error?.body?.error,
140
+ error?.body?.message,
141
+ ]
142
+ .filter(Boolean)
143
+ .map(String)
144
+ .join('\n');
145
+
146
+ export const classifySpawnFailure = (error) => {
147
+ const text = errorText(error);
148
+ // A public seat's withheld turn (TASK-256) quotes the tool's own error, which
149
+ // is vendor text: the code decides, so the event stays unacked for retry and
150
+ // is never acked as refused or parked behind a quota or configuration
151
+ // cooldown by that text. A throttle keeps its backoff, which also retries.
152
+ if (error?.code === PUBLIC_TOOL_FAILED_CODE || error?.code === PUBLIC_MCP_NOT_READY_CODE) {
153
+ return error?.status === 429 || RATE_LIMIT_RE.test(text)
154
+ ? SPAWN_FAILURE_CLASS.RATE_LIMIT
155
+ : SPAWN_FAILURE_CLASS.RUNTIME;
156
+ }
157
+ // Only whoever runs the seat can change this, so it waits as a missing CLI does.
158
+ if (error?.code === PUBLIC_CODEX_VERSION_CODE) return SPAWN_FAILURE_CLASS.CONFIGURATION;
159
+ // First: the refusal text carries none of the words below today, but a
160
+ // refusal must never be read as a transient condition and retried.
161
+ if (CONTENT_REFUSED_RE.test(text) || PI_CONTENT_STOP_RE.test(String(error?.message || '').trim())) {
162
+ return SPAWN_FAILURE_CLASS.CONTENT_REFUSED;
163
+ }
164
+ if (error?.code === 'spawn_stalled') return SPAWN_FAILURE_CLASS.STALLED;
165
+ // Provider APIs commonly report an exhausted account quota as HTTP 429.
166
+ // Prefer the more specific body/message over the generic status code so a
167
+ // hard quota failure gets the long cooldown rather than a one-minute probe.
168
+ if (QUOTA_RE.test(text) || GATEWAY_BUDGET_RE.test(text)) return SPAWN_FAILURE_CLASS.QUOTA;
169
+ // The possessive shape is deliberately loose about what sits between "your"
170
+ // and "limit", and its `(?!rate[ -]?limit)` lookahead only guards the word
171
+ // IMMEDIATELY after "your" — so "you've hit your API rate limit" walked past
172
+ // it into the 15-minute circuit, where main correctly gives the 60-second
173
+ // rate-limit probe (gate on #2491; it was a regression, not a gap).
174
+ // A throttle wording anywhere in the text therefore wins over the shape.
175
+ // QUOTA_RE is still tested first, so an explicit quota word keeps QUOTA even
176
+ // when the same message also mentions rate limits.
177
+ if (QUOTA_POSSESSIVE_RE.test(text) && !THROTTLE_WORDING_RE.test(text)) {
178
+ return SPAWN_FAILURE_CLASS.QUOTA;
179
+ }
180
+ if (error?.status === 429 || RATE_LIMIT_RE.test(text)) {
181
+ return SPAWN_FAILURE_CLASS.RATE_LIMIT;
182
+ }
183
+ // A model the provider won't run fails the same way until someone changes it.
184
+ // A 403 for that reads as configuration below; a 404 has no status of its own
185
+ // there, so without this it retried in seconds against the same answer. A
186
+ // turn's text can hold a tool's 404 from before a dropped connection killed
187
+ // the turn, so a connection fault keeps the quick retry.
188
+ if (
189
+ error?.code === 'ENOENT'
190
+ || error?.status === 401
191
+ || error?.status === 403
192
+ || CONFIGURATION_RE.test(text)
193
+ || (modelUnavailableFailure(error) && !CONNECTION_FAULT_RE.test(text))
194
+ ) {
195
+ return SPAWN_FAILURE_CLASS.CONFIGURATION;
196
+ }
197
+ return SPAWN_FAILURE_CLASS.RUNTIME;
198
+ };
199
+
200
+ // A stable per-agent offset keeps a fleet from probing a recovering provider
201
+ // in lockstep. Stability matters: random jitter makes operator logs and tests
202
+ // harder to reason about, while distinct agent names already provide entropy.
203
+ export const spawnRetryJitter = (agentName) => {
204
+ let hash = 2166136261;
205
+ for (const char of String(agentName || 'agent')) {
206
+ hash ^= char.charCodeAt(0);
207
+ hash = Math.imul(hash, 16777619);
208
+ }
209
+ return ((hash >>> 0) % 2001) / 10000;
210
+ };
211
+
212
+ const applyJitter = (delayMs, jitterRatio) => {
213
+ const safeJitter = Number.isFinite(jitterRatio)
214
+ ? Math.min(SPAWN_RETRY_JITTER_MAX_RATIO, Math.max(0, jitterRatio))
215
+ : 0;
216
+ return Math.round(delayMs * (1 + safeJitter));
217
+ };
218
+
219
+ /**
220
+ * Return the next probe delay and whether the circuit is open.
221
+ *
222
+ * Known non-transient failures open immediately. Unknown runtime failures get
223
+ * two quick retries, then open the circuit on the third consecutive failure.
224
+ * Later probes back off exponentially to the same 15-minute base ceiling.
225
+ */
226
+ export const spawnRetryPolicy = ({
227
+ error,
228
+ consecutiveFailures,
229
+ intervalMs,
230
+ jitterRatio = 0,
231
+ }) => {
232
+ const failureClass = classifySpawnFailure(error);
233
+ const safeIntervalMs = Number.isFinite(intervalMs) && intervalMs > 0 ? intervalMs : 5000;
234
+ const failureCount = Number.isInteger(consecutiveFailures) && consecutiveFailures > 0
235
+ ? consecutiveFailures
236
+ : 1;
237
+
238
+ if (
239
+ failureClass === SPAWN_FAILURE_CLASS.QUOTA
240
+ || failureClass === SPAWN_FAILURE_CLASS.CONFIGURATION
241
+ ) {
242
+ return {
243
+ failureClass,
244
+ circuitOpen: true,
245
+ delayMs: applyJitter(SPAWN_RETRY_MAX_MS, jitterRatio),
246
+ };
247
+ }
248
+
249
+ if (failureClass === SPAWN_FAILURE_CLASS.STALLED) {
250
+ return {
251
+ failureClass,
252
+ circuitOpen: true,
253
+ delayMs: applyJitter(
254
+ Math.min(SPAWN_RETRY_MAX_MS, SPAWN_STALL_RETRY_BASE_MS * (2 ** (failureCount - 1))),
255
+ jitterRatio,
256
+ ),
257
+ };
258
+ }
259
+
260
+ if (failureClass === SPAWN_FAILURE_CLASS.RATE_LIMIT) {
261
+ return {
262
+ failureClass,
263
+ circuitOpen: true,
264
+ delayMs: applyJitter(
265
+ Math.min(
266
+ SPAWN_RETRY_MAX_MS,
267
+ 60 * 1000 * (2 ** (failureCount - 1)),
268
+ ),
269
+ jitterRatio,
270
+ ),
271
+ };
272
+ }
273
+
274
+ if (failureCount < SPAWN_CIRCUIT_THRESHOLD) {
275
+ return {
276
+ failureClass,
277
+ circuitOpen: false,
278
+ delayMs: applyJitter(
279
+ Math.min(
280
+ SPAWN_RETRY_MAX_MS,
281
+ safeIntervalMs * (2 ** (failureCount - 1)),
282
+ ),
283
+ jitterRatio,
284
+ ),
285
+ };
286
+ }
287
+
288
+ return {
289
+ failureClass,
290
+ circuitOpen: true,
291
+ delayMs: applyJitter(
292
+ Math.min(
293
+ SPAWN_RETRY_MAX_MS,
294
+ 60 * 1000 * (2 ** (failureCount - SPAWN_CIRCUIT_THRESHOLD)),
295
+ ),
296
+ jitterRatio,
297
+ ),
298
+ };
299
+ };
300
+
301
+ export const formatRetryDelay = (delayMs) => {
302
+ if (delayMs >= 60000) {
303
+ const minutes = delayMs / 60000;
304
+ return `${Number.isInteger(minutes) ? minutes : minutes.toFixed(1)}m`;
305
+ }
306
+ if (delayMs >= 1000) {
307
+ const seconds = delayMs / 1000;
308
+ return `${Number.isInteger(seconds) ? seconds : seconds.toFixed(1)}s`;
309
+ }
310
+ return `${delayMs}ms`;
311
+ };
@@ -0,0 +1,176 @@
1
+ /**
2
+ * F370 (ADR-026): a stable, user-owned install of @commonlyai/cli, so the
3
+ * daemon-first one-paste works from `npx` with no global install.
4
+ *
5
+ * Why this exists. `commonly daemon install` pins the launchd/systemd/schtasks
6
+ * service to the EXACT interpreter + CLI entry that ran it (daemon-service.js,
7
+ * resolved at install time). Run from the npx cache (`~/.npm/_npx/<hash>`),
8
+ * that entry is shared across every npx run and evictable — `npm cache clean`
9
+ * or ordinary reclamation deletes it, and the daemon then fails to start at the
10
+ * next boot with nothing on screen to say why. `npm i -g` keeps a stable path
11
+ * but is EACCES on a stock-Node Mac (the nodejs.org .pkg, no version manager)
12
+ * and needs sudo — the exact wall that cost a first-time user an hour.
13
+ *
14
+ * So `daemon setup` installs the CLI UNDER $HOME — no sudo, no EACCES, no npx
15
+ * cache — and runs `daemon install` AS that stable copy, which pins the service
16
+ * there. The daemon then spawns its seats with its own argv (daemon.js `run`),
17
+ * so the seats are pinned to the same stable CLI by construction.
18
+ *
19
+ * Side effects are injected so the install decision and the setup sequence are
20
+ * unit-testable without touching npm or the network.
21
+ */
22
+ import { spawn } from 'child_process';
23
+ import { existsSync, mkdirSync, readFileSync } from 'fs';
24
+ import { homedir } from 'os';
25
+ import { join, sep } from 'path';
26
+ import { resolveLaunch } from './windows-command.js';
27
+ import { NPX } from './cli-hint.js';
28
+
29
+ export const CLI_PACKAGE = '@ravefox/space';
30
+
31
+ // The daemon's adapters (claude, codex) need a current Node; below this the
32
+ // setup fails later with a cryptic error, so it is named up front instead.
33
+ export const MIN_NODE_MAJOR = 22;
34
+
35
+ // One stable copy, one version at a time. `daemon setup` keeps it at the
36
+ // version that invoked it, so what runs resident is what the user installed.
37
+ export const stableCliDir = (home = homedir()) => join(home, '.commonly', 'cli');
38
+ /** The one line that sets up or updates a computer, for advice the CLI prints. */
39
+ export const DAEMON_SETUP_LINE = `${NPX} -y ${CLI_PACKAGE}@latest daemon setup`;
40
+
41
+ /** True when this process runs the copy `daemon setup` installed under $HOME. */
42
+ export const runsFromStableInstall = (entry = process.argv[1], home = homedir()) => (
43
+ // The folder plus a separator, so a sibling such as ~/.commonly/cli-old never matches.
44
+ Boolean(entry) && String(entry).startsWith(`${stableCliDir(home)}${sep}`)
45
+ );
46
+
47
+ export const stableCliEntry = (home = homedir()) => (
48
+ join(stableCliDir(home), 'node_modules', CLI_PACKAGE, 'src', 'index.js')
49
+ );
50
+
51
+ /** The version installed at the stable path, or null when it is not there. */
52
+ export const stableInstalledVersion = (home = homedir(), { readFile = readFileSync, exists = existsSync } = {}) => {
53
+ const manifest = join(stableCliDir(home), 'node_modules', CLI_PACKAGE, 'package.json');
54
+ if (!exists(manifest)) return null;
55
+ try {
56
+ return JSON.parse(readFile(manifest, 'utf8')).version || null;
57
+ } catch {
58
+ return null;
59
+ }
60
+ };
61
+
62
+ /** A message when this Node is too old for the daemon, or null when it is fine. */
63
+ export const nodeTooOld = (version = process.version, min = MIN_NODE_MAJOR) => {
64
+ const major = Number.parseInt(String(version).replace(/^v/, '').split('.')[0], 10);
65
+ if (!Number.isFinite(major) || major >= min) return null;
66
+ return `Node ${version} is too old for the Space daemon, which needs Node ${min} or newer. `
67
+ + 'Install the current Node from nodejs.org, or with a version manager such as nvm, then run this again.';
68
+ };
69
+
70
+ // Spawn one `npm` with the Windows-safe launcher the adapters use (npm is
71
+ // npm.cmd there, which Node cannot spawn without a shell). Inherits stdio so
72
+ // the one-time install is visible; rejects on a non-zero exit.
73
+ const runNpm = (args, { cwd, stdio = 'inherit', spawnImpl = spawn, resolution } = {}) => new Promise((resolve, reject) => {
74
+ let launch;
75
+ try {
76
+ launch = resolveLaunch('npm', args, { cwd, ...resolution });
77
+ } catch (error) {
78
+ reject(error);
79
+ return;
80
+ }
81
+ const child = spawnImpl(launch.command, launch.args, { cwd, stdio, env: process.env });
82
+ child.on('error', reject);
83
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`npm ${args[0] || ''} exited with code ${code}`))));
84
+ });
85
+
86
+ /**
87
+ * Ensure @commonlyai/cli@<version> is installed under $HOME and return the
88
+ * stable CLI entry to pin the daemon to. Idempotent: a stable copy already at
89
+ * <version> is left as is.
90
+ */
91
+ export const ensureStableCliInstall = async ({
92
+ version,
93
+ home = homedir(),
94
+ mkdir = (dir) => mkdirSync(dir, { recursive: true }),
95
+ installedVersion = stableInstalledVersion,
96
+ runInstall = runNpm,
97
+ log = () => {},
98
+ } = {}) => {
99
+ if (!version) throw new Error('ensureStableCliInstall needs the version to pin');
100
+ const dir = stableCliDir(home);
101
+ const entry = stableCliEntry(home);
102
+ if (installedVersion(home) === version) {
103
+ return { entry, dir, version, installed: false };
104
+ }
105
+ mkdir(dir);
106
+ log(`Installing ${CLI_PACKAGE}@${version} under ${dir} (no global install, no sudo)…`);
107
+ // --prefix keeps it a local install under $HOME; the quiet flags keep the
108
+ // one-time setup output readable.
109
+ await runInstall(['install', '--prefix', dir, '--no-audit', '--no-fund', `${CLI_PACKAGE}@${version}`], { cwd: dir });
110
+ return { entry, dir, version, installed: true };
111
+ };
112
+
113
+ /**
114
+ * The daemon-first one-paste: a stable install, then login → register → install
115
+ * run AS the stable CLI so the service is pinned there (and the seats with it).
116
+ * `runCli(entry, args)` runs the stable CLI once and rejects on a non-zero exit.
117
+ */
118
+ export const orchestrateDaemonSetup = async ({
119
+ version,
120
+ instanceUrl,
121
+ runCli,
122
+ unsandboxed = false,
123
+ // Resolves true when this computer already holds an active registration for
124
+ // the instance. Setup then skips login and register, so re-running it is the
125
+ // way to update the CLI and restart the daemon.
126
+ alreadyRegistered = async () => false,
127
+ // Resolves a refusal when this computer account already runs another HOME's
128
+ // daemon (daemon-service.js foreignServiceRefusal). Checked before login, so
129
+ // a refused setup registers nothing. --replace skips it and is forwarded.
130
+ foreignDaemon = async () => null,
131
+ replace = false,
132
+ platform = process.platform,
133
+ nodeVersion = process.version,
134
+ ensureStable = ensureStableCliInstall,
135
+ home = homedir(),
136
+ log = () => {},
137
+ }) => {
138
+ const tooOld = nodeTooOld(nodeVersion);
139
+ if (tooOld) throw new Error(tooOld);
140
+ if (typeof runCli !== 'function') throw new Error('orchestrateDaemonSetup needs a runCli function');
141
+ // --replace can't take over a Linux daemon (daemon-service.js), so the
142
+ // check runs there either way.
143
+ if (!replace || platform === 'linux') {
144
+ const refusal = await foreignDaemon();
145
+ if (refusal) throw new Error(refusal);
146
+ }
147
+ const { entry } = await ensureStable({ version, home, log });
148
+ // One spelling for both steps: login saves the URL without trailing slashes,
149
+ // so register must look it up the same way or it can't find the token.
150
+ const instance = instanceUrl ? String(instanceUrl).replace(/\/+$/, '') : '';
151
+ const instanceArgs = instance ? ['--instance', instance] : [];
152
+ // Each step must land before the next: a failed login must not register, a
153
+ // failed register must not install. runCli rejects on a non-zero exit.
154
+ if (await alreadyRegistered(instance)) {
155
+ log('This computer is already registered here, so setup skips login and register, '
156
+ + 'and reinstalls the daemon on the CLI it just installed.');
157
+ } else {
158
+ await runCli(entry, ['login', ...instanceArgs]);
159
+ await runCli(entry, ['daemon', 'register', ...instanceArgs]);
160
+ }
161
+ // --unsandboxed is forwarded to the install step. Without it, native Windows
162
+ // clears the opt-in and the daemon's seats are refused at run time.
163
+ const installArgs = ['daemon', 'install', ...(unsandboxed ? ['--unsandboxed'] : []), ...(replace ? ['--replace'] : [])];
164
+ try {
165
+ await runCli(entry, installArgs);
166
+ } catch (error) {
167
+ // The computer is registered by now. Name the one step left, in a form the
168
+ // user's shell runs as pasted: PowerShell needs `&` before a quoted program.
169
+ const call = `${platform === 'win32' ? '& ' : ''}"${process.execPath}" "${entry}" ${installArgs.join(' ')}`;
170
+ throw new Error(`${error.message}. This computer is registered; after fixing the error above, `
171
+ + `finish with: ${call} (or run the setup line again)`);
172
+ }
173
+ log('This computer is set up: the daemon is running and starts when you log in. '
174
+ + 'Add an agent to it from the web app and it comes online here.');
175
+ return { entry };
176
+ };