@commonlyai/cli 0.1.79 → 0.1.81

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.79",
3
+ "version": "0.1.81",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -0,0 +1,292 @@
1
+ /**
2
+ * commonly inbox <subcommand>
3
+ *
4
+ * list — read ONE account's own attention queue, newest first, optionally only what is new
5
+ * ack — acknowledge an item (takes the item's `attentionItemId`)
6
+ * choose — rule a decision card (takes the item's SOURCE id, i.e. the decision id)
7
+ *
8
+ * The account comes from `--token-file <path>` (or COMMONLY_TOKEN_FILE) and from
9
+ * nowhere else — see the header of ../lib/inbox.js for why that is a rule and
10
+ * not a preference. Every run prints the resolved account on its first line.
11
+ */
12
+
13
+ import { resolveInstanceUrl } from '../lib/config.js';
14
+ import {
15
+ InboxRefusal,
16
+ createInboxRequest,
17
+ countsLine,
18
+ fetchQueue,
19
+ filterItems,
20
+ formatAccountLine,
21
+ formatItemLine,
22
+ nextCursorFrom,
23
+ parseCursor,
24
+ parseWindowMs,
25
+ readCursorFile,
26
+ readInboxToken,
27
+ resolveAccountLabel,
28
+ resolveTokenFilePath,
29
+ writeCursorFile,
30
+ INBOX_KINDS,
31
+ TOKEN_FILE_ENV,
32
+ } from '../lib/inbox.js';
33
+
34
+ export const INBOX_TOKEN_HELP = `Account token file (or ${TOKEN_FILE_ENV})`;
35
+
36
+ const normalizeKind = (value) => {
37
+ if (value === undefined || value === null || value === '') return null;
38
+ const kind = String(value).trim().toLowerCase();
39
+ if (!INBOX_KINDS.includes(kind)) {
40
+ throw new InboxRefusal(`Unknown --kind ${value}. One of: ${INBOX_KINDS.join(', ')}.`);
41
+ }
42
+ return kind;
43
+ };
44
+
45
+ /**
46
+ * Resolve identity and build the request layer. Deliberately the only path to a
47
+ * request: every subcommand goes through it, so no subcommand can grow its own
48
+ * fallback to the saved login.
49
+ */
50
+ const connect = async (opts, { env, fetchImpl, sleep }) => {
51
+ const tokenFile = resolveTokenFilePath(opts, env);
52
+ if (!tokenFile) {
53
+ throw new InboxRefusal(
54
+ `no token file. Pass --token-file <path> or set ${TOKEN_FILE_ENV}.\n`
55
+ + 'This command reads ONE account\'s own queue; it never falls back to the saved login, because on a\n'
56
+ + 'shared operator host that login belongs to someone else (Sam\'s queue is the wrong queue, silently).',
57
+ );
58
+ }
59
+ const token = readInboxToken(tokenFile);
60
+ const baseUrl = resolveInstanceUrl(opts.instance);
61
+ const request = createInboxRequest({
62
+ baseUrl, token, tokenFile, fetchImpl, sleep,
63
+ });
64
+ const user = await request('/api/auth/user');
65
+ return {
66
+ tokenFile, baseUrl, request, user, account: resolveAccountLabel(user),
67
+ };
68
+ };
69
+
70
+ const reportRefusal = (err, error) => {
71
+ error(`refusing: ${err.message}`);
72
+ return 1;
73
+ };
74
+
75
+ const reportFailure = (err, error) => {
76
+ error(`Failed: ${err.message}`);
77
+ return 1;
78
+ };
79
+
80
+ export const runInboxList = async (opts = {}, deps = {}) => {
81
+ const {
82
+ env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
83
+ } = deps;
84
+ try {
85
+ // Validated before the request: a typo in --kind must not cost a network
86
+ // round trip, and must not read a queue the caller did not ask for.
87
+ const kind = normalizeKind(opts.kind);
88
+ // Validated here with --kind, for the same reason: a typo must not cost a
89
+ // request, and a caller who asked for a window must not silently get another.
90
+ const windowMs = parseWindowMs(opts.window);
91
+ // Read and check the cursor BEFORE connecting: this is a local-file fact, and
92
+ // a misuse of the flags must not cost even the identity request that
93
+ // resolves the account.
94
+ const sinceRaw = opts.since !== undefined ? opts.since : readCursorFile(opts.cursorFile);
95
+ const cursorIn = parseCursor(sinceRaw);
96
+ // A resume cursor is valid only for the query that wrote it. Reading a
97
+ // kind-scoped cursor with a different filter — including no filter at all —
98
+ // would advance past items of other kinds that the earlier read never
99
+ // printed, and no later read could reach them. Only this direction is
100
+ // refused: a BROAD cursor read by a narrower query loses nothing, because
101
+ // that earlier read printed every kind.
102
+ if (cursorIn?.kind && cursorIn.kind !== kind) {
103
+ throw new InboxRefusal(
104
+ `the cursor file was written by a read with --kind ${cursorIn.kind}, and this read is `
105
+ + `${kind ? `--kind ${kind}` : 'not filtered by kind'}. A cursor records how far THAT query got, so this `
106
+ + `read would skip items of other kinds that one never printed. Keep one cursor file per kind, e.g. `
107
+ + `--kind ${cursorIn.kind} --cursor-file <path>.${cursorIn.kind}.`,
108
+ );
109
+ }
110
+ // The window: W behind the cursor's own stamp, not at it. A row stamped before
111
+ // the cursor can commit after the read that wrote it, and it is that read's
112
+ // `since` — not the row — that made it invisible; a read starting W earlier
113
+ // sees it. The ids already delivered inside the window are what keep the
114
+ // re-read from printing the old rows again (see lib/inbox.js).
115
+ const sinceMs = cursorIn ? cursorIn.ms - windowMs : null;
116
+ const since = sinceMs === null ? null : new Date(sinceMs).toISOString();
117
+
118
+ const {
119
+ baseUrl, request, account,
120
+ } = await connect(opts, { env, fetchImpl, sleep });
121
+
122
+ const { items, meta } = await fetchQueue(request, { podId: opts.pod, since });
123
+ const selected = filterItems(items, {
124
+ kind,
125
+ sinceMs,
126
+ // Everything already delivered inside the window. An item of the same id in
127
+ // the response is a re-delivery, not news; an item that merely shares its
128
+ // instant with one is news, which is why this dedupes by id and not by time.
129
+ seenIds: cursorIn ? cursorIn.ids : [],
130
+ });
131
+ const cursor = nextCursorFrom(selected, cursorIn, kind, windowMs);
132
+
133
+ if (opts.json) {
134
+ log(JSON.stringify({
135
+ account,
136
+ instance: baseUrl,
137
+ count: selected.length,
138
+ // The bare timestamp: a caller feeding this back through --since loses
139
+ // the boundary ids, which degrades to re-printing that millisecond
140
+ // rather than to skipping it.
141
+ cursor: cursor ? cursor.at : null,
142
+ countsByKind: meta?.countsByKind || {},
143
+ items: selected,
144
+ }, null, 2));
145
+ } else {
146
+ log(formatAccountLine({ account, baseUrl }));
147
+ const counts = countsLine(meta?.countsByKind);
148
+ log(`open: ${Number.isInteger(meta?.count) ? meta.count : items.length}${counts ? ` (${counts})` : ''}`);
149
+ if (selected.length === 0) {
150
+ log(since ? `(nothing new; the window since ${since} was re-read)` : '(queue is empty)');
151
+ }
152
+ for (const item of selected) log(formatItemLine(item));
153
+ log(`cursor: ${cursor ? cursor.at : ''}`);
154
+ }
155
+ writeCursorFile(opts.cursorFile, cursor);
156
+ return 0;
157
+ } catch (err) {
158
+ if (err instanceof InboxRefusal) return reportRefusal(err, error);
159
+ return reportFailure(err, error);
160
+ }
161
+ };
162
+
163
+ export const runInboxAck = async (itemId, opts = {}, deps = {}) => {
164
+ const {
165
+ env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
166
+ } = deps;
167
+ try {
168
+ if (!itemId) throw new InboxRefusal('an item id is required: commonly inbox ack <attentionItemId>');
169
+ const {
170
+ baseUrl, request, account,
171
+ } = await connect(opts, { env, fetchImpl, sleep });
172
+ log(formatAccountLine({ account, baseUrl }));
173
+ const result = await request(`/api/activity/${encodeURIComponent(itemId)}/acknowledge`, {
174
+ method: 'POST',
175
+ body: {},
176
+ });
177
+ log(`acked ${itemId}${result?.success === false ? ` — server said: ${result.error || 'not acknowledged'}` : ''}`);
178
+ return 0;
179
+ } catch (err) {
180
+ if (err instanceof InboxRefusal) return reportRefusal(err, error);
181
+ if (err?.status === 400) {
182
+ error(`Failed: ${err.message}`);
183
+ error('Acknowledge takes the item\'s attentionItemId — the id printed as `item=…` by `commonly inbox list`'
184
+ + ' (the unlabelled source id is what `commonly inbox choose` takes).');
185
+ return 1;
186
+ }
187
+ return reportFailure(err, error);
188
+ }
189
+ };
190
+
191
+ export const runInboxChoose = async (decisionId, value, opts = {}, deps = {}) => {
192
+ const {
193
+ env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
194
+ } = deps;
195
+ try {
196
+ if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <value>');
197
+ if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <value>');
198
+ const {
199
+ baseUrl, request, account,
200
+ } = await connect(opts, { env, fetchImpl, sleep });
201
+ log(formatAccountLine({ account, baseUrl }));
202
+ const result = await request(`/api/activity/decisions/${encodeURIComponent(decisionId)}/choose`, {
203
+ method: 'POST',
204
+ body: { value },
205
+ });
206
+ log(`chose ${decisionId} = ${value}`);
207
+ log(`-> ${JSON.stringify(result)}`);
208
+ return 0;
209
+ } catch (err) {
210
+ if (err instanceof InboxRefusal) return reportRefusal(err, error);
211
+ return reportFailure(err, error);
212
+ }
213
+ };
214
+
215
+ export const registerInbox = (program) => {
216
+ const inbox = program
217
+ .command('inbox')
218
+ .description("Read and act on ONE account's own attention queue");
219
+
220
+ inbox.addHelpText('after', `
221
+ The account is read from a token file and from nowhere else:
222
+
223
+ $ commonly inbox list --token-file ~/.commonly/bin/connector-ops-token
224
+ $ COMMONLY_TOKEN_FILE=~/.commonly/bin/lily-token commonly inbox list
225
+
226
+ There is no fallback to the saved login. On a host where \`commonly login\` was
227
+ run once as somebody else, a fallback would print that person's queue while the
228
+ operator believed they were reading their own. Every run therefore names the
229
+ account it resolved on its first line.
230
+
231
+ With neither --token-file nor COMMONLY_TOKEN_FILE set, the command refuses
232
+ before it makes a request.
233
+
234
+ Examples:
235
+ $ commonly inbox list --json | jq '.items[].title'
236
+ $ commonly inbox list --kind decision --pod <podId>
237
+ $ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
238
+ $ commonly inbox list --cursor-file ~/.commonly/inbox/c.cursor --window 300 # widen the re-read to 5m
239
+ $ commonly inbox ack <attentionItemId>
240
+ $ commonly inbox choose <decisionId> 2
241
+
242
+ Cost: with no cursor, \`list\` reads the WHOLE queue, one request per 50 items —
243
+ 550 open items is 11 requests. /api/activity is limited to 60 a minute **keyed on
244
+ the caller's IP**, so every session on one host (and the operator's browser)
245
+ shares that budget; a 429 is backed off and retried twice on the server's own
246
+ \`Retry-After\`, then reported.
247
+
248
+ With \`--cursor-file\`, the read instead asks for \`createdAt >= cursor - W\`
249
+ (\`--window\`, default 60s), so a watcher tick is ONE request. W is the margin
250
+ over write-commit latency plus the writer's clock offset — a row is stamped before
251
+ its write commits, so resuming exactly at the cursor would lose it. Rows already
252
+ delivered inside W are dropped by id, which is the re-delivery the window is for.
253
+ The counts printed in the header still describe the whole open set; only the rows
254
+ inside the window are printed.
255
+
256
+ \`ack\` deliberately ships WITHOUT an \`--all\`: a burst of acks shares that same
257
+ per-IP budget, so a paced loop is the caller's decision and a bulk
258
+ \`ids[]\`-accepting route (one request) is the kernel-side answer.
259
+
260
+ Ids: \`list\` prints both ids an item has. \`ack\` takes \`item=…\`
261
+ (attentionItemId); \`choose\` takes the item's source id, which for a decision is
262
+ the decision id. Agent runtime tokens (cm_agent_…) are out of scope — they get
263
+ their attention as runtime events, not from this queue.
264
+ `);
265
+
266
+ inbox
267
+ .command('list')
268
+ .description("List the account's open attention queue, newest first")
269
+ .option('--token-file <path>', INBOX_TOKEN_HELP)
270
+ .option('--since <cursor>', 'ISO-8601 timestamp; only items created after it')
271
+ .option('--cursor-file <path>', 'Read the cursor from this file and write the advanced cursor back')
272
+ .option('--window <seconds>', 'Re-read this far behind the cursor, dropping ids already delivered (default 60; 0 = exact cursor)')
273
+ .option('--kind <kind>', `Only this kind: ${INBOX_KINDS.join('|')}`)
274
+ .option('--pod <podId>', 'Only this pod')
275
+ .option('--json', 'Emit one JSON object (with the resolved account and the cursor)')
276
+ .option('--instance <url>', 'Target Commonly instance')
277
+ .action(async (opts) => { process.exit(await runInboxList(opts)); });
278
+
279
+ inbox
280
+ .command('ack <itemId>')
281
+ .description('Acknowledge an attention item (the item= id from `list`)')
282
+ .option('--token-file <path>', INBOX_TOKEN_HELP)
283
+ .option('--instance <url>', 'Target Commonly instance')
284
+ .action(async (itemId, opts) => { process.exit(await runInboxAck(itemId, opts)); });
285
+
286
+ inbox
287
+ .command('choose <decisionId> <value>')
288
+ .description('Rule a decision card (the source= id from `list`)')
289
+ .option('--token-file <path>', INBOX_TOKEN_HELP)
290
+ .option('--instance <url>', 'Target Commonly instance')
291
+ .action(async (decisionId, value, opts) => { process.exit(await runInboxChoose(decisionId, value, opts)); });
292
+ };
package/src/index.js CHANGED
@@ -15,6 +15,7 @@ import { join, dirname } from 'path';
15
15
  import { fileURLToPath } from 'url';
16
16
 
17
17
  import { registerLogin, registerWhoami } from './commands/login.js';
18
+ import { registerInbox } from './commands/inbox.js';
18
19
  import { registerAgent } from './commands/agent.js';
19
20
  import { registerDaemon } from './commands/daemon.js';
20
21
  import { registerPod } from './commands/pod.js';
@@ -35,6 +36,10 @@ program
35
36
  registerLogin(program);
36
37
  registerWhoami(program);
37
38
 
39
+ // An operator account's own attention queue (explicit token file, never the
40
+ // saved login — see cli/src/lib/inbox.js)
41
+ registerInbox(program);
42
+
38
43
  // Agent management
39
44
  registerAgent(program);
40
45
 
@@ -59,6 +64,7 @@ Quick start:
59
64
  $ commonly agent detach my-claude # clean uninstall
60
65
  $ commonly daemon register --name "My MacBook"
61
66
  $ commonly daemon status
67
+ $ commonly inbox list --token-file ~/.commonly/bin/ops-token # an operator's own queue
62
68
 
63
69
  Custom Python agent:
64
70
  $ commonly agent init --language python --name research-bot --pod <podId>
@@ -0,0 +1,472 @@
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
+
66
+ export const INBOX_KINDS = ['mention', 'decision', 'handoff', 'approval'];
67
+ export const TOKEN_FILE_ENV = 'COMMONLY_TOKEN_FILE';
68
+ export const API_PAGE_LIMIT = 50;
69
+
70
+ const TOKEN_FIELDS = ['token', 'runtimeToken', 'accessToken', 'access_token'];
71
+ const DETAIL_CHARS = 160;
72
+
73
+ /** A refusal the operator can fix by re-reading the message. Never a server fault. */
74
+ export class InboxRefusal extends Error {}
75
+
76
+ /** A server-side failure. `status` is the HTTP status. */
77
+ export class InboxRequestError extends Error {
78
+ constructor(message, status) {
79
+ super(message);
80
+ this.name = 'InboxRequestError';
81
+ this.status = status;
82
+ }
83
+ }
84
+
85
+ export const resolveTokenFilePath = (opts = {}, env = process.env) => {
86
+ const flag = typeof opts.tokenFile === 'string' ? opts.tokenFile.trim() : '';
87
+ if (flag) return flag;
88
+ const fromEnv = typeof env?.[TOKEN_FILE_ENV] === 'string' ? env[TOKEN_FILE_ENV].trim() : '';
89
+ return fromEnv || null;
90
+ };
91
+
92
+ /**
93
+ * Pull a token out of a file body. A raw token is the documented shape (that is
94
+ * what `~/.commonly/bin/<account>-token` holds today); JSON is tolerated because
95
+ * the CLI's own token files are JSON, and a caller that reaches for one should
96
+ * get a named answer rather than a confusing 401.
97
+ */
98
+ export const extractToken = (raw) => {
99
+ const text = String(raw ?? '').trim();
100
+ if (!text) return null;
101
+ if (text.startsWith('{')) {
102
+ let parsed;
103
+ try {
104
+ parsed = JSON.parse(text);
105
+ } catch {
106
+ return null;
107
+ }
108
+ for (const field of TOKEN_FIELDS) {
109
+ const value = parsed?.[field];
110
+ if (typeof value === 'string' && value.trim()) return value.trim();
111
+ }
112
+ return null;
113
+ }
114
+ return text.replace(/^Bearer\s+/i, '').trim() || null;
115
+ };
116
+
117
+ /**
118
+ * Read the operator's token. Every failure here is a refusal with the PATH in
119
+ * it — never the token, and never a hint to run `commonly login`, which would
120
+ * point the account back at the saved login this command exists to avoid.
121
+ */
122
+ export const readInboxToken = (filePath) => {
123
+ let raw;
124
+ try {
125
+ raw = readFileSync(filePath, 'utf8');
126
+ } catch (err) {
127
+ throw new InboxRefusal(
128
+ `Cannot read the token file ${filePath} (${err?.code || err?.message}).`,
129
+ );
130
+ }
131
+ const token = extractToken(raw);
132
+ if (!token) {
133
+ throw new InboxRefusal(
134
+ `No token in ${filePath}. Expected a raw token, or JSON carrying one of: ${TOKEN_FIELDS.join(', ')}.`,
135
+ );
136
+ }
137
+ if (token.startsWith('cm_agent_')) {
138
+ throw new InboxRefusal(
139
+ `${filePath} holds an AGENT runtime token (cm_agent_…). This command reads a HUMAN account's own queue; `
140
+ + 'agent attention arrives as runtime events, not here. Pass that account\'s own token file.',
141
+ );
142
+ }
143
+ return token;
144
+ };
145
+
146
+ /** `--since` / a cursor file holds an ISO-8601 timestamp; null means "everything". */
147
+ export const parseCursor = (value) => {
148
+ const text = value === undefined || value === null ? '' : String(value).trim();
149
+ if (!text) return null;
150
+ // Two accepted shapes. The JSON object is what `list` writes (a timestamp plus
151
+ // the ids delivered at it); a bare ISO timestamp is what an operator copies off
152
+ // the `cursor:` line, and what older versions wrote — it parses with an empty id
153
+ // set, so the boundary items re-print instead of disappearing.
154
+ let at = text;
155
+ let ids = [];
156
+ let kind = null;
157
+ if (text.startsWith('{')) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(text);
161
+ } catch {
162
+ throw new InboxRefusal(`Not a cursor: ${text}`);
163
+ }
164
+ at = parsed?.at;
165
+ ids = Array.isArray(parsed?.ids) ? parsed.ids.filter((id) => typeof id === 'string' && id) : [];
166
+ // Which kind filter the read that wrote this cursor used, so the next read
167
+ // can tell whether it is asking the same question — see the rule at the top
168
+ // of this file. Absent on a bare timestamp and on a file written by an
169
+ // unfiltered read, which is the direction that cannot lose a row.
170
+ kind = typeof parsed?.kind === 'string' && parsed.kind ? parsed.kind : null;
171
+ }
172
+ const ms = Date.parse(String(at));
173
+ if (!Number.isFinite(ms)) {
174
+ throw new InboxRefusal(`Not an ISO-8601 timestamp: ${at}`);
175
+ }
176
+ return {
177
+ at: new Date(ms).toISOString(), ms, ids, kind,
178
+ };
179
+ };
180
+
181
+ /**
182
+ * `--window <seconds>`: how far behind the cursor a read starts. The default is
183
+ * the spec's 60s — the margin over in-cluster write latency and clock offset —
184
+ * and 0 restores the exact-cursor behaviour. Refused rather than clamped: a
185
+ * negative or unreadable window would silently change which rows a read can see,
186
+ * and that is the failure this whole module is built to avoid.
187
+ */
188
+ export const DEFAULT_WINDOW_MS = 60 * 1000;
189
+
190
+ export const parseWindowMs = (value, fallback = DEFAULT_WINDOW_MS) => {
191
+ if (value === undefined || value === null || value === '') return fallback;
192
+ const seconds = Number(String(value).trim());
193
+ if (!Number.isFinite(seconds) || seconds < 0) {
194
+ throw new InboxRefusal(`--window must be a number of seconds >= 0, not ${value}.`);
195
+ }
196
+ return Math.round(seconds * 1000);
197
+ };
198
+
199
+ /** A missing cursor file is "nothing seen yet", not an error. */
200
+ export const readCursorFile = (filePath) => {
201
+ if (!filePath) return null;
202
+ try {
203
+ return readFileSync(filePath, 'utf8').trim() || null;
204
+ } catch (err) {
205
+ if (err?.code === 'ENOENT') return null;
206
+ throw new InboxRefusal(`Cannot read the cursor file ${filePath} (${err?.code || err?.message}).`);
207
+ }
208
+ };
209
+
210
+ /** The cursor as it goes on disk: `{"at":"<iso>","ids":[...]}` on one line. */
211
+ export const formatCursor = (cursor) => {
212
+ if (!cursor) return null;
213
+ if (typeof cursor === 'string') return cursor;
214
+ const at = cursor.at || (Number.isFinite(cursor.ms) ? new Date(cursor.ms).toISOString() : null);
215
+ if (!at) return null;
216
+ const ids = Array.isArray(cursor.ids) ? cursor.ids : [];
217
+ // `kind` is written only when the read was scoped to one, so an unfiltered
218
+ // cursor keeps the exact shape it had before and neither gains nor loses
219
+ // meaning.
220
+ return JSON.stringify(cursor.kind ? { at, ids, kind: cursor.kind } : { at, ids });
221
+ };
222
+
223
+ /**
224
+ * Written temp-then-rename so a watcher reading this file mid-write sees either
225
+ * the previous cursor or the new one, never a half line. A failed write must not
226
+ * fail the run whose items were already printed, so the caller decides.
227
+ */
228
+ export const writeCursorFile = (filePath, cursor) => {
229
+ const text = formatCursor(cursor);
230
+ if (!filePath || !text) return;
231
+ mkdirSync(dirname(filePath), { recursive: true });
232
+ const tmp = `${filePath}.tmp`;
233
+ writeFileSync(tmp, `${text}\n`, 'utf8');
234
+ renameSync(tmp, filePath);
235
+ };
236
+
237
+ export const itemCreatedAtMs = (item) => {
238
+ const ms = Date.parse(String(item?.createdAt ?? ''));
239
+ return Number.isFinite(ms) ? ms : null;
240
+ };
241
+
242
+ /** The identity an item is remembered by between runs: the id `ack` consumes. */
243
+ export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id || '');
244
+
245
+ /**
246
+ * An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
247
+ * would lose the row silently, and re-printing is the recoverable error. The same
248
+ * argument decides the boundary — see the module header.
249
+ *
250
+ * `seenIds` is the set delivered inside the window and is dropped BY ID, whatever
251
+ * the item's timestamp: the window exists to re-serve those rows to the server
252
+ * call, and the id is the only thing that can say "the caller already saw this
253
+ * one". `sinceMs` is the window's own start and is inclusive, matching the
254
+ * route's bound, so a row stamped exactly at it still prints.
255
+ */
256
+ export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
257
+ const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
258
+ return (items || []).filter((item) => {
259
+ if (kind && item?.kind !== kind) return false;
260
+ if (delivered.has(cursorKeyOf(item))) return false;
261
+ if (sinceMs === null) return true;
262
+ const ms = itemCreatedAtMs(item);
263
+ if (ms === null) return true;
264
+ return ms >= sinceMs;
265
+ });
266
+ };
267
+
268
+ /**
269
+ * Newest timestamp this query has covered — the previous cursor's stamp or the
270
+ * newest item printed, whichever is later — so the next call resumes from W behind
271
+ * it, plus every id delivered at or after that window's start, which is what the
272
+ * next read's window will re-serve and must therefore not print twice.
273
+ *
274
+ * The max is taken over the PRINTED items, not the server's `nextSince`, because
275
+ * `--kind` is filtered on this side: the server's window may hold rows of other
276
+ * kinds that this read will never print, and advancing the cursor onto them would
277
+ * move the high-water mark past a row of THIS kind that a later page has not
278
+ * delivered yet. When nothing printed, the cursor stays where it was — the
279
+ * fallback — so a quiet tick does not move the mark at all.
280
+ *
281
+ * The set is recomputed from the window plus the ids the previous cursor had not
282
+ * yet aged out of it, and both halves are load-bearing: this read's items are
283
+ * post-dedupe, so a row it dropped is absent from them and would be forgotten
284
+ * without the carry. The prune is what bounds the file at one window of
285
+ * deliveries instead of a list of everything ever printed.
286
+ */
287
+ export const nextCursorFrom = (items, fallback = null, kind = null, windowMs = DEFAULT_WINDOW_MS) => {
288
+ let max = null;
289
+ for (const item of items || []) {
290
+ const ms = itemCreatedAtMs(item);
291
+ if (ms === null) continue;
292
+ if (max === null || ms > max) max = ms;
293
+ }
294
+ if (max === null) return fallback;
295
+ // A high-water mark: the newest item printed, or the cursor that came in,
296
+ // whichever is later. A row printed from inside the window is older than the
297
+ // mark that delimited that window, and letting it drag the mark back would move
298
+ // the next read's `since` back with it — reopening ground this query has already
299
+ // covered and moving the prune cutoff that bounds the id set.
300
+ const mark = fallback && Number.isFinite(fallback.ms) && fallback.ms > max ? fallback.ms : max;
301
+ const at = new Date(mark).toISOString();
302
+ const cutoff = mark - windowMs;
303
+ const inWindow = (items || []).filter((item) => {
304
+ const ms = itemCreatedAtMs(item);
305
+ return ms !== null && ms >= cutoff;
306
+ }).map(cursorKeyOf).filter(Boolean);
307
+ // The ids from the previous cursor are NOT dead weight, and dropping them is a
308
+ // measurable bug: `items` is what this read SELECTED, so a row delivered last
309
+ // tick has already been filtered out of it, and the set built from `items` alone
310
+ // would forget it — then the read after that would print it again, and the
311
+ // dedupe would alternate every tick. They are carried only while the previous
312
+ // cursor's own stamp is still inside this window; past that the next read's
313
+ // `since` is later than every one of them, the server can never re-serve them,
314
+ // and keeping them would grow the file for nothing.
315
+ const carried = fallback && Number.isFinite(fallback.ms) && fallback.ms >= cutoff
316
+ ? (fallback.ids || []) : [];
317
+ // The kind is the CURRENT read's, never inherited: the cursor describes the
318
+ // query that just ran, and a mismatch with the file's own kind is what the
319
+ // command refuses on before it makes a request.
320
+ return {
321
+ at, ms: mark, ids: [...new Set([...carried, ...inWindow])], kind: kind || null,
322
+ };
323
+ };
324
+
325
+ const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
326
+
327
+ export const formatItemLine = (item) => {
328
+ const id = String(item?.attentionItemId || item?.id || '?');
329
+ const source = String(item?.id || '');
330
+ const sourceLabel = source && source !== id ? ` source=${source}` : '';
331
+ const detail = oneLine(item?.title || item?.detail || '').slice(0, DETAIL_CHARS);
332
+ const pod = oneLine(item?.podName || item?.podId || '');
333
+ const actor = item?.actorName ? ` — ${oneLine(item.actorName)}` : '';
334
+ return `${String(item?.kind || '?').padEnd(8)} item=${id}${sourceLabel} [${pod}] ${detail}${actor}`;
335
+ };
336
+
337
+ export const countsLine = (countsByKind = {}) => {
338
+ const parts = Object.entries(countsByKind)
339
+ .sort(([a], [b]) => a.localeCompare(b))
340
+ .map(([kind, count]) => `${kind} ${count}`);
341
+ return parts.join(', ');
342
+ };
343
+
344
+ /**
345
+ * Never echo the token back, whatever the server decides to say — but only when
346
+ * there is something distinctive to remove. A needle that short shreds the
347
+ * message it is meant to clean: with a one-character token, "Attention item not
348
+ * found" loses every `t` and reads as a different error. Real tokens are JWTs or
349
+ * `cm_*` strings, so the floor is far below anything real and still above any
350
+ * string that could appear inside an ordinary sentence.
351
+ */
352
+ export const MIN_SCRUB_CHARS = 16;
353
+
354
+ export const createScrubber = (token) => {
355
+ const needle = typeof token === 'string' && token.length >= MIN_SCRUB_CHARS ? token : '';
356
+ return (value) => {
357
+ const text = String(value ?? '');
358
+ return needle ? text.split(needle).join('[redacted]') : text;
359
+ };
360
+ };
361
+
362
+ /**
363
+ * The `/api/activity` limiter answers 429 with `{code:'rate_limited'}` and
364
+ * `Retry-After` (it sets `standardHeaders`). A read that pages a 550-item queue
365
+ * is 11 requests against a limit of **60 a minute keyed on the caller's IP**
366
+ * (`backend/routes/activity.ts:43-53`) — shared by every session on one host,
367
+ * including the operator's browser — so a 429 is an ordinary busy signal here,
368
+ * not a fault. Back off and continue; fail only when the budget is still gone
369
+ * after the retries.
370
+ */
371
+ export const MAX_RATE_LIMIT_RETRIES = 2;
372
+ export const DEFAULT_BACKOFF_MS = 5_000;
373
+ export const MAX_BACKOFF_MS = 60_000;
374
+
375
+ export const retryDelayMs = (response, fallback = DEFAULT_BACKOFF_MS) => {
376
+ const header = Number(response?.headers?.get?.('retry-after'));
377
+ if (Number.isFinite(header) && header > 0) return Math.min(header * 1_000, MAX_BACKOFF_MS);
378
+ return fallback;
379
+ };
380
+
381
+ /**
382
+ * Minimal request layer, deliberately NOT `lib/api.js`'s `createClient`.
383
+ * That client answers a 401 with "run `commonly login`", which is the one
384
+ * instruction this command must never give: the token here comes from a file
385
+ * that `login` does not write. `fetchImpl` is injectable so the identity rule
386
+ * is testable without a server.
387
+ */
388
+ export const createInboxRequest = ({
389
+ baseUrl, token, tokenFile, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
390
+ }) => {
391
+ const scrub = createScrubber(token);
392
+ return async (path, { method = 'GET', params = {}, body } = {}) => {
393
+ const url = new URL(`${baseUrl}${path}`);
394
+ for (const [key, value] of Object.entries(params)) {
395
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
396
+ }
397
+ for (let attempt = 0; ; attempt += 1) {
398
+ // eslint-disable-next-line no-await-in-loop
399
+ const res = await fetchImpl(url.toString(), {
400
+ method,
401
+ headers: {
402
+ Authorization: `Bearer ${token}`,
403
+ 'Content-Type': 'application/json',
404
+ 'User-Agent': 'commonly-cli/0.1',
405
+ },
406
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
407
+ });
408
+ // eslint-disable-next-line no-await-in-loop
409
+ const text = await res.text();
410
+ let parsed;
411
+ try {
412
+ parsed = JSON.parse(text);
413
+ } catch {
414
+ parsed = { message: text };
415
+ }
416
+ if (res.ok) return parsed;
417
+ if (res.status === 429 && attempt < MAX_RATE_LIMIT_RETRIES) {
418
+ // eslint-disable-next-line no-await-in-loop
419
+ await sleep(retryDelayMs(res));
420
+ continue;
421
+ }
422
+ const detail = parsed?.error || parsed?.message || parsed?.msg || parsed?.code || `HTTP ${res.status}`;
423
+ const hint = res.status === 401
424
+ ? ` — the token in ${tokenFile} was rejected. This command never uses the saved login.`
425
+ : res.status === 429
426
+ ? ' — 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.'
427
+ : '';
428
+ throw new InboxRequestError(scrub(`${detail}${hint}`), res.status);
429
+ }
430
+ };
431
+ };
432
+
433
+ /**
434
+ * Page the queue through `hasMore`. Two independent stops, because a server
435
+ * that reports `hasMore: true` beside an empty page would otherwise spin
436
+ * forever, and one that lies about `count` would page past the end.
437
+ */
438
+ export const fetchQueue = async (request, { podId, since } = {}) => {
439
+ const items = [];
440
+ let meta = {};
441
+ let offset = 0;
442
+ for (;;) {
443
+ // eslint-disable-next-line no-await-in-loop
444
+ const page = await request('/api/activity/decision-queue', {
445
+ params: {
446
+ limit: API_PAGE_LIMIT,
447
+ offset,
448
+ ...(podId ? { podId } : {}),
449
+ // Only sent when there is a cursor: a first read is asking for everything
450
+ // the account has, and a bound there would silently hide its older half.
451
+ ...(since ? { since } : {}),
452
+ },
453
+ });
454
+ const batch = Array.isArray(page?.items) ? page.items : [];
455
+ items.push(...batch);
456
+ meta = page || {};
457
+ if (!meta.hasMore) break;
458
+ if (batch.length === 0) break;
459
+ // A bounded call reports the WHOLE open set in `count` and the window in
460
+ // `windowCount`, so stopping on `count` would page past the window's end.
461
+ const reported = since && Number.isInteger(meta.windowCount) ? meta.windowCount : meta.count;
462
+ if (Number.isInteger(reported) && items.length >= reported) break;
463
+ offset += batch.length;
464
+ }
465
+ return { items, meta };
466
+ };
467
+
468
+ export const resolveAccountLabel = (user) => String(
469
+ user?.username || user?.email || user?._id || 'unknown',
470
+ );
471
+
472
+ export const formatAccountLine = ({ account, baseUrl }) => `account: ${account} @ ${baseUrl}`;