@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
package/src/lib/api.js ADDED
@@ -0,0 +1,205 @@
1
+ /**
2
+ * CAP HTTP client — thin wrapper over fetch.
3
+ *
4
+ * Every method reads the active instance URL and token from config
5
+ * unless overridden. This is the only place that makes HTTP calls.
6
+ */
7
+
8
+ import { resolveInstanceUrl, getToken, resolveInstance } from './config.js';
9
+ import { CLI } from './cli-hint.js';
10
+
11
+ const headers = (token, extra = {}) => ({
12
+ 'Content-Type': 'application/json',
13
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
14
+ ...extra,
15
+ });
16
+
17
+ const knownSessionFailure = (body) => [body?.msg, body?.error, body?.message]
18
+ .some((value) => ['Token is not valid', 'Invalid API token', 'Account no longer exists'].includes(value));
19
+
20
+ export const sessionExpiredMessage = ({ instanceKey, baseUrl }) => (
21
+ `Session for ${instanceKey} (${baseUrl}) has expired.\nRun: ${CLI} login --instance ${instanceKey}`
22
+ );
23
+
24
+ // A non-JSON error body is usually an edge proxy's HTML page; a Cloudflare
25
+ // tunnel error is ~120 lines, and the message used to be all of them, so every
26
+ // failed poll dumped a whole page into the agent log. The message names the
27
+ // page by its <title>; err.body keeps the full text for callers that classify it.
28
+ const MAX_TEXT_ERROR = 300;
29
+ const summarizeTextError = (text, status) => {
30
+ const trimmed = String(text || '').trim();
31
+ if (!trimmed) return null;
32
+ if (/^<(?:!doctype|html)/i.test(trimmed) || /<html[\s>]/i.test(trimmed)) {
33
+ const title = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1]?.replace(/\s+/g, ' ').trim();
34
+ return title ? `HTTP ${status}: ${title}` : `HTTP ${status} (HTML error page)`;
35
+ }
36
+ const line = trimmed.replace(/\s+/g, ' ');
37
+ return line.length > MAX_TEXT_ERROR ? `${line.slice(0, MAX_TEXT_ERROR)}…` : line;
38
+ };
39
+
40
+ const handleResponse = async (res, session = null) => {
41
+ const text = await res.text();
42
+ let body;
43
+ let isJson = true;
44
+ try { body = JSON.parse(text); } catch { body = { message: text }; isJson = false; }
45
+ if (!res.ok) {
46
+ const serverMessage = isJson
47
+ ? body?.error || body?.message || body?.msg
48
+ : summarizeTextError(text, res.status);
49
+ const msg = res.status === 401 && session && knownSessionFailure(body)
50
+ ? sessionExpiredMessage(session)
51
+ : serverMessage || `HTTP ${res.status}`;
52
+ const err = new Error(msg);
53
+ err.status = res.status;
54
+ err.body = body;
55
+ throw err;
56
+ }
57
+ return body;
58
+ };
59
+
60
+ // TASK-338: every CAP request carries a hard timeout. Without one, a request
61
+ // that a proxy or the network accepts and never answers leaves its promise
62
+ // pending for good, and the run loop awaiting it is alive but wedged: nib and
63
+ // folio each sat 8+ hours on one unanswered poll on 10-31, and their keepalives,
64
+ // which respawn only on exit, could not see it. The poll-retry backoff
65
+ // (TASK-025) only handles a fetch that throws, so a timeout throws: it is an
66
+ // ordinary failed request, retried with backoff, and never an ack.
67
+ //
68
+ // Reads get the base timeout; writes and uploads carry a body and a server-side
69
+ // write, so they get a multiple of it. COMMONLY_CAP_TIMEOUT_MS sets the base.
70
+ export const CAP_TIMEOUT_DEFAULT_MS = 30_000;
71
+ const TIMEOUT_SCALE = { read: 1, write: 2, upload: 4 };
72
+
73
+ export const capTimeoutMs = (kind, { baseMs, env = process.env } = {}) => {
74
+ const fromEnv = Number(env.COMMONLY_CAP_TIMEOUT_MS);
75
+ const base = [baseMs, fromEnv].find((value) => Number.isFinite(value) && value > 0)
76
+ ?? CAP_TIMEOUT_DEFAULT_MS;
77
+ return Math.min(base * (TIMEOUT_SCALE[kind] || 1), 2_147_483_647);
78
+ };
79
+
80
+ // A caller's own signal (doctor, and the 5s identity and skills reads in the
81
+ // run loop) still applies; the hard timeout is added beside it, never instead.
82
+ // AbortSignal.any is Node 20.3+, and `engines` allows 20.0, so the fallback
83
+ // joins the two by hand. Either way the abort carries the source's own reason,
84
+ // which is what nameTimeout matches on.
85
+ // The fallback's listeners are removed when the request settles, so a caller
86
+ // that reuses one long-lived signal does not collect one per request.
87
+ const capSignal = (capTimeout, callerSignal) => {
88
+ if (!callerSignal) return { signal: capTimeout, release: () => {} };
89
+ if (typeof AbortSignal.any === 'function') {
90
+ return { signal: AbortSignal.any([callerSignal, capTimeout]), release: () => {} };
91
+ }
92
+ const joined = new AbortController();
93
+ const listeners = [];
94
+ for (const source of [callerSignal, capTimeout]) {
95
+ if (source.aborted) {
96
+ joined.abort(source.reason);
97
+ break;
98
+ }
99
+ const onAbort = () => joined.abort(source.reason);
100
+ source.addEventListener('abort', onAbort, { once: true });
101
+ listeners.push([source, onAbort]);
102
+ }
103
+ return {
104
+ signal: joined.signal,
105
+ release: () => listeners.forEach(([source, onAbort]) => source.removeEventListener('abort', onAbort)),
106
+ };
107
+ };
108
+
109
+ export const formatLimit = (ms) => (ms < 1000 ? `${ms}ms` : `${Math.round(ms / 1000)}s`);
110
+
111
+ // The cap's timeout rejects with a DOMException named TimeoutError, from the
112
+ // fetch or from the body read. Name the request and the limit instead, so the
113
+ // log line reads as an unanswered request rather than "operation was aborted".
114
+ // Only the cap's own timeout is renamed: a caller's abort or its own shorter
115
+ // timeout passes through as it was thrown.
116
+ const nameTimeout = (capTimeout, method, path, ms) => (err) => {
117
+ // By identity: the caller's timeout can fire first and the cap's a moment
118
+ // later, and then `capTimeout.aborted` alone would claim the caller's error.
119
+ if (err?.name !== 'TimeoutError' || err !== capTimeout.reason) throw err;
120
+ throw Object.assign(
121
+ new Error(`${method} ${path} got no answer in ${formatLimit(ms)}`),
122
+ { code: 'cap_timeout', timeoutMs: ms },
123
+ );
124
+ };
125
+
126
+ const timedFetch = (url, init, { method, path, ms, session }) => {
127
+ const capTimeout = AbortSignal.timeout(ms);
128
+ const { signal, release } = capSignal(capTimeout, init.signal);
129
+ return fetch(url, { ...init, signal })
130
+ .then((res) => handleResponse(res, session))
131
+ .catch(nameTimeout(capTimeout, method, path, ms))
132
+ .finally(release);
133
+ };
134
+
135
+ export const createClient = ({ instance = null, token = undefined, timeoutMs = undefined } = {}) => {
136
+ const baseUrl = resolveInstanceUrl(instance);
137
+ const resolved = resolveInstance(instance);
138
+ const authToken = token === undefined ? getToken(instance) : token;
139
+ const session = {
140
+ instanceKey: resolved?.key || (typeof instance === 'string' && instance && !/^https?:\/\//i.test(instance) ? instance : 'default'),
141
+ baseUrl,
142
+ };
143
+ const limit = (kind) => capTimeoutMs(kind, { baseMs: timeoutMs });
144
+
145
+ const get = (path, params = {}, options = {}) => {
146
+ const url = new URL(`${baseUrl}${path}`);
147
+ Object.entries(params).forEach(([k, v]) => v != null && url.searchParams.set(k, v));
148
+ return timedFetch(url.toString(), { headers: headers(authToken), ...options }, {
149
+ method: 'GET', path, ms: limit('read'), session,
150
+ });
151
+ };
152
+
153
+ const post = (path, body = {}) => timedFetch(`${baseUrl}${path}`, {
154
+ method: 'POST',
155
+ headers: headers(authToken),
156
+ body: JSON.stringify(body),
157
+ }, { method: 'POST', path, ms: limit('write'), session });
158
+
159
+ const patch = (path, body = {}) => timedFetch(`${baseUrl}${path}`, {
160
+ method: 'PATCH',
161
+ headers: headers(authToken),
162
+ body: JSON.stringify(body),
163
+ }, { method: 'PATCH', path, ms: limit('write'), session });
164
+
165
+ const del = (path, body) => timedFetch(`${baseUrl}${path}`, {
166
+ method: 'DELETE',
167
+ headers: headers(authToken),
168
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
169
+ }, { method: 'DELETE', path, ms: limit('write'), session });
170
+
171
+ // Multipart upload via native FormData/Blob (Node 18+) — no runtime deps.
172
+ // Content-Type is deliberately NOT set: fetch writes the multipart boundary.
173
+ const upload = (path, {
174
+ fileBuffer, fileName, contentType, fileField = 'file', fields = {},
175
+ }) => {
176
+ const form = new FormData();
177
+ form.append(
178
+ fileField,
179
+ new Blob([fileBuffer], { type: contentType || 'application/octet-stream' }),
180
+ fileName,
181
+ );
182
+ for (const [k, v] of Object.entries(fields)) {
183
+ if (v !== undefined && v !== null) form.append(k, String(v));
184
+ }
185
+ return timedFetch(`${baseUrl}${path}`, {
186
+ method: 'POST',
187
+ headers: authToken ? { Authorization: `Bearer ${authToken}` } : {},
188
+ body: form,
189
+ }, { method: 'POST', path, ms: limit('upload'), session });
190
+ };
191
+
192
+ return {
193
+ get, post, patch, del, upload, baseUrl,
194
+ };
195
+ };
196
+
197
+ // Convenience: login doesn't need a token
198
+ export const login = (instanceUrl, email, password) => {
199
+ const ms = capTimeoutMs('write');
200
+ return timedFetch(`${instanceUrl}/api/auth/login`, {
201
+ method: 'POST',
202
+ headers: { 'Content-Type': 'application/json' },
203
+ body: JSON.stringify({ email, password }),
204
+ }, { method: 'POST', path: '/api/auth/login', ms, session: null });
205
+ };
@@ -0,0 +1,128 @@
1
+ /**
2
+ * claimReleaseFor — the ONE decision about how a finished turn releases its
3
+ * ADR-018 attention claim.
4
+ *
5
+ * It lives here, extracted from two byte-identical copies in the run loop
6
+ * (single-event and batch release), for the reason a second copy is always a
7
+ * risk: the batch path is the one `message.posted` actually reaches, so a
8
+ * change applied to only one of them would pass review and do nothing.
9
+ *
10
+ * Three outcomes, and the KERNEL decides what each one does with the wake
11
+ * (TASK-099 ruling, corrected at 71194/71195/71210):
12
+ *
13
+ * - `refused` — the lease holder could not deliver. Nothing about the
14
+ * agent's CHOICE happened: the model never ran (an upstream
15
+ * 429/502/401, named by TASK-096) or the server refused the
16
+ * post. On a HUMAN wake the kernel hands the message to one
17
+ * remaining listener exactly as it does for a decline — a
18
+ * per-seat budget failure must not make a human's message
19
+ * disappear — and records the refuser so the chain stays
20
+ * finite. On any other wake it is terminal. The routing is
21
+ * the kernel's: this module only names the outcome.
22
+ * - `declined` — a silent, normally completed human broadcast. The agent
23
+ * chose not to answer, or printed nothing (`empty-output`),
24
+ * so the kernel hands it to exactly one remaining original
25
+ * listener.
26
+ * - `completed` — everything else that produced a result: a posted reply, or
27
+ * a skip with a reason (`no-prompt`, `claim-held`,
28
+ * `duplicate-delivery`) where nothing was refused.
29
+ *
30
+ * The refusal CLASS is an enum, not free text: `upstream-refused` (with the
31
+ * HTTP status), `cascade-cap`, `delivery-refused`. The kernel stores the class
32
+ * so refusals are countable, and rejects anything else with a 400. A producer
33
+ * that cannot classify itself maps to `delivery-refused` before sending — the
34
+ * mapping is here, in one place, rather than in each producer.
35
+ *
36
+ * A thrown turn returns no turnResult and releases with no outcome — the
37
+ * legacy holder-only DELETE whose at-least-once redelivery is the correct
38
+ * behaviour for a crashed spawn (the lid closed, the process died).
39
+ */
40
+
41
+ /** The classes the kernel accepts. Mirrors `REFUSAL_REASONS` in agentsRuntime. */
42
+ export const REFUSAL_REASONS = ['upstream-refused', 'cascade-cap', 'delivery-refused'];
43
+
44
+ /** What an unclassifiable refusal is recorded as, rather than a fourth class. */
45
+ export const DEFAULT_REFUSAL_REASON = 'delivery-refused';
46
+
47
+ /**
48
+ * The reason a turn that printed nothing carries. It releases like a bare
49
+ * decline, so a human's message still reaches another listener, but it is not
50
+ * the `NO_REPLY` sentinel and does not mark a digest read.
51
+ */
52
+ export const EMPTY_OUTPUT = 'empty-output';
53
+
54
+ /**
55
+ * The range the kernel accepts for a refusal's `status` — its
56
+ * `MIN_UPSTREAM_STATUS` / `MAX_UPSTREAM_STATUS` in `messageClaimService`.
57
+ * A number outside it is not a refusal status the kernel will record: it
58
+ * answers 400, and the 400 fallback in `enforcement.release` re-releases as
59
+ * `completed` — which is terminal. So forwarding an out-of-range number does
60
+ * not merely lose the number, it loses the handoff: an upstream refusal on a
61
+ * human wake would be recorded as a clean completion. The sender's rule has to
62
+ * be the receiver's rule, which is why the bound lives here as well as there.
63
+ */
64
+ export const MIN_UPSTREAM_STATUS = 400;
65
+ export const MAX_UPSTREAM_STATUS = 599;
66
+
67
+ /**
68
+ * @param {{type?: string, payload?: {senderIsHuman?: boolean}}} event
69
+ * @param {{refused?: {reason?: string, status?: number}|true, outcome?: string, reason?: string}|undefined} turnResult
70
+ * @returns {{outcome?: 'refused'|'declined'|'completed', reason?: string, status?: number}}
71
+ */
72
+ export const claimReleaseFor = (event, turnResult) => {
73
+ if (!turnResult) return { outcome: undefined };
74
+ // `refused: true` is still honoured: it is what an older producer shape (and
75
+ // a third-party wrapper following TASK-096's docs) sends, and it means the
76
+ // same thing — `delivery-refused`, the class that claims the least.
77
+ if (turnResult.refused) {
78
+ const declared = typeof turnResult.refused === 'object' ? turnResult.refused : {};
79
+ const reason = REFUSAL_REASONS.includes(declared.reason)
80
+ ? declared.reason
81
+ : DEFAULT_REFUSAL_REASON;
82
+ // The status is the instance of the class, and only the upstream class has
83
+ // one: a cap refusal or a refused post never saw an upstream HTTP status,
84
+ // so sending its number would put a field on the record that cannot mean
85
+ // what a reader would assume. It is also bounded by what the kernel
86
+ // accepts — an out-of-range number keeps the class and drops only the
87
+ // number, so the refusal still hands off instead of falling back to
88
+ // `completed`.
89
+ const status = reason === 'upstream-refused'
90
+ && Number.isInteger(declared.status)
91
+ && declared.status >= MIN_UPSTREAM_STATUS
92
+ && declared.status <= MAX_UPSTREAM_STATUS
93
+ ? declared.status
94
+ : undefined;
95
+ return {
96
+ outcome: 'refused',
97
+ reason,
98
+ ...(status !== undefined ? { status } : {}),
99
+ };
100
+ }
101
+ if (event?.type === 'message.posted'
102
+ && event.payload?.senderIsHuman === true
103
+ && turnResult.outcome === 'no_action'
104
+ && (!turnResult.reason || turnResult.reason === EMPTY_OUTPUT)) {
105
+ return { outcome: 'declined' };
106
+ }
107
+ return { outcome: 'completed' };
108
+ };
109
+
110
+ /**
111
+ * `refused` is a LOCAL release marker: the producer sets it so the claim
112
+ * release can name the outcome (see `claimReleaseFor`), and nothing else reads
113
+ * it. The event ack is a wire payload with its own readers, and the information
114
+ * that belongs there already reaches them as `reason` — so the marker is
115
+ * dropped at the ack boundary rather than widening the ack with an internal
116
+ * flag. Returns the result unchanged when there is no marker, so the common
117
+ * path never sees a rebuilt object.
118
+ *
119
+ * @param {object|undefined} turnResult
120
+ */
121
+ export const ackResultFor = (turnResult) => {
122
+ if (!turnResult || !turnResult.refused) return turnResult;
123
+ // ts-eslint's `arg`/`vars` ignore pattern is `^_`, and ignoreRestSiblings is
124
+ // off in this repo's config — so the dropped key is renamed rather than
125
+ // left to trip no-unused-vars.
126
+ const { refused: _refused, ...rest } = turnResult;
127
+ return rest;
128
+ };
@@ -0,0 +1,73 @@
1
+ // Defer-while-claimed (TASK-640). While a seat holds a claimed board row in a
2
+ // pod, a broadcast wake there that no person sent and that does not concern
3
+ // its row is acked without a spawn. Measured on one seat, 2026-10-30: most of
4
+ // its hourly wake-mode turns were pod broadcasts while it held one row, and
5
+ // most of those ended in NO_REPLY.
6
+ //
7
+ // Mentions, DMs, human posts, heartbeats and digests are never deferred, and
8
+ // neither is a broadcast about a row the seat holds. Every failure fails OPEN:
9
+ // a kernel that does not mark `claimedByMe`, or a board read that errors,
10
+ // means nothing is deferred.
11
+
12
+ export const CLAIMED_WORK_TTL_MS = 60_000;
13
+
14
+ // Pure: may this waking event wait while the seat holds `claimedTaskIds`?
15
+ // `trigger` is classifyTrigger's verdict for the event.
16
+ export const isDeferrableWhileClaimed = (event, trigger, claimedTaskIds) => {
17
+ if (!event || event.deliveryMode === 'digest') return false;
18
+ if (event.type !== 'message.posted') return false;
19
+ const payload = event.payload || {};
20
+ if (payload.addressed === true || payload.repliesToYourMessage === true) return false;
21
+ // A lease warning is about the seat's own row, though it names it differently.
22
+ if (payload.leaseWarningTaskId) return false;
23
+ // A board wake with a reason is a change the seat must see: a dependency of
24
+ // its row finished, an edit to its row folded into another row's wake (so
25
+ // taskId names the first row), or an agent assigned it a new row. Only the
26
+ // reasonless found-work nudge may wait.
27
+ if (payload.boardWake === true && payload.wakeReason) return false;
28
+ if (trigger !== 'agent' && trigger !== 'kernel') return false;
29
+ if (payload.taskId && claimedTaskIds?.has(String(payload.taskId))) return false;
30
+ return true;
31
+ };
32
+
33
+ export const createClaimedWorkGate = ({ client, ttlMs = CLAIMED_WORK_TTL_MS, now = () => Date.now() } = {}) => {
34
+ const cache = new Map();
35
+
36
+ // The ids of this seat's claimed rows in `podId`, or null when it holds none
37
+ // or the board cannot say.
38
+ const claimedTaskIds = async (podId) => {
39
+ if (!client || !podId) return null;
40
+ const cached = cache.get(podId);
41
+ if (cached && now() - cached.at < ttlMs) return cached.ids;
42
+ let ids = null;
43
+ try {
44
+ const { tasks = [] } = await client.get(`/api/v1/tasks/${podId}`, { status: 'claimed' });
45
+ // A lapsed lease still carries claimedBy; only a live one holds the seat.
46
+ const mine = tasks.filter((task) => task?.claimedByMe === true && task?.leaseState === 'held')
47
+ .map((task) => String(task.taskId));
48
+ ids = mine.length > 0 ? new Set(mine) : null;
49
+ } catch {
50
+ ids = null;
51
+ }
52
+ cache.set(podId, { at: now(), ids });
53
+ return ids;
54
+ };
55
+
56
+ // True when every waking event in the group may wait. An empty group is
57
+ // never deferred.
58
+ const shouldDefer = async (podId, wakes) => {
59
+ if (!Array.isArray(wakes) || wakes.length === 0) return false;
60
+ // Only read the board when the group could be deferred at all. A group
61
+ // that can't be deferred spawns a turn, and that turn may complete or
62
+ // release the row, so the next read must not trust the cached claim.
63
+ if (!wakes.every(({ event, trigger }) => isDeferrableWhileClaimed(event, trigger, null))) {
64
+ cache.delete(podId);
65
+ return false;
66
+ }
67
+ const ids = await claimedTaskIds(podId);
68
+ if (!ids) return false;
69
+ return wakes.every(({ event, trigger }) => isDeferrableWhileClaimed(event, trigger, ids));
70
+ };
71
+
72
+ return { shouldDefer };
73
+ };
@@ -0,0 +1,33 @@
1
+ // The commands this CLI prints for a person to type (F435).
2
+ //
3
+ // npm installs `commonly` and `npx` on Windows as three shims: a `.ps1`, a
4
+ // `.cmd` and a bare sh script. PowerShell picks the `.ps1` first, and its
5
+ // default execution policy (Restricted on a Windows client) refuses to run
6
+ // it, so a printed `commonly daemon install` fails there before it starts.
7
+ // The `.cmd` shim is not a script to PowerShell and runs under any policy, and
8
+ // it runs the same in cmd.exe. So on win32 every hint names `commonly.cmd`,
9
+ // `npx.cmd` or `npm.cmd`; elsewhere the bare name.
10
+ //
11
+ // Windows PowerShell 5.1 has no `&&`, and `;` would run the second step even
12
+ // when the first failed, so steps that depend on each other print one per line
13
+ // on win32 (`cliSteps`).
14
+ //
15
+ // The guard in __tests__/cli-hint.test.mjs fails on a `commonly <subcommand>`
16
+ // written into a string anywhere else in cli/src: use `${CLI} <subcommand>`.
17
+
18
+ export const cliName = (platform = process.platform) => (platform === 'win32' ? 'space.cmd' : 'space');
19
+ export const npxName = (platform = process.platform) => (platform === 'win32' ? 'npx.cmd' : 'npx');
20
+ export const npmName = (platform = process.platform) => (platform === 'win32' ? 'npm.cmd' : 'npm');
21
+
22
+ export const CLI = cliName();
23
+ export const NPX = npxName();
24
+ export const NPM = npmName();
25
+
26
+ // Commands that run one after another, each only if the one before succeeded.
27
+ // Joined with ` && ` on POSIX shells; on win32, one command per line, each
28
+ // indented by `indent`, so the reader runs them in order.
29
+ export const cliSteps = (steps, { platform = process.platform, indent = ' ' } = {}) => (
30
+ platform === 'win32'
31
+ ? steps.map((step) => `\n${indent}${step}`).join('')
32
+ : steps.join(' && ')
33
+ );