@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,200 @@
1
+ /**
2
+ * Event poller — polls GET /api/agents/runtime/events and forwards
3
+ * each event to a local handler (webhook server or callback).
4
+ *
5
+ * Used by `commonly agent connect` when running against any instance,
6
+ * since localhost webhooks can't receive inbound calls from remote GKE.
7
+ */
8
+
9
+ import { createClient } from './api.js';
10
+ import { formatDigestFrame } from './digest-frame.js';
11
+ import { EMPTY_OUTPUT } from './claim-outcome.js';
12
+ import { createPollLiveness, formatStall, resolvePollLivenessMs } from './poll-liveness.js';
13
+ import { CLI } from './cli-hint.js';
14
+
15
+ // Delivery failures that must stop a consumer rather than fall through to the
16
+ // normal at-least-once retry path. A stale delivery belongs to a replacement
17
+ // runner; a missing required nonce means this runner is misconfigured. Neither
18
+ // can become healthy by immediately fetching and retrying more work.
19
+ export const terminalDeliveryAckError = (ackErr, eventId, runner = 'poller') => {
20
+ if (ackErr?.status === 409 && ackErr?.body?.code === 'stale_delivery') {
21
+ return Object.assign(
22
+ new Error(`Delivery ${eventId} was superseded — stopping ${runner}.`),
23
+ { code: 'stale_delivery' },
24
+ );
25
+ }
26
+ if (ackErr?.status === 400 && ackErr?.body?.code === 'delivery_id_required') {
27
+ return Object.assign(
28
+ new Error(`Delivery ${eventId} requires deliveryId — stopping ${runner}; update its configuration.`),
29
+ { code: 'delivery_id_required' },
30
+ );
31
+ }
32
+ return null;
33
+ };
34
+
35
+ export const startPoller = ({
36
+ instanceUrl,
37
+ token,
38
+ agentName,
39
+ instanceId = 'default',
40
+ intervalMs = 5000,
41
+ loginInstance, // the --instance the connect ran with, so the login hint names it
42
+ onEvent, // async (event) => { outcome, content? }
43
+ onError, // (err) => void
44
+ pollLivenessMs, // TASK-338: see lib/poll-liveness.js
45
+ pollLivenessCheckMs,
46
+ onPollStalled, // ({ stalledMs }) => void; the connect command exits on it
47
+ }) => {
48
+ const client = createClient({ instance: instanceUrl, token });
49
+ let running = true;
50
+ let consecutiveErrors = 0;
51
+ // Stop-after-N-auth-failures: without this, a revoked token leaves the
52
+ // poller hammering 401s forever at 60s backoff, invisible to the user.
53
+ // 3 is deliberate — 1 would churn on a token-rotation race during
54
+ // reprovision-all; 5+ wastes rate-limit budget after the real-revoke case.
55
+ let consecutiveAuthErrors = 0;
56
+ const MAX_AUTH_ERRORS = 3;
57
+ let pollStalled = false;
58
+ const pollLiveness = createPollLiveness({
59
+ limitMs: resolvePollLivenessMs(pollLivenessMs),
60
+ checkEveryMs: pollLivenessCheckMs,
61
+ onStall: ({ stalledMs }) => {
62
+ if (!running) return;
63
+ pollStalled = true;
64
+ running = false;
65
+ pollLiveness.stop();
66
+ onError?.(new Error(`poll stalled: no inbox poll settled for ${formatStall(stalledMs)} — stopping poller`));
67
+ onPollStalled?.({ stalledMs });
68
+ },
69
+ });
70
+
71
+ const poll = async () => {
72
+ if (!running) return;
73
+
74
+ try {
75
+ // Same clock as `agent run`: running from here to the scheduled wait,
76
+ // paused while the local handler takes the event.
77
+ pollLiveness.arm();
78
+ const page = await client.get('/api/agents/runtime/events', {
79
+ agentName,
80
+ instanceId,
81
+ limit: 10,
82
+ });
83
+ pollLiveness.arm();
84
+ if (pollStalled) return;
85
+ const { events = [] } = page;
86
+
87
+ // A digest is received context, not a webhook invocation. The runtime
88
+ // inbox claims it only alongside a wake from the same pod.
89
+ const wakeEvents = events.filter((event) => event.deliveryMode !== 'digest');
90
+ const digestEvents = events.filter((event) => event.deliveryMode === 'digest'
91
+ && wakeEvents[0] && String(event.podId) === String(wakeEvents[0].podId));
92
+ const rollup = wakeEvents[0]?.payload?.digestRollup;
93
+ const frame = formatDigestFrame(digestEvents, rollup);
94
+ let digestConsumed = false;
95
+ for (const [index, event] of wakeEvents.entries()) {
96
+ const attachedDigest = index === 0 && frame !== null;
97
+ const forwarded = attachedDigest ? {
98
+ ...event,
99
+ payload: {
100
+ ...(event.payload || {}),
101
+ digestSummary: frame,
102
+ ...(typeof event.payload?.content === 'string' ? {
103
+ content: `${event.payload.content}\n\n${frame}`,
104
+ } : {}),
105
+ },
106
+ } : event;
107
+ let result = { outcome: 'no_action' };
108
+ try {
109
+ // A handler that returns nothing said nothing: not a read of the digest.
110
+ pollLiveness.pause();
111
+ result = (await onEvent(forwarded)) || { outcome: 'no_action', reason: EMPTY_OUTPUT };
112
+ } catch (handlerErr) {
113
+ result = { outcome: 'error', reason: handlerErr.message };
114
+ } finally {
115
+ pollLiveness.arm();
116
+ }
117
+
118
+ // Acknowledge the event
119
+ try {
120
+ const deliveryId = event.payload?.deliveryId;
121
+ await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
122
+ result,
123
+ ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
124
+ });
125
+ if (attachedDigest && (result.outcome === 'posted'
126
+ || result.outcome === 'acknowledged'
127
+ || (result.outcome === 'no_action' && !result.reason))) {
128
+ digestConsumed = true;
129
+ }
130
+ } catch (ackErr) {
131
+ const terminalError = terminalDeliveryAckError(ackErr, event._id);
132
+ if (terminalError) {
133
+ running = false;
134
+ onError?.(terminalError);
135
+ return;
136
+ }
137
+ // Non-fatal — event will be retried
138
+ onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
139
+ }
140
+ }
141
+
142
+ if (digestConsumed) {
143
+ for (const event of digestEvents) {
144
+ try {
145
+ const deliveryId = event.payload?.deliveryId;
146
+ await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
147
+ result: { outcome: 'no_action', reason: 'digest-read' },
148
+ ...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
149
+ });
150
+ } catch (ackErr) {
151
+ const terminalError = terminalDeliveryAckError(ackErr, event._id);
152
+ if (terminalError) {
153
+ running = false;
154
+ onError?.(terminalError);
155
+ return;
156
+ }
157
+ onError?.(new Error(`Ack failed for ${event._id}: ${ackErr.message}`));
158
+ }
159
+ }
160
+ }
161
+
162
+ consecutiveErrors = 0;
163
+ consecutiveAuthErrors = 0;
164
+ } catch (err) {
165
+ if (err?.status === 401 || err?.status === 403) {
166
+ consecutiveAuthErrors += 1;
167
+ if (consecutiveAuthErrors >= MAX_AUTH_ERRORS) {
168
+ onError?.(new Error(
169
+ `Runtime token rejected ${consecutiveAuthErrors} times in a row — stopping poller. `
170
+ + `This connection takes its key from --token, then COMMONLY_TOKEN, then your saved login. `
171
+ + `If the key was rotated or revoked, re-run with the new key in --token or COMMONLY_TOKEN; `
172
+ + `with neither set, run ${CLI} login${loginInstance ? ` --instance ${loginInstance}` : ''}.`,
173
+ ));
174
+ running = false;
175
+ return;
176
+ }
177
+ }
178
+ consecutiveErrors++;
179
+ onError?.(err);
180
+ // Back off on repeated errors (max 60s). A wait this loop scheduled
181
+ // itself always ends, so the liveness clock stops for it.
182
+ const backoff = Math.min(intervalMs * consecutiveErrors, 60_000);
183
+ pollLiveness.pause();
184
+ await new Promise((r) => setTimeout(r, backoff));
185
+ }
186
+
187
+ pollLiveness.pause();
188
+ if (running) setTimeout(poll, intervalMs);
189
+ };
190
+
191
+ // Start immediately
192
+ poll();
193
+
194
+ return {
195
+ stop: () => {
196
+ running = false;
197
+ pollLiveness.stop();
198
+ },
199
+ };
200
+ };
@@ -0,0 +1,42 @@
1
+ import { resolveProviderKeyFile as resolveKeyFileShared } from '@commonlyai/seats/wire/provider-proxy';
2
+ import { effectiveSandboxTrust } from './environment.js';
3
+ import { bwrapReadRoots } from './sandbox/bwrap.js';
4
+ import { seatbeltSystemReadRoots } from './sandbox/seatbelt.js';
5
+
6
+ export * from '@commonlyai/seats/wire/provider-proxy';
7
+
8
+ /**
9
+ * F453. What a sandboxed seat can read on this platform, as roots, or
10
+ * undefined for a spawn that runs in no sandbox. macOS: the Seatbelt
11
+ * baseline's read grants, read from the profile itself. Linux: bwrap's read
12
+ * binds, including the seat's declared read-outside and write-outside.
13
+ * `sandboxed` is the adapter's word on whether this spawn is confined; left
14
+ * out, a public seat is confined and any other is not.
15
+ */
16
+ export const sandboxReadableRoots = (environment, platform = process.platform, { sandboxed } = {}) => {
17
+ const confined = sandboxed ?? effectiveSandboxTrust(environment?.sandbox?.trust) === 'public';
18
+ if (!confined) return undefined;
19
+ if (platform === 'darwin') return seatbeltSystemReadRoots();
20
+ if (platform === 'linux') return bwrapReadRoots(environment);
21
+ return { subpaths: [], literals: [] };
22
+ };
23
+
24
+ /**
25
+ * The shared key-file validation, with the sandbox check every adapter gets:
26
+ * the proxy reads a confined seat's key file outside the sandbox, so a key
27
+ * file the sandbox can read is refused before the seat starts, and before
28
+ * anything opens the file. Adapters pass their environment; `seatDirectories`
29
+ * names stable directories the adapter itself gives the sandbox (a seat home),
30
+ * checked whenever the spawn is confined. Without options nothing changes.
31
+ */
32
+ export const resolveProviderKeyFile = (provider, workspacePath, adapterLabel, {
33
+ environment, platform, sandboxed, seatDirectories = [],
34
+ } = {}) => {
35
+ const roots = sandboxReadableRoots(environment, platform, { sandboxed });
36
+ return resolveKeyFileShared(provider, workspacePath, adapterLabel, {
37
+ readableRoots: roots && {
38
+ subpaths: [...roots.subpaths, ...seatDirectories.filter(Boolean)],
39
+ literals: roots.literals,
40
+ },
41
+ });
42
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Tell a public seat's Commonly MCP server that it serves a public seat.
3
+ *
4
+ * The server then writes nothing once Commonly has been unreachable in that
5
+ * process (commonly-mcp/src/public-seat-gate.js), which closes the post a model
6
+ * makes through the tool before the CLI's own witness withholds the reply
7
+ * (public-tool-error.js, TASK-256). Only the shipped Commonly server is
8
+ * flagged. Every adapter carries a declared literal env key through to the
9
+ * server's process (`deliverSeatCredential` copies the declaration's env), so
10
+ * one copy here reaches all of them. A server that predates the gate ignores
11
+ * the key.
12
+ *
13
+ * Set at spawn, on a copy: the record and the guards (`auditDeclaredMcp`,
14
+ * `isShippedCommonlyMcpEntry`) still see the declaration as written.
15
+ */
16
+ import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
17
+ import { describeMcpCommand } from './mcp-server-version.js';
18
+
19
+ export const PUBLIC_SEAT_MCP_ENV = 'COMMONLY_MCP_PUBLIC_SEAT';
20
+
21
+ // The npx form, or a recorded local checkout of the same package (the form
22
+ // goose's public check and `auditDeclaredMcp` also admit).
23
+ const isCommonlyServer = (server, describe) => isShippedCommonlyMcpCommand(server?.command)
24
+ || Boolean(describe(server?.command)?.isCommonly);
25
+
26
+ export const withPublicSeatMcpFlag = (environment, { describe = describeMcpCommand } = {}) => {
27
+ const servers = Array.isArray(environment?.mcp) ? environment.mcp : null;
28
+ const flagged = servers ? servers.map((server) => isCommonlyServer(server, describe)) : [];
29
+ if (!flagged.some(Boolean)) return environment;
30
+ return {
31
+ ...environment,
32
+ mcp: servers.map((server, i) => (flagged[i]
33
+ ? { ...server, env: { ...(server.env || {}), [PUBLIC_SEAT_MCP_ENV]: '1' } }
34
+ : server)),
35
+ };
36
+ };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Which failed Commonly tool calls a public seat's model may answer in plain
3
+ * words. Everything else withholds the turn (#2374).
4
+ *
5
+ * The rule is inverted on purpose. Matching known connection failures let an
6
+ * API 5xx through: the shipped MCP turns a non-2xx into a tool error carrying
7
+ * the status, and during a rollout the ingress answers 503 while the socket
8
+ * connects, so the model narrated the outage to a stranger. A list of failures
9
+ * fails open on the next one nobody listed; a list of answers fails closed.
10
+ *
11
+ * The answerable ones:
12
+ * - an API refusal the person can be told about: 400, 403, 404, 409, 410, 413
13
+ * or 422. 401 is NOT among them: a seat whose token the API rejects is
14
+ * broken plumbing, and every later call fails the same way.
15
+ * - the runtime's own schema check, which the model fixes on its next call.
16
+ * OpenCode 1.19.0 words it "The <tool> tool was called with invalid
17
+ * arguments: …" (ToolInvalidArgumentsError, read from the shipped binary).
18
+ *
19
+ * Everything else withholds: a 5xx, 401, 429 or any other status; a tool error
20
+ * with no status (the shipped MCP's network, timeout and token failures); an
21
+ * MCP transport error ("MCP error -32000: Connection closed"); an empty error;
22
+ * and any text this module does not recognise.
23
+ *
24
+ * How the status arrives: the shipped MCP answers a failed call with
25
+ * `isError: true` and one text part, `JSON.stringify({ status, body, message })`
26
+ * for an HTTP error or `{ message }` otherwise (commonly-mcp/src/tools.js,
27
+ * `err`). OpenCode throws that text as the tool's error, so the event's
28
+ * `state.error` is the JSON (measured on the 2026-10-21 canary).
29
+ *
30
+ * The TASK-256 readiness witness uses this same predicate, so the two
31
+ * witnesses cannot disagree about what counts as a failure.
32
+ */
33
+
34
+ export const ANSWERABLE_API_STATUSES = new Set([400, 403, 404, 409, 410, 413, 422]);
35
+
36
+ // The two ways a public turn is withheld over its Commonly MCP server: a tool
37
+ // call failed in a way the model must not narrate, or the server was never shown
38
+ // working. Both leave the event unacked for retry.
39
+ export const PUBLIC_TOOL_FAILED_CODE = 'public_tool_failed';
40
+ export const PUBLIC_MCP_NOT_READY_CODE = 'public_mcp_not_ready';
41
+
42
+ const INVALID_ARGUMENTS_RE = /^The \S+ tool was called with invalid arguments: /;
43
+
44
+ const parsePayload = (text) => {
45
+ try {
46
+ const parsed = JSON.parse(text);
47
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
48
+ } catch {
49
+ return null;
50
+ }
51
+ };
52
+
53
+ /** Whether a failed tool call's error text is one the model may answer. */
54
+ export const isAnswerableToolError = (error) => {
55
+ const text = typeof error === 'string' ? error.trim() : '';
56
+ if (!text) return false;
57
+ if (INVALID_ARGUMENTS_RE.test(text)) return true;
58
+ const payload = parsePayload(text);
59
+ return Boolean(payload && Number.isInteger(payload.status) && ANSWERABLE_API_STATUSES.has(payload.status));
60
+ };
@@ -0,0 +1,48 @@
1
+ // Commonly MCP tools that public seats are not offered, on any adapter.
2
+ // Claude workspace seats follow the same list. Each
3
+ // adapter removes them in its own vocabulary (TASK-287; Goose's public list
4
+ // already leaves them out).
5
+ // commonly_catch_up moves the read cursor past history unread; a wrapper seat
6
+ // already catches up before each turn, so no seat on this list needs the tool.
7
+ export const PUBLIC_WITHHELD_TOOLS = Object.freeze(['commonly_attach_file', 'commonly_catch_up']);
8
+
9
+ // F118 (Cofounder ruling, 11-04): public agents start from nothing and earn
10
+ // capabilities. A public seat answers strangers, so an injected message must
11
+ // not be able to spread the agent into other pods or rewrite its memory.
12
+ // Withheld on public trust only: a Claude workspace seat keeps its memory
13
+ // writes, and denies self-install itself (claude.js, workspaceDenies). The
14
+ // memory writes come back on a turn the owner woke (F145, below).
15
+ // commonly_log_cycle is a memory write too: it appends to the agent's
16
+ // `cycles[]` through POST /api/agents/runtime/memory/sync. Reading memory
17
+ // (commonly_read_agent_memory, and the long_term the wrapper puts in the
18
+ // prompt) stays offered.
19
+ // commonly_delete_message (F141): a stranger-steered seat could delete its own
20
+ // replies to an injection, which are the evidence of what the attack achieved.
21
+ // A workspace seat keeps it.
22
+ export const PUBLIC_ONLY_WITHHELD_TOOLS = Object.freeze([
23
+ 'commonly_self_install_into_pod',
24
+ 'commonly_write_agent_memory',
25
+ 'commonly_save_my_memory',
26
+ 'commonly_log_cycle',
27
+ 'commonly_delete_message',
28
+ ]);
29
+
30
+ // Everything a public seat is not offered. Codex, OpenCode, Qwen and Goose
31
+ // confine on public trust only, so this is the whole list for them.
32
+ export const PUBLIC_SEAT_WITHHELD_TOOLS = Object.freeze([
33
+ ...PUBLIC_WITHHELD_TOOLS, ...PUBLIC_ONLY_WITHHELD_TOOLS,
34
+ ]);
35
+
36
+ // F145: what comes back on a turn the kernel verified its owner woke, so the
37
+ // owner keeps a remembering agent. The kernel refuses these writes on any other
38
+ // public turn, whatever an adapter offers.
39
+ export const OWNER_WOKEN_MEMORY_TOOLS = Object.freeze([
40
+ 'commonly_write_agent_memory',
41
+ 'commonly_save_my_memory',
42
+ 'commonly_log_cycle',
43
+ ]);
44
+
45
+ /** What a public seat is not offered on this turn. Only a literal `true` opens it. */
46
+ export const publicSeatWithheldTools = (ctx) => (ctx?.ownerWoken === true
47
+ ? PUBLIC_SEAT_WITHHELD_TOOLS.filter((tool) => !OWNER_WOKEN_MEMORY_TOOLS.includes(tool))
48
+ : PUBLIC_SEAT_WITHHELD_TOOLS);
@@ -0,0 +1,55 @@
1
+ // D245 item 4: the receipt the wrapper posts with a reply, so a BYO reply
2
+ // carries "how this reply was made" the way a hosted reply has its run trace.
3
+ // It is the wrapper's own account and the server stores it as self-reported
4
+ // (backend/services/byoReceipt.ts), which also bounds and checks every field.
5
+ // This side keeps to what it actually observed: an adapter that reports no
6
+ // stats sends only its name and the measured duration.
7
+
8
+ const isName = (value) => typeof value === 'string' && value.length > 0;
9
+ const isCount = (value) => Number.isInteger(value) && value >= 0;
10
+
11
+ const parseLine = (raw) => {
12
+ try { return JSON.parse(raw); } catch { return null; }
13
+ };
14
+
15
+ /**
16
+ * What a Claude Code stream-json run says about itself: the model from the
17
+ * init event, the turn count from the result event, and each tool it called,
18
+ * once, in first-use order.
19
+ */
20
+ export const claudeRunStats = (stdout) => {
21
+ const events = String(stdout || '').split('\n').map(parseLine).filter(Boolean);
22
+ const stats = {};
23
+ const init = events.find((event) => event.type === 'system' && event.subtype === 'init');
24
+ if (isName(init?.model)) stats.model = init.model;
25
+ const result = [...events].reverse().find((event) => event.type === 'result');
26
+ if (isCount(result?.num_turns)) stats.turns = result.num_turns;
27
+ const tools = [];
28
+ for (const event of events) {
29
+ if (event.type !== 'assistant') continue;
30
+ for (const block of event.message?.content || []) {
31
+ if (block?.type === 'tool_use' && isName(block.name) && !tools.includes(block.name)) tools.push(block.name);
32
+ }
33
+ }
34
+ if (tools.length) stats.tools = tools;
35
+ return stats;
36
+ };
37
+
38
+ /** The receipt for one reply, or null when there is no adapter to name. */
39
+ export const buildReplyReceipt = ({ adapterName, stats = {}, durationMs } = {}) => {
40
+ if (!isName(adapterName)) return null;
41
+ const receipt = { adapter: adapterName };
42
+ // A cloud-hosted model can be named by an ARN or a resource path that
43
+ // carries the account or project it runs in; that is not this reply's to
44
+ // publish, so such a model id is left out.
45
+ if (isName(stats?.model) && !/^arn:|(^|\/)projects\//i.test(stats.model)) receipt.model = stats.model;
46
+ // D247: the effort level the adapter passed, as a bare level name.
47
+ if (isName(stats?.effort) && /^[a-z0-9][a-z0-9._-]{0,19}$/i.test(stats.effort)) receipt.effort = stats.effort;
48
+ if (isCount(stats?.turns)) receipt.turns = stats.turns;
49
+ if (Array.isArray(stats?.tools)) {
50
+ const tools = stats.tools.filter(isName);
51
+ if (tools.length) receipt.tools = tools;
52
+ }
53
+ if (Number.isFinite(durationMs) && durationMs >= 0) receipt.durationMs = Math.round(durationMs);
54
+ return receipt;
55
+ };
@@ -0,0 +1,22 @@
1
+ import { accessSync, constants as fsConstants, statSync } from 'node:fs';
2
+ import { delimiter, isAbsolute, join } from 'node:path';
3
+
4
+ /**
5
+ * The first executable `name` on `pathValue`, absolute, or null. No shell
6
+ * involved, and only absolute PATH entries count: a relative entry would
7
+ * resolve against whatever the daemon's working directory happens to be.
8
+ * Only a file counts: X_OK passes a directory, and execvp passes over one
9
+ * (EACCES) to the next entry, so a same-named folder earlier on PATH must not
10
+ * be returned as the binary (F146).
11
+ */
12
+ export const resolveOnPath = (name, pathValue) => {
13
+ for (const dir of String(pathValue || '').split(delimiter)) {
14
+ if (!dir || !isAbsolute(dir)) continue;
15
+ const candidate = join(dir, name);
16
+ try {
17
+ accessSync(candidate, fsConstants.X_OK);
18
+ if (statSync(candidate).isFile()) return candidate;
19
+ } catch { /* next */ }
20
+ }
21
+ return null;
22
+ };
@@ -0,0 +1,56 @@
1
+ // Probe only the walked adapter's version command. Never enumerate workspace
2
+ // extensions to discover models: a model picker must not execute project code.
3
+ export const WALKED_HARNESSES = ['claude', 'codex', 'opencode', 'hermes', 'dsh'];
4
+ // `loginState` (F458), when given, adds `signedIn` to an installed harness it
5
+ // has a verdict for, so the page can say "not signed in" before the first
6
+ // message fails instead of after.
7
+ export const collectRuntimeInventory = async (getAdapter, capabilities, harnesses = WALKED_HARNESSES, { loginState } = {}) => Promise.all(harnesses.map(async (harness) => {
8
+ const adapter = getAdapter(harness);
9
+ let detected = null;
10
+ try { detected = await adapter?.detect(); } catch { /* unavailable */ }
11
+ let choices = { models: null, efforts: [] };
12
+ if (detected) { try { choices = (await (capabilities ? capabilities(harness) : adapter?.inventory?.())) || choices; } catch { /* unavailable */ } }
13
+ let signedIn = null;
14
+ if (detected && loginState) { try { signedIn = await loginState(harness); } catch { /* no verdict */ } }
15
+ return { harness, installed: Boolean(detected), version: detected?.version || null,
16
+ models: choices.models, efforts: choices.efforts, ...(typeof signedIn === 'boolean' ? { signedIn } : {}) };
17
+ }));
18
+
19
+ // The daemon's inventory, refreshed in full every five minutes. A harness it
20
+ // didn't find is probed again sooner, so one installed while the BYO page is
21
+ // open shows up within a heartbeat or two, without a reload (F370 #8). One
22
+ // that reported signed out is rechecked the same way, so a sign-in does too (F458).
23
+ // Probes are synchronous: a binary that isn't there fails at once, but one
24
+ // that's there and hangs on --version blocks the daemon for its timeout. A
25
+ // re-probe slower than INVENTORY_SLOW_MS waits for the full refresh instead.
26
+ export const INVENTORY_FULL_MS = 300_000;
27
+ export const INVENTORY_MISSING_MS = 25_000;
28
+ export const INVENTORY_SLOW_MS = 1_000;
29
+ export const createInventoryCache = (collect, { now = Date.now } = {}) => {
30
+ let cached;
31
+ let fullAt = 0;
32
+ let missingAt = 0;
33
+ let slow = new Set();
34
+ return async () => {
35
+ const at = now();
36
+ if (!cached || at - fullAt > INVENTORY_FULL_MS) {
37
+ cached = await collect(WALKED_HARNESSES);
38
+ fullAt = at;
39
+ missingAt = at;
40
+ slow = new Set();
41
+ return cached;
42
+ }
43
+ const missing = cached.filter((row) => (!row.installed || row.signedIn === false) && !slow.has(row.harness))
44
+ .map((row) => row.harness);
45
+ if (missing.length && at - missingAt > INVENTORY_MISSING_MS) {
46
+ missingAt = at;
47
+ for (const harness of missing) {
48
+ const started = now();
49
+ const [fresh] = await collect([harness]);
50
+ if (now() - started > INVENTORY_SLOW_MS) slow.add(harness);
51
+ if (fresh) cached = cached.map((row) => (row.harness === harness ? fresh : row));
52
+ }
53
+ }
54
+ return cached;
55
+ };
56
+ };
@@ -0,0 +1,37 @@
1
+ // controlsFromArguments and observeSpawn live in the seats runtime package;
2
+ // the reporter below posts to a Commonly instance, so it stays in the CLI.
3
+ export { controlsFromArguments, observeSpawn } from '@commonlyai/seats/runtime-observation';
4
+
5
+ export const createRuntimeReporter = ({ client, harness, detect }) => {
6
+ let queue = Promise.resolve();
7
+ const send = (body) => {
8
+ // Serialize so a fast process cannot have its settled report overtake its
9
+ // spawn report. Old backends and diagnostic outages don't stop a turn.
10
+ queue = queue.then(() => client.post('/api/agents/runtime/observation', body)).catch(() => {});
11
+ return queue;
12
+ };
13
+ return {
14
+ async connect() {
15
+ return send({ event: 'connected' });
16
+ },
17
+ spawn: ({ model, effort, at }) => {
18
+ const receipt = { event: 'spawn', harness, version: null, model, effort, ...(at ? { at } : {}) };
19
+ queue = queue.then(async () => {
20
+ try { receipt.version = (await detect())?.version || null; } catch { /* unavailable */ }
21
+ await client.post('/api/agents/runtime/observation', receipt);
22
+ }).catch(() => {});
23
+ return queue;
24
+ },
25
+ // A failed spawn carries its reason code (F370 #11). A backend from before
26
+ // the field refuses the whole report with a 400, so it's sent once more
27
+ // without the reason, and the outcome still lands.
28
+ settled: (outcome = null, reason = null) => {
29
+ const body = { event: 'settled', ...(outcome ? { outcome } : {}) };
30
+ if (!(outcome === 'failed' && reason)) return send(body);
31
+ queue = queue.then(() => client.post('/api/agents/runtime/observation', { ...body, reason })
32
+ .catch((error) => (error?.status === 400 ? client.post('/api/agents/runtime/observation', body) : undefined)))
33
+ .catch(() => {});
34
+ return queue;
35
+ },
36
+ };
37
+ };