@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,483 @@
1
+ /**
2
+ * Operator inbox — ONE account's attention queue, chosen explicitly.
3
+ *
4
+ * Why this exists: an operator account (connector-ops, lily-shen, gtm-ops) is a
5
+ * human-class recipient, so the kernel already materializes AttentionItems for
6
+ * it — mentions via `recordMentionedUsers`, decisions and handoffs via the
7
+ * `currentHumanMembers` fan-out. What was missing was a consumer. The queue is
8
+ * real: 576 open items for connector-ops on 2026-09-28, none of them ever read.
9
+ *
10
+ * THE IDENTITY RULE IS THE POINT OF THIS MODULE, not a detail in it. Every host
11
+ * helper built on the CLI's saved login read SAM's queue, because the saved
12
+ * login on a shared operator host is Sam's. So this command refuses to run
13
+ * without an explicit token file (`--token-file`, or `COMMONLY_TOKEN_FILE`) and
14
+ * never consults the saved login or `COMMONLY_TOKEN`. A reader can therefore
15
+ * trust the first line of every run: it names whose queue was read, resolved
16
+ * from `GET /api/auth/user` with that token.
17
+ *
18
+ * Two ids exist on a queue item and different commands consume them:
19
+ * `attentionItemId` — what `POST /api/activity/:id/acknowledge` wants
20
+ * (`attentionItemService.acknowledgeAttention` matches
21
+ * `_id` and refuses a non-24-hex value)
22
+ * `id` — the SOURCE row: for a decision that is the decision id
23
+ * that `POST /api/activity/decisions/:id/choose` wants
24
+ * `list` prints both, labelled, so neither command has to be discovered by
25
+ * failing first.
26
+ *
27
+ * The cursor is a TIMESTAMP PLUS the ids delivered INSIDE A WINDOW that ends at
28
+ * it, compared against `createdAt` — the only field on an item that never moves.
29
+ * The queue's ORDER moves as items are acknowledged (priority, then newest first),
30
+ * so an offset cursor would skip rows; `createdAt` cannot.
31
+ *
32
+ * The window is what makes the cursor safe, and the ids are not decoration. A row
33
+ * is stamped from the WRITER's clock at the moment of the write and becomes
34
+ * visible only when that write commits, so a row can carry a stamp before the
35
+ * cursor and appear after the read that wrote it — and it is not late, it is
36
+ * simply invisible to a reader who resumes at the cursor. Starting a read W
37
+ * behind the cursor (`--window`, default 60s) makes that row visible on the next
38
+ * tick; the ids already delivered inside that window are then dropped, so the
39
+ * re-read prints only what the caller has not seen. A bare `ms > since` cursor
40
+ * loses such a row silently and forever — the one failure this queue (576 open
41
+ * items, none ever read) cannot afford, which is why the recoverable direction
42
+ * (re-printing, deduped by `attentionItemId`) is always the one taken here. A
43
+ * legacy plain-timestamp cursor parses with an EMPTY set, which degrades toward
44
+ * re-printing rather than skipping.
45
+ *
46
+ * The window is NOT a guess about clock skew alone: W must exceed the write's
47
+ * commit latency PLUS the writer's clock offset, and a caller that sets it to 0
48
+ * gets the exact-cursor behaviour (resume at the cursor, dedupe only the ids
49
+ * stamped with it). The ids are pruned to the window on every write, so the
50
+ * cursor file stays the size of one window of deliveries instead of growing.
51
+ *
52
+ * THE GENERAL RULE, which both cursor bugs above are instances of: a resume
53
+ * cursor is valid only for the query that wrote it. `--kind` is a DIFFERENT
54
+ * query over a subset of the same queue, so a kind-filtered read advances the
55
+ * high-water mark past items of every other kind — items that read never printed,
56
+ * and that no later read can reach, because the mark has already moved beyond
57
+ * them. A cursor file therefore records the kind filter (`kind` in the JSON) and
58
+ * a read whose filter differs from the recorded one refuses before it makes a
59
+ * request, rather than silently skipping. One cursor file per kind is the
60
+ * remedy, and the message names it.
61
+ */
62
+
63
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
64
+ import { dirname } from 'path';
65
+ import { capTimeoutMs, formatLimit } from './api.js';
66
+
67
+ export const INBOX_KINDS = ['mention', 'decision', 'handoff', 'approval'];
68
+ export const TOKEN_FILE_ENV = 'COMMONLY_TOKEN_FILE';
69
+ export const API_PAGE_LIMIT = 50;
70
+
71
+ const TOKEN_FIELDS = ['token', 'runtimeToken', 'accessToken', 'access_token'];
72
+ const DETAIL_CHARS = 160;
73
+
74
+ /** A refusal the operator can fix by re-reading the message. Never a server fault. */
75
+ export class InboxRefusal extends Error {}
76
+
77
+ /** A server-side failure. `status` is the HTTP status. */
78
+ export class InboxRequestError extends Error {
79
+ constructor(message, status) {
80
+ super(message);
81
+ this.name = 'InboxRequestError';
82
+ this.status = status;
83
+ }
84
+ }
85
+
86
+ export const resolveTokenFilePath = (opts = {}, env = process.env) => {
87
+ const flag = typeof opts.tokenFile === 'string' ? opts.tokenFile.trim() : '';
88
+ if (flag) return flag;
89
+ const fromEnv = typeof env?.[TOKEN_FILE_ENV] === 'string' ? env[TOKEN_FILE_ENV].trim() : '';
90
+ return fromEnv || null;
91
+ };
92
+
93
+ /**
94
+ * Pull a token out of a file body. A raw token is the documented shape (that is
95
+ * what `~/.commonly/bin/<account>-token` holds today); JSON is tolerated because
96
+ * the CLI's own token files are JSON, and a caller that reaches for one should
97
+ * get a named answer rather than a confusing 401.
98
+ */
99
+ export const extractToken = (raw) => {
100
+ const text = String(raw ?? '').trim();
101
+ if (!text) return null;
102
+ if (text.startsWith('{')) {
103
+ let parsed;
104
+ try {
105
+ parsed = JSON.parse(text);
106
+ } catch {
107
+ return null;
108
+ }
109
+ for (const field of TOKEN_FIELDS) {
110
+ const value = parsed?.[field];
111
+ if (typeof value === 'string' && value.trim()) return value.trim();
112
+ }
113
+ return null;
114
+ }
115
+ return text.replace(/^Bearer\s+/i, '').trim() || null;
116
+ };
117
+
118
+ /**
119
+ * Read the operator's token. Every failure here is a refusal with the PATH in
120
+ * it — never the token, and never a hint to run `commonly login`, which would
121
+ * point the account back at the saved login this command exists to avoid.
122
+ */
123
+ export const readInboxToken = (filePath) => {
124
+ let raw;
125
+ try {
126
+ raw = readFileSync(filePath, 'utf8');
127
+ } catch (err) {
128
+ throw new InboxRefusal(
129
+ `Cannot read the token file ${filePath} (${err?.code || err?.message}).`,
130
+ );
131
+ }
132
+ const token = extractToken(raw);
133
+ if (!token) {
134
+ throw new InboxRefusal(
135
+ `No token in ${filePath}. Expected a raw token, or JSON carrying one of: ${TOKEN_FIELDS.join(', ')}.`,
136
+ );
137
+ }
138
+ if (token.startsWith('cm_agent_')) {
139
+ throw new InboxRefusal(
140
+ `${filePath} holds an AGENT runtime token (cm_agent_…). This command reads a HUMAN account's own queue; `
141
+ + 'agent attention arrives as runtime events, not here. Pass that account\'s own token file.',
142
+ );
143
+ }
144
+ return token;
145
+ };
146
+
147
+ /** `--since` / a cursor file holds an ISO-8601 timestamp; null means "everything". */
148
+ export const parseCursor = (value) => {
149
+ const text = value === undefined || value === null ? '' : String(value).trim();
150
+ if (!text) return null;
151
+ // Two accepted shapes. The JSON object is what `list` writes (a timestamp plus
152
+ // the ids delivered at it); a bare ISO timestamp is what an operator copies off
153
+ // the `cursor:` line, and what older versions wrote — it parses with an empty id
154
+ // set, so the boundary items re-print instead of disappearing.
155
+ let at = text;
156
+ let ids = [];
157
+ let kind = null;
158
+ if (text.startsWith('{')) {
159
+ let parsed;
160
+ try {
161
+ parsed = JSON.parse(text);
162
+ } catch {
163
+ throw new InboxRefusal(`Not a cursor: ${text}`);
164
+ }
165
+ at = parsed?.at;
166
+ ids = Array.isArray(parsed?.ids) ? parsed.ids.filter((id) => typeof id === 'string' && id) : [];
167
+ // Which kind filter the read that wrote this cursor used, so the next read
168
+ // can tell whether it is asking the same question — see the rule at the top
169
+ // of this file. Absent on a bare timestamp and on a file written by an
170
+ // unfiltered read, which is the direction that cannot lose a row.
171
+ kind = typeof parsed?.kind === 'string' && parsed.kind ? parsed.kind : null;
172
+ }
173
+ const ms = Date.parse(String(at));
174
+ if (!Number.isFinite(ms)) {
175
+ throw new InboxRefusal(`Not an ISO-8601 timestamp: ${at}`);
176
+ }
177
+ return {
178
+ at: new Date(ms).toISOString(), ms, ids, kind,
179
+ };
180
+ };
181
+
182
+ /**
183
+ * `--window <seconds>`: how far behind the cursor a read starts. The default is
184
+ * the spec's 60s — the margin over in-cluster write latency and clock offset —
185
+ * and 0 restores the exact-cursor behaviour. Refused rather than clamped: a
186
+ * negative or unreadable window would silently change which rows a read can see,
187
+ * and that is the failure this whole module is built to avoid.
188
+ */
189
+ export const DEFAULT_WINDOW_MS = 60 * 1000;
190
+
191
+ export const parseWindowMs = (value, fallback = DEFAULT_WINDOW_MS) => {
192
+ if (value === undefined || value === null || value === '') return fallback;
193
+ const seconds = Number(String(value).trim());
194
+ if (!Number.isFinite(seconds) || seconds < 0) {
195
+ throw new InboxRefusal(`--window must be a number of seconds >= 0, not ${value}.`);
196
+ }
197
+ return Math.round(seconds * 1000);
198
+ };
199
+
200
+ /** A missing cursor file is "nothing seen yet", not an error. */
201
+ export const readCursorFile = (filePath) => {
202
+ if (!filePath) return null;
203
+ try {
204
+ return readFileSync(filePath, 'utf8').trim() || null;
205
+ } catch (err) {
206
+ if (err?.code === 'ENOENT') return null;
207
+ throw new InboxRefusal(`Cannot read the cursor file ${filePath} (${err?.code || err?.message}).`);
208
+ }
209
+ };
210
+
211
+ /** The cursor as it goes on disk: `{"at":"<iso>","ids":[...]}` on one line. */
212
+ export const formatCursor = (cursor) => {
213
+ if (!cursor) return null;
214
+ if (typeof cursor === 'string') return cursor;
215
+ const at = cursor.at || (Number.isFinite(cursor.ms) ? new Date(cursor.ms).toISOString() : null);
216
+ if (!at) return null;
217
+ const ids = Array.isArray(cursor.ids) ? cursor.ids : [];
218
+ // `kind` is written only when the read was scoped to one, so an unfiltered
219
+ // cursor keeps the exact shape it had before and neither gains nor loses
220
+ // meaning.
221
+ return JSON.stringify(cursor.kind ? { at, ids, kind: cursor.kind } : { at, ids });
222
+ };
223
+
224
+ /**
225
+ * Written temp-then-rename so a watcher reading this file mid-write sees either
226
+ * the previous cursor or the new one, never a half line. A failed write must not
227
+ * fail the run whose items were already printed, so the caller decides.
228
+ */
229
+ export const writeCursorFile = (filePath, cursor) => {
230
+ const text = formatCursor(cursor);
231
+ if (!filePath || !text) return;
232
+ mkdirSync(dirname(filePath), { recursive: true });
233
+ const tmp = `${filePath}.tmp`;
234
+ writeFileSync(tmp, `${text}\n`, 'utf8');
235
+ renameSync(tmp, filePath);
236
+ };
237
+
238
+ export const itemCreatedAtMs = (item) => {
239
+ const ms = Date.parse(String(item?.createdAt ?? ''));
240
+ return Number.isFinite(ms) ? ms : null;
241
+ };
242
+
243
+ /** The identity an item is remembered by between runs: the id `ack` consumes. */
244
+ export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id || '');
245
+
246
+ /**
247
+ * An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
248
+ * would lose the row silently, and re-printing is the recoverable error. The same
249
+ * argument decides the boundary — see the module header.
250
+ *
251
+ * `seenIds` is the set delivered inside the window and is dropped BY ID, whatever
252
+ * the item's timestamp: the window exists to re-serve those rows to the server
253
+ * call, and the id is the only thing that can say "the caller already saw this
254
+ * one". `sinceMs` is the window's own start and is inclusive, matching the
255
+ * route's bound, so a row stamped exactly at it still prints.
256
+ */
257
+ export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
258
+ const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
259
+ return (items || []).filter((item) => {
260
+ if (kind && item?.kind !== kind) return false;
261
+ if (delivered.has(cursorKeyOf(item))) return false;
262
+ if (sinceMs === null) return true;
263
+ const ms = itemCreatedAtMs(item);
264
+ if (ms === null) return true;
265
+ return ms >= sinceMs;
266
+ });
267
+ };
268
+
269
+ /**
270
+ * Newest timestamp this query has covered — the previous cursor's stamp or the
271
+ * newest item printed, whichever is later — so the next call resumes from W behind
272
+ * it, plus every id delivered at or after that window's start, which is what the
273
+ * next read's window will re-serve and must therefore not print twice.
274
+ *
275
+ * The max is taken over the PRINTED items, not the server's `nextSince`, because
276
+ * `--kind` is filtered on this side: the server's window may hold rows of other
277
+ * kinds that this read will never print, and advancing the cursor onto them would
278
+ * move the high-water mark past a row of THIS kind that a later page has not
279
+ * delivered yet. When nothing printed, the cursor stays where it was — the
280
+ * fallback — so a quiet tick does not move the mark at all.
281
+ *
282
+ * The set is recomputed from the window plus the ids the previous cursor had not
283
+ * yet aged out of it, and both halves are load-bearing: this read's items are
284
+ * post-dedupe, so a row it dropped is absent from them and would be forgotten
285
+ * without the carry. The prune is what bounds the file at one window of
286
+ * deliveries instead of a list of everything ever printed.
287
+ */
288
+ export const nextCursorFrom = (items, fallback = null, kind = null, windowMs = DEFAULT_WINDOW_MS) => {
289
+ let max = null;
290
+ for (const item of items || []) {
291
+ const ms = itemCreatedAtMs(item);
292
+ if (ms === null) continue;
293
+ if (max === null || ms > max) max = ms;
294
+ }
295
+ if (max === null) return fallback;
296
+ // A high-water mark: the newest item printed, or the cursor that came in,
297
+ // whichever is later. A row printed from inside the window is older than the
298
+ // mark that delimited that window, and letting it drag the mark back would move
299
+ // the next read's `since` back with it — reopening ground this query has already
300
+ // covered and moving the prune cutoff that bounds the id set.
301
+ const mark = fallback && Number.isFinite(fallback.ms) && fallback.ms > max ? fallback.ms : max;
302
+ const at = new Date(mark).toISOString();
303
+ const cutoff = mark - windowMs;
304
+ const inWindow = (items || []).filter((item) => {
305
+ const ms = itemCreatedAtMs(item);
306
+ return ms !== null && ms >= cutoff;
307
+ }).map(cursorKeyOf).filter(Boolean);
308
+ // The ids from the previous cursor are NOT dead weight, and dropping them is a
309
+ // measurable bug: `items` is what this read SELECTED, so a row delivered last
310
+ // tick has already been filtered out of it, and the set built from `items` alone
311
+ // would forget it — then the read after that would print it again, and the
312
+ // dedupe would alternate every tick. They are carried only while the previous
313
+ // cursor's own stamp is still inside this window; past that the next read's
314
+ // `since` is later than every one of them, the server can never re-serve them,
315
+ // and keeping them would grow the file for nothing.
316
+ const carried = fallback && Number.isFinite(fallback.ms) && fallback.ms >= cutoff
317
+ ? (fallback.ids || []) : [];
318
+ // The kind is the CURRENT read's, never inherited: the cursor describes the
319
+ // query that just ran, and a mismatch with the file's own kind is what the
320
+ // command refuses on before it makes a request.
321
+ return {
322
+ at, ms: mark, ids: [...new Set([...carried, ...inWindow])], kind: kind || null,
323
+ };
324
+ };
325
+
326
+ const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
327
+
328
+ export const formatItemLine = (item) => {
329
+ const id = String(item?.attentionItemId || item?.id || '?');
330
+ const source = String(item?.id || '');
331
+ const sourceLabel = source && source !== id ? ` source=${source}` : '';
332
+ const detail = oneLine(item?.title || item?.detail || '').slice(0, DETAIL_CHARS);
333
+ const pod = oneLine(item?.podName || item?.podId || '');
334
+ const actor = item?.actorName ? ` — ${oneLine(item.actorName)}` : '';
335
+ return `${String(item?.kind || '?').padEnd(8)} item=${id}${sourceLabel} [${pod}] ${detail}${actor}`;
336
+ };
337
+
338
+ export const countsLine = (countsByKind = {}) => {
339
+ const parts = Object.entries(countsByKind)
340
+ .sort(([a], [b]) => a.localeCompare(b))
341
+ .map(([kind, count]) => `${kind} ${count}`);
342
+ return parts.join(', ');
343
+ };
344
+
345
+ /**
346
+ * Never echo the token back, whatever the server decides to say — but only when
347
+ * there is something distinctive to remove. A needle that short shreds the
348
+ * message it is meant to clean: with a one-character token, "Attention item not
349
+ * found" loses every `t` and reads as a different error. Real tokens are JWTs or
350
+ * `cm_*` strings, so the floor is far below anything real and still above any
351
+ * string that could appear inside an ordinary sentence.
352
+ */
353
+ export const MIN_SCRUB_CHARS = 16;
354
+
355
+ export const createScrubber = (token) => {
356
+ const needle = typeof token === 'string' && token.length >= MIN_SCRUB_CHARS ? token : '';
357
+ return (value) => {
358
+ const text = String(value ?? '');
359
+ return needle ? text.split(needle).join('[redacted]') : text;
360
+ };
361
+ };
362
+
363
+ /**
364
+ * The `/api/activity` limiter answers 429 with `{code:'rate_limited'}` and
365
+ * `Retry-After` (it sets `standardHeaders`). A read that pages a 550-item queue
366
+ * is 11 requests against a limit of **60 a minute keyed on the caller's IP**
367
+ * (`backend/routes/activity.ts:43-53`) — shared by every session on one host,
368
+ * including the operator's browser — so a 429 is an ordinary busy signal here,
369
+ * not a fault. Back off and continue; fail only when the budget is still gone
370
+ * after the retries.
371
+ */
372
+ export const MAX_RATE_LIMIT_RETRIES = 2;
373
+ export const DEFAULT_BACKOFF_MS = 5_000;
374
+ export const MAX_BACKOFF_MS = 60_000;
375
+
376
+ export const retryDelayMs = (response, fallback = DEFAULT_BACKOFF_MS) => {
377
+ const header = Number(response?.headers?.get?.('retry-after'));
378
+ if (Number.isFinite(header) && header > 0) return Math.min(header * 1_000, MAX_BACKOFF_MS);
379
+ return fallback;
380
+ };
381
+
382
+ /**
383
+ * Minimal request layer, deliberately NOT `lib/api.js`'s `createClient`.
384
+ * That client answers a 401 with "run `commonly login`", which is the one
385
+ * instruction this command must never give: the token here comes from a file
386
+ * that `login` does not write. `fetchImpl` is injectable so the identity rule
387
+ * is testable without a server.
388
+ */
389
+ export const createInboxRequest = ({
390
+ baseUrl, token, tokenFile, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
391
+ }) => {
392
+ const scrub = createScrubber(token);
393
+ return async (path, { method = 'GET', params = {}, body } = {}) => {
394
+ const url = new URL(`${baseUrl}${path}`);
395
+ for (const [key, value] of Object.entries(params)) {
396
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
397
+ }
398
+ // TASK-338: the same hard timeout as every other CAP request (api.js).
399
+ const timeoutMs = capTimeoutMs(method === 'GET' ? 'read' : 'write');
400
+ for (let attempt = 0; ; attempt += 1) {
401
+ let res;
402
+ let text;
403
+ try {
404
+ // eslint-disable-next-line no-await-in-loop
405
+ res = await fetchImpl(url.toString(), {
406
+ method,
407
+ headers: {
408
+ Authorization: `Bearer ${token}`,
409
+ 'Content-Type': 'application/json',
410
+ 'User-Agent': 'commonly-cli/0.1',
411
+ },
412
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
413
+ signal: AbortSignal.timeout(timeoutMs),
414
+ });
415
+ // eslint-disable-next-line no-await-in-loop
416
+ text = await res.text();
417
+ } catch (err) {
418
+ if (err?.name !== 'TimeoutError') throw err;
419
+ throw new InboxRequestError(`${method} ${path} got no answer in ${formatLimit(timeoutMs)}`, null);
420
+ }
421
+ let parsed;
422
+ try {
423
+ parsed = JSON.parse(text);
424
+ } catch {
425
+ parsed = { message: text };
426
+ }
427
+ if (res.ok) return parsed;
428
+ if (res.status === 429 && attempt < MAX_RATE_LIMIT_RETRIES) {
429
+ // eslint-disable-next-line no-await-in-loop
430
+ await sleep(retryDelayMs(res));
431
+ continue;
432
+ }
433
+ const detail = parsed?.error || parsed?.message || parsed?.msg || parsed?.code || `HTTP ${res.status}`;
434
+ const hint = res.status === 401
435
+ ? ` — the token in ${tokenFile} was rejected. This command never uses the saved login.`
436
+ : res.status === 429
437
+ ? ' — the /api/activity rate limit (60 a minute, keyed on the caller IP, so every session on this host shares it). Wait and retry; one page per 50 items is already the minimum this read can make.'
438
+ : '';
439
+ throw new InboxRequestError(scrub(`${detail}${hint}`), res.status);
440
+ }
441
+ };
442
+ };
443
+
444
+ /**
445
+ * Page the queue through `hasMore`. Two independent stops, because a server
446
+ * that reports `hasMore: true` beside an empty page would otherwise spin
447
+ * forever, and one that lies about `count` would page past the end.
448
+ */
449
+ export const fetchQueue = async (request, { podId, since } = {}) => {
450
+ const items = [];
451
+ let meta = {};
452
+ let offset = 0;
453
+ for (;;) {
454
+ // eslint-disable-next-line no-await-in-loop
455
+ const page = await request('/api/activity/decision-queue', {
456
+ params: {
457
+ limit: API_PAGE_LIMIT,
458
+ offset,
459
+ ...(podId ? { podId } : {}),
460
+ // Only sent when there is a cursor: a first read is asking for everything
461
+ // the account has, and a bound there would silently hide its older half.
462
+ ...(since ? { since } : {}),
463
+ },
464
+ });
465
+ const batch = Array.isArray(page?.items) ? page.items : [];
466
+ items.push(...batch);
467
+ meta = page || {};
468
+ if (!meta.hasMore) break;
469
+ if (batch.length === 0) break;
470
+ // A bounded call reports the WHOLE open set in `count` and the window in
471
+ // `windowCount`, so stopping on `count` would page past the window's end.
472
+ const reported = since && Number.isInteger(meta.windowCount) ? meta.windowCount : meta.count;
473
+ if (Number.isInteger(reported) && items.length >= reported) break;
474
+ offset += batch.length;
475
+ }
476
+ return { items, meta };
477
+ };
478
+
479
+ export const resolveAccountLabel = (user) => String(
480
+ user?.username || user?.email || user?._id || 'unknown',
481
+ );
482
+
483
+ export const formatAccountLine = ({ account, baseUrl }) => `account: ${account} @ ${baseUrl}`;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * A per-spawn relay to one declared Commonly instance origin, on a Unix
3
+ * socket, or on an ephemeral loopback TCP port when no socketPath is given
4
+ * (for a Seatbelt run whose only network is loopback).
5
+ */
6
+ import { createServer, request as httpRequest } from 'http';
7
+ import { request as httpsRequest } from 'https';
8
+ import { chmod, unlink } from 'fs/promises';
9
+
10
+ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => {
11
+ const target = new URL(instanceUrl);
12
+ if (!['http:', 'https:'].includes(target.protocol)
13
+ || target.username || target.password || target.search || target.hash
14
+ || target.pathname !== '/') {
15
+ throw new Error('Space instance proxy requires a bare http(s) origin');
16
+ }
17
+ const transport = target.protocol === 'https:' ? httpsRequest : httpRequest;
18
+ const upstreams = new Set();
19
+ const server = createServer((incoming, outgoing) => {
20
+ const path = incoming.url || '';
21
+ if (!path.startsWith('/') || path.startsWith('//')
22
+ || incoming.method === 'CONNECT' || incoming.headers.upgrade) {
23
+ incoming.resume();
24
+ outgoing.writeHead(400).end();
25
+ return;
26
+ }
27
+ const headers = { ...incoming.headers, host: target.host };
28
+ delete headers.connection;
29
+ delete headers['proxy-authorization'];
30
+ delete headers['proxy-connection'];
31
+ const upstream = transport(target, {
32
+ method: incoming.method,
33
+ path,
34
+ headers,
35
+ }, (response) => {
36
+ outgoing.writeHead(response.statusCode || 502, response.headers);
37
+ response.pipe(outgoing);
38
+ response.on('error', () => outgoing.destroy());
39
+ response.on('close', () => {
40
+ if (!response.complete) outgoing.destroy();
41
+ });
42
+ });
43
+ upstreams.add(upstream);
44
+ upstream.on('close', () => upstreams.delete(upstream));
45
+ outgoing.on('close', () => upstream.destroy());
46
+ upstream.on('error', () => {
47
+ if (outgoing.destroyed) return;
48
+ if (!outgoing.headersSent) outgoing.writeHead(502);
49
+ outgoing.end();
50
+ });
51
+ incoming.pipe(upstream);
52
+ });
53
+ await new Promise((resolve, reject) => {
54
+ server.once('error', reject);
55
+ const listening = () => {
56
+ server.removeListener('error', reject);
57
+ resolve();
58
+ };
59
+ if (socketPath) server.listen(socketPath, listening);
60
+ else server.listen(0, '127.0.0.1', listening);
61
+ });
62
+ if (socketPath) await chmod(socketPath, 0o600);
63
+ const port = socketPath ? null : server.address().port;
64
+ return {
65
+ socketPath: socketPath || null,
66
+ port,
67
+ url: port ? `http://127.0.0.1:${port}` : null,
68
+ close: async () => {
69
+ const closed = new Promise((resolve) => server.close(resolve));
70
+ for (const upstream of upstreams) upstream.destroy();
71
+ server.closeAllConnections();
72
+ await closed;
73
+ if (socketPath) await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; });
74
+ },
75
+ };
76
+ };
@@ -0,0 +1,29 @@
1
+ /**
2
+ * TASK-784 (D164): what the daemon's computer runs, as the server stores it.
3
+ * Metadata only: Node's platform and arch names, the OS release's leading
4
+ * number, and this CLI's version. Nothing here may name the machine or the
5
+ * person (no hostname, path or user name); the server drops anything else.
6
+ */
7
+ import { release as osRelease } from 'os';
8
+ import { installedCliVersion } from './update-check.js';
9
+
10
+ /**
11
+ * The leading number of an OS release string: Windows "10.0.22631" is 10,
12
+ * a Darwin kernel "25.4.0" is 25, Linux "6.5.0-1025-azure" is 6.
13
+ */
14
+ export const osReleaseMajor = (release) => {
15
+ const match = /^(\d{1,4})(?:\D|$)/.exec(String(release ?? ''));
16
+ return match ? Number(match[1]) : null;
17
+ };
18
+
19
+ export const machineHostReport = ({
20
+ platform = process.platform,
21
+ release = osRelease(),
22
+ arch = process.arch,
23
+ cliVersion = installedCliVersion(),
24
+ } = {}) => ({
25
+ platform,
26
+ osMajor: osReleaseMajor(release),
27
+ arch,
28
+ cliVersion: cliVersion || null,
29
+ });