@commonlyai/cli 0.1.79 → 0.1.80

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.80",
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,275 @@
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
+ readCursorFile,
25
+ readInboxToken,
26
+ resolveAccountLabel,
27
+ resolveTokenFilePath,
28
+ writeCursorFile,
29
+ INBOX_KINDS,
30
+ TOKEN_FILE_ENV,
31
+ } from '../lib/inbox.js';
32
+
33
+ export const INBOX_TOKEN_HELP = `Account token file (or ${TOKEN_FILE_ENV})`;
34
+
35
+ const normalizeKind = (value) => {
36
+ if (value === undefined || value === null || value === '') return null;
37
+ const kind = String(value).trim().toLowerCase();
38
+ if (!INBOX_KINDS.includes(kind)) {
39
+ throw new InboxRefusal(`Unknown --kind ${value}. One of: ${INBOX_KINDS.join(', ')}.`);
40
+ }
41
+ return kind;
42
+ };
43
+
44
+ /**
45
+ * Resolve identity and build the request layer. Deliberately the only path to a
46
+ * request: every subcommand goes through it, so no subcommand can grow its own
47
+ * fallback to the saved login.
48
+ */
49
+ const connect = async (opts, { env, fetchImpl, sleep }) => {
50
+ const tokenFile = resolveTokenFilePath(opts, env);
51
+ if (!tokenFile) {
52
+ throw new InboxRefusal(
53
+ `no token file. Pass --token-file <path> or set ${TOKEN_FILE_ENV}.\n`
54
+ + 'This command reads ONE account\'s own queue; it never falls back to the saved login, because on a\n'
55
+ + 'shared operator host that login belongs to someone else (Sam\'s queue is the wrong queue, silently).',
56
+ );
57
+ }
58
+ const token = readInboxToken(tokenFile);
59
+ const baseUrl = resolveInstanceUrl(opts.instance);
60
+ const request = createInboxRequest({
61
+ baseUrl, token, tokenFile, fetchImpl, sleep,
62
+ });
63
+ const user = await request('/api/auth/user');
64
+ return {
65
+ tokenFile, baseUrl, request, user, account: resolveAccountLabel(user),
66
+ };
67
+ };
68
+
69
+ const reportRefusal = (err, error) => {
70
+ error(`refusing: ${err.message}`);
71
+ return 1;
72
+ };
73
+
74
+ const reportFailure = (err, error) => {
75
+ error(`Failed: ${err.message}`);
76
+ return 1;
77
+ };
78
+
79
+ export const runInboxList = async (opts = {}, deps = {}) => {
80
+ const {
81
+ env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
82
+ } = deps;
83
+ try {
84
+ // Validated before the request: a typo in --kind must not cost a network
85
+ // round trip, and must not read a queue the caller did not ask for.
86
+ const kind = normalizeKind(opts.kind);
87
+ // Read and check the cursor BEFORE connecting: this is a local-file fact, and
88
+ // a misuse of the flags must not cost even the identity request that
89
+ // resolves the account.
90
+ const sinceRaw = opts.since !== undefined ? opts.since : readCursorFile(opts.cursorFile);
91
+ const cursorIn = parseCursor(sinceRaw);
92
+ // A resume cursor is valid only for the query that wrote it. Reading a
93
+ // kind-scoped cursor with a different filter — including no filter at all —
94
+ // would advance past items of other kinds that the earlier read never
95
+ // printed, and no later read could reach them. Only this direction is
96
+ // refused: a BROAD cursor read by a narrower query loses nothing, because
97
+ // that earlier read printed every kind.
98
+ if (cursorIn?.kind && cursorIn.kind !== kind) {
99
+ throw new InboxRefusal(
100
+ `the cursor file was written by a read with --kind ${cursorIn.kind}, and this read is `
101
+ + `${kind ? `--kind ${kind}` : 'not filtered by kind'}. A cursor records how far THAT query got, so this `
102
+ + `read would skip items of other kinds that one never printed. Keep one cursor file per kind, e.g. `
103
+ + `--kind ${cursorIn.kind} --cursor-file <path>.${cursorIn.kind}.`,
104
+ );
105
+ }
106
+ const sinceMs = cursorIn ? cursorIn.ms : null;
107
+ const since = cursorIn ? cursorIn.at : null;
108
+
109
+ const {
110
+ baseUrl, request, account,
111
+ } = await connect(opts, { env, fetchImpl, sleep });
112
+
113
+ const { items, meta } = await fetchQueue(request, { podId: opts.pod });
114
+ const selected = filterItems(items, {
115
+ kind,
116
+ sinceMs,
117
+ // The ids already delivered at the cursor's own millisecond. An item that
118
+ // shares that millisecond but is not among them arrived after the cursor
119
+ // was written, and is exactly the row a bare `ms > since` loses.
120
+ seenIds: cursorIn ? cursorIn.ids : [],
121
+ });
122
+ const cursor = nextCursorFrom(selected, cursorIn, kind);
123
+
124
+ if (opts.json) {
125
+ log(JSON.stringify({
126
+ account,
127
+ instance: baseUrl,
128
+ count: selected.length,
129
+ // The bare timestamp: a caller feeding this back through --since loses
130
+ // the boundary ids, which degrades to re-printing that millisecond
131
+ // rather than to skipping it.
132
+ cursor: cursor ? cursor.at : null,
133
+ countsByKind: meta?.countsByKind || {},
134
+ items: selected,
135
+ }, null, 2));
136
+ } else {
137
+ log(formatAccountLine({ account, baseUrl }));
138
+ const counts = countsLine(meta?.countsByKind);
139
+ log(`open: ${Number.isInteger(meta?.count) ? meta.count : items.length}${counts ? ` (${counts})` : ''}`);
140
+ if (selected.length === 0) {
141
+ log(since ? `(no items newer than ${since})` : '(queue is empty)');
142
+ }
143
+ for (const item of selected) log(formatItemLine(item));
144
+ log(`cursor: ${cursor ? cursor.at : ''}`);
145
+ }
146
+ writeCursorFile(opts.cursorFile, cursor);
147
+ return 0;
148
+ } catch (err) {
149
+ if (err instanceof InboxRefusal) return reportRefusal(err, error);
150
+ return reportFailure(err, error);
151
+ }
152
+ };
153
+
154
+ export const runInboxAck = async (itemId, opts = {}, deps = {}) => {
155
+ const {
156
+ env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
157
+ } = deps;
158
+ try {
159
+ if (!itemId) throw new InboxRefusal('an item id is required: commonly inbox ack <attentionItemId>');
160
+ const {
161
+ baseUrl, request, account,
162
+ } = await connect(opts, { env, fetchImpl, sleep });
163
+ log(formatAccountLine({ account, baseUrl }));
164
+ const result = await request(`/api/activity/${encodeURIComponent(itemId)}/acknowledge`, {
165
+ method: 'POST',
166
+ body: {},
167
+ });
168
+ log(`acked ${itemId}${result?.success === false ? ` — server said: ${result.error || 'not acknowledged'}` : ''}`);
169
+ return 0;
170
+ } catch (err) {
171
+ if (err instanceof InboxRefusal) return reportRefusal(err, error);
172
+ if (err?.status === 400) {
173
+ error(`Failed: ${err.message}`);
174
+ error('Acknowledge takes the item\'s attentionItemId — the id printed as `item=…` by `commonly inbox list`'
175
+ + ' (the unlabelled source id is what `commonly inbox choose` takes).');
176
+ return 1;
177
+ }
178
+ return reportFailure(err, error);
179
+ }
180
+ };
181
+
182
+ export const runInboxChoose = async (decisionId, value, opts = {}, deps = {}) => {
183
+ const {
184
+ env = process.env, fetchImpl = fetch, log = console.log, error = console.error, sleep = undefined,
185
+ } = deps;
186
+ try {
187
+ if (!decisionId) throw new InboxRefusal('a decision id is required: commonly inbox choose <decisionId> <value>');
188
+ if (value === undefined) throw new InboxRefusal('a value is required: commonly inbox choose <decisionId> <value>');
189
+ const {
190
+ baseUrl, request, account,
191
+ } = await connect(opts, { env, fetchImpl, sleep });
192
+ log(formatAccountLine({ account, baseUrl }));
193
+ const result = await request(`/api/activity/decisions/${encodeURIComponent(decisionId)}/choose`, {
194
+ method: 'POST',
195
+ body: { value },
196
+ });
197
+ log(`chose ${decisionId} = ${value}`);
198
+ log(`-> ${JSON.stringify(result)}`);
199
+ return 0;
200
+ } catch (err) {
201
+ if (err instanceof InboxRefusal) return reportRefusal(err, error);
202
+ return reportFailure(err, error);
203
+ }
204
+ };
205
+
206
+ export const registerInbox = (program) => {
207
+ const inbox = program
208
+ .command('inbox')
209
+ .description("Read and act on ONE account's own attention queue");
210
+
211
+ inbox.addHelpText('after', `
212
+ The account is read from a token file and from nowhere else:
213
+
214
+ $ commonly inbox list --token-file ~/.commonly/bin/connector-ops-token
215
+ $ COMMONLY_TOKEN_FILE=~/.commonly/bin/lily-token commonly inbox list
216
+
217
+ There is no fallback to the saved login. On a host where \`commonly login\` was
218
+ run once as somebody else, a fallback would print that person's queue while the
219
+ operator believed they were reading their own. Every run therefore names the
220
+ account it resolved on its first line.
221
+
222
+ With neither --token-file nor COMMONLY_TOKEN_FILE set, the command refuses
223
+ before it makes a request.
224
+
225
+ Examples:
226
+ $ commonly inbox list --json | jq '.items[].title'
227
+ $ commonly inbox list --kind decision --pod <podId>
228
+ $ commonly inbox list --cursor-file ~/.commonly/inbox/connector-ops.cursor # only what is new
229
+ $ commonly inbox ack <attentionItemId>
230
+ $ commonly inbox choose <decisionId> 2
231
+
232
+ Cost: \`list\` reads the WHOLE queue, one request per 50 items — 550 open
233
+ items is 11 requests — and /api/activity is limited to 60 a minute **keyed on
234
+ the caller's IP**, so every session on one host (and the operator's browser)
235
+ shares that budget. A 429 is backed off and retried twice on the server's own
236
+ \`Retry-After\`, then reported. A cursor narrows what is PRINTED, not what is
237
+ read: the route has no created-since filter yet, so \`--pod\` is the one scope
238
+ that narrows the request itself.
239
+
240
+ \`ack\` deliberately ships WITHOUT an \`--all\`: a burst of acks shares that same
241
+ per-IP budget, so a paced loop is the caller's decision and a bulk
242
+ \`ids[]\`-accepting route (one request) is the kernel-side answer.
243
+
244
+ Ids: \`list\` prints both ids an item has. \`ack\` takes \`item=…\`
245
+ (attentionItemId); \`choose\` takes the item's source id, which for a decision is
246
+ the decision id. Agent runtime tokens (cm_agent_…) are out of scope — they get
247
+ their attention as runtime events, not from this queue.
248
+ `);
249
+
250
+ inbox
251
+ .command('list')
252
+ .description("List the account's open attention queue, newest first")
253
+ .option('--token-file <path>', INBOX_TOKEN_HELP)
254
+ .option('--since <cursor>', 'ISO-8601 timestamp; only items created after it')
255
+ .option('--cursor-file <path>', 'Read the cursor from this file and write the advanced cursor back')
256
+ .option('--kind <kind>', `Only this kind: ${INBOX_KINDS.join('|')}`)
257
+ .option('--pod <podId>', 'Only this pod')
258
+ .option('--json', 'Emit one JSON object (with the resolved account and the cursor)')
259
+ .option('--instance <url>', 'Target Commonly instance')
260
+ .action(async (opts) => { process.exit(await runInboxList(opts)); });
261
+
262
+ inbox
263
+ .command('ack <itemId>')
264
+ .description('Acknowledge an attention item (the item= id from `list`)')
265
+ .option('--token-file <path>', INBOX_TOKEN_HELP)
266
+ .option('--instance <url>', 'Target Commonly instance')
267
+ .action(async (itemId, opts) => { process.exit(await runInboxAck(itemId, opts)); });
268
+
269
+ inbox
270
+ .command('choose <decisionId> <value>')
271
+ .description('Rule a decision card (the source= id from `list`)')
272
+ .option('--token-file <path>', INBOX_TOKEN_HELP)
273
+ .option('--instance <url>', 'Target Commonly instance')
274
+ .action(async (decisionId, value, opts) => { process.exit(await runInboxChoose(decisionId, value, opts)); });
275
+ };
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,398 @@
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 at exactly that timestamp,
28
+ * compared against `createdAt` — the only field on an item that never moves. The
29
+ * 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 ids are not decoration. A bare `ms > since` cursor loses any row that
33
+ * shares its millisecond with the cursor and was inserted after the cursor was
34
+ * written: the next run compares `ms > since` (false), nothing prints it, and no
35
+ * later run can see it either, because the cursor never moves. That row is gone
36
+ * silently and forever — the one failure this queue (576 open items, none ever
37
+ * read) cannot afford. So the cursor carries the ids it already delivered at its
38
+ * own millisecond and re-prints only the ones it did not: `ms > sinceMs ||
39
+ * (ms === sinceMs && !delivered.has(id))`. A legacy plain-timestamp cursor parses
40
+ * with an EMPTY set, which degrades toward re-printing rather than skipping.
41
+ *
42
+ * THE GENERAL RULE, which both cursor bugs above are instances of: a resume
43
+ * cursor is valid only for the query that wrote it. `--kind` is a DIFFERENT
44
+ * query over a subset of the same queue, so a kind-filtered read advances the
45
+ * high-water mark past items of every other kind — items that read never printed,
46
+ * and that no later read can reach, because the mark has already moved beyond
47
+ * them. A cursor file therefore records the kind filter (`kind` in the JSON) and
48
+ * a read whose filter differs from the recorded one refuses before it makes a
49
+ * request, rather than silently skipping. One cursor file per kind is the
50
+ * remedy, and the message names it.
51
+ */
52
+
53
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
54
+ import { dirname } from 'path';
55
+
56
+ export const INBOX_KINDS = ['mention', 'decision', 'handoff', 'approval'];
57
+ export const TOKEN_FILE_ENV = 'COMMONLY_TOKEN_FILE';
58
+ export const API_PAGE_LIMIT = 50;
59
+
60
+ const TOKEN_FIELDS = ['token', 'runtimeToken', 'accessToken', 'access_token'];
61
+ const DETAIL_CHARS = 160;
62
+
63
+ /** A refusal the operator can fix by re-reading the message. Never a server fault. */
64
+ export class InboxRefusal extends Error {}
65
+
66
+ /** A server-side failure. `status` is the HTTP status. */
67
+ export class InboxRequestError extends Error {
68
+ constructor(message, status) {
69
+ super(message);
70
+ this.name = 'InboxRequestError';
71
+ this.status = status;
72
+ }
73
+ }
74
+
75
+ export const resolveTokenFilePath = (opts = {}, env = process.env) => {
76
+ const flag = typeof opts.tokenFile === 'string' ? opts.tokenFile.trim() : '';
77
+ if (flag) return flag;
78
+ const fromEnv = typeof env?.[TOKEN_FILE_ENV] === 'string' ? env[TOKEN_FILE_ENV].trim() : '';
79
+ return fromEnv || null;
80
+ };
81
+
82
+ /**
83
+ * Pull a token out of a file body. A raw token is the documented shape (that is
84
+ * what `~/.commonly/bin/<account>-token` holds today); JSON is tolerated because
85
+ * the CLI's own token files are JSON, and a caller that reaches for one should
86
+ * get a named answer rather than a confusing 401.
87
+ */
88
+ export const extractToken = (raw) => {
89
+ const text = String(raw ?? '').trim();
90
+ if (!text) return null;
91
+ if (text.startsWith('{')) {
92
+ let parsed;
93
+ try {
94
+ parsed = JSON.parse(text);
95
+ } catch {
96
+ return null;
97
+ }
98
+ for (const field of TOKEN_FIELDS) {
99
+ const value = parsed?.[field];
100
+ if (typeof value === 'string' && value.trim()) return value.trim();
101
+ }
102
+ return null;
103
+ }
104
+ return text.replace(/^Bearer\s+/i, '').trim() || null;
105
+ };
106
+
107
+ /**
108
+ * Read the operator's token. Every failure here is a refusal with the PATH in
109
+ * it — never the token, and never a hint to run `commonly login`, which would
110
+ * point the account back at the saved login this command exists to avoid.
111
+ */
112
+ export const readInboxToken = (filePath) => {
113
+ let raw;
114
+ try {
115
+ raw = readFileSync(filePath, 'utf8');
116
+ } catch (err) {
117
+ throw new InboxRefusal(
118
+ `Cannot read the token file ${filePath} (${err?.code || err?.message}).`,
119
+ );
120
+ }
121
+ const token = extractToken(raw);
122
+ if (!token) {
123
+ throw new InboxRefusal(
124
+ `No token in ${filePath}. Expected a raw token, or JSON carrying one of: ${TOKEN_FIELDS.join(', ')}.`,
125
+ );
126
+ }
127
+ if (token.startsWith('cm_agent_')) {
128
+ throw new InboxRefusal(
129
+ `${filePath} holds an AGENT runtime token (cm_agent_…). This command reads a HUMAN account's own queue; `
130
+ + 'agent attention arrives as runtime events, not here. Pass that account\'s own token file.',
131
+ );
132
+ }
133
+ return token;
134
+ };
135
+
136
+ /** `--since` / a cursor file holds an ISO-8601 timestamp; null means "everything". */
137
+ export const parseCursor = (value) => {
138
+ const text = value === undefined || value === null ? '' : String(value).trim();
139
+ if (!text) return null;
140
+ // Two accepted shapes. The JSON object is what `list` writes (a timestamp plus
141
+ // the ids delivered at it); a bare ISO timestamp is what an operator copies off
142
+ // the `cursor:` line, and what older versions wrote — it parses with an empty id
143
+ // set, so the boundary items re-print instead of disappearing.
144
+ let at = text;
145
+ let ids = [];
146
+ let kind = null;
147
+ if (text.startsWith('{')) {
148
+ let parsed;
149
+ try {
150
+ parsed = JSON.parse(text);
151
+ } catch {
152
+ throw new InboxRefusal(`Not a cursor: ${text}`);
153
+ }
154
+ at = parsed?.at;
155
+ ids = Array.isArray(parsed?.ids) ? parsed.ids.filter((id) => typeof id === 'string' && id) : [];
156
+ // Which kind filter the read that wrote this cursor used, so the next read
157
+ // can tell whether it is asking the same question — see the rule at the top
158
+ // of this file. Absent on a bare timestamp and on a file written by an
159
+ // unfiltered read, which is the direction that cannot lose a row.
160
+ kind = typeof parsed?.kind === 'string' && parsed.kind ? parsed.kind : null;
161
+ }
162
+ const ms = Date.parse(String(at));
163
+ if (!Number.isFinite(ms)) {
164
+ throw new InboxRefusal(`Not an ISO-8601 timestamp: ${at}`);
165
+ }
166
+ return {
167
+ at: new Date(ms).toISOString(), ms, ids, kind,
168
+ };
169
+ };
170
+
171
+ /** A missing cursor file is "nothing seen yet", not an error. */
172
+ export const readCursorFile = (filePath) => {
173
+ if (!filePath) return null;
174
+ try {
175
+ return readFileSync(filePath, 'utf8').trim() || null;
176
+ } catch (err) {
177
+ if (err?.code === 'ENOENT') return null;
178
+ throw new InboxRefusal(`Cannot read the cursor file ${filePath} (${err?.code || err?.message}).`);
179
+ }
180
+ };
181
+
182
+ /** The cursor as it goes on disk: `{"at":"<iso>","ids":[...]}` on one line. */
183
+ export const formatCursor = (cursor) => {
184
+ if (!cursor) return null;
185
+ if (typeof cursor === 'string') return cursor;
186
+ const at = cursor.at || (Number.isFinite(cursor.ms) ? new Date(cursor.ms).toISOString() : null);
187
+ if (!at) return null;
188
+ const ids = Array.isArray(cursor.ids) ? cursor.ids : [];
189
+ // `kind` is written only when the read was scoped to one, so an unfiltered
190
+ // cursor keeps the exact shape it had before and neither gains nor loses
191
+ // meaning.
192
+ return JSON.stringify(cursor.kind ? { at, ids, kind: cursor.kind } : { at, ids });
193
+ };
194
+
195
+ /**
196
+ * Written temp-then-rename so a watcher reading this file mid-write sees either
197
+ * the previous cursor or the new one, never a half line. A failed write must not
198
+ * fail the run whose items were already printed, so the caller decides.
199
+ */
200
+ export const writeCursorFile = (filePath, cursor) => {
201
+ const text = formatCursor(cursor);
202
+ if (!filePath || !text) return;
203
+ mkdirSync(dirname(filePath), { recursive: true });
204
+ const tmp = `${filePath}.tmp`;
205
+ writeFileSync(tmp, `${text}\n`, 'utf8');
206
+ renameSync(tmp, filePath);
207
+ };
208
+
209
+ export const itemCreatedAtMs = (item) => {
210
+ const ms = Date.parse(String(item?.createdAt ?? ''));
211
+ return Number.isFinite(ms) ? ms : null;
212
+ };
213
+
214
+ /** The identity an item is remembered by between runs: the id `ack` consumes. */
215
+ export const cursorKeyOf = (item) => String(item?.attentionItemId || item?.id || '');
216
+
217
+ /**
218
+ * An item with an unparseable `createdAt` is KEPT under a cursor: dropping it
219
+ * would lose the row silently, and re-printing is the recoverable error. The same
220
+ * argument decides the millisecond boundary — see the module header.
221
+ */
222
+ export const filterItems = (items, { kind = null, sinceMs = null, seenIds = [] } = {}) => {
223
+ const delivered = seenIds instanceof Set ? seenIds : new Set(seenIds);
224
+ return (items || []).filter((item) => {
225
+ if (kind && item?.kind !== kind) return false;
226
+ if (sinceMs === null) return true;
227
+ const ms = itemCreatedAtMs(item);
228
+ if (ms === null) return true;
229
+ if (ms > sinceMs) return true;
230
+ return ms === sinceMs && !delivered.has(cursorKeyOf(item));
231
+ });
232
+ };
233
+
234
+ /**
235
+ * Newest timestamp delivered, so the next call resumes exactly where this one
236
+ * ended — plus the ids delivered AT that timestamp, which are what make the
237
+ * boundary safe to resume from. Those ids are carried forward only while the
238
+ * timestamp stays put; once it moves, they can never match again (the check needs
239
+ * `ms === sinceMs`) and carrying them would grow the file for nothing.
240
+ */
241
+ export const nextCursorFrom = (items, fallback = null, kind = null) => {
242
+ let max = null;
243
+ for (const item of items || []) {
244
+ const ms = itemCreatedAtMs(item);
245
+ if (ms === null) continue;
246
+ if (max === null || ms > max) max = ms;
247
+ }
248
+ if (max === null) return fallback;
249
+ const at = new Date(max).toISOString();
250
+ const boundary = (items || []).filter((item) => itemCreatedAtMs(item) === max)
251
+ .map(cursorKeyOf).filter(Boolean);
252
+ const carried = fallback && fallback.ms === max ? (fallback.ids || []) : [];
253
+ // The kind is the CURRENT read's, never inherited: the cursor describes the
254
+ // query that just ran, and a mismatch with the file's own kind is what the
255
+ // command refuses on before it makes a request.
256
+ return {
257
+ at, ms: max, ids: [...new Set([...carried, ...boundary])], kind: kind || null,
258
+ };
259
+ };
260
+
261
+ const oneLine = (value) => String(value ?? '').replace(/\s+/g, ' ').trim();
262
+
263
+ export const formatItemLine = (item) => {
264
+ const id = String(item?.attentionItemId || item?.id || '?');
265
+ const source = String(item?.id || '');
266
+ const sourceLabel = source && source !== id ? ` source=${source}` : '';
267
+ const detail = oneLine(item?.title || item?.detail || '').slice(0, DETAIL_CHARS);
268
+ const pod = oneLine(item?.podName || item?.podId || '');
269
+ const actor = item?.actorName ? ` — ${oneLine(item.actorName)}` : '';
270
+ return `${String(item?.kind || '?').padEnd(8)} item=${id}${sourceLabel} [${pod}] ${detail}${actor}`;
271
+ };
272
+
273
+ export const countsLine = (countsByKind = {}) => {
274
+ const parts = Object.entries(countsByKind)
275
+ .sort(([a], [b]) => a.localeCompare(b))
276
+ .map(([kind, count]) => `${kind} ${count}`);
277
+ return parts.join(', ');
278
+ };
279
+
280
+ /**
281
+ * Never echo the token back, whatever the server decides to say — but only when
282
+ * there is something distinctive to remove. A needle that short shreds the
283
+ * message it is meant to clean: with a one-character token, "Attention item not
284
+ * found" loses every `t` and reads as a different error. Real tokens are JWTs or
285
+ * `cm_*` strings, so the floor is far below anything real and still above any
286
+ * string that could appear inside an ordinary sentence.
287
+ */
288
+ export const MIN_SCRUB_CHARS = 16;
289
+
290
+ export const createScrubber = (token) => {
291
+ const needle = typeof token === 'string' && token.length >= MIN_SCRUB_CHARS ? token : '';
292
+ return (value) => {
293
+ const text = String(value ?? '');
294
+ return needle ? text.split(needle).join('[redacted]') : text;
295
+ };
296
+ };
297
+
298
+ /**
299
+ * The `/api/activity` limiter answers 429 with `{code:'rate_limited'}` and
300
+ * `Retry-After` (it sets `standardHeaders`). A read that pages a 550-item queue
301
+ * is 11 requests against a limit of **60 a minute keyed on the caller's IP**
302
+ * (`backend/routes/activity.ts:43-53`) — shared by every session on one host,
303
+ * including the operator's browser — so a 429 is an ordinary busy signal here,
304
+ * not a fault. Back off and continue; fail only when the budget is still gone
305
+ * after the retries.
306
+ */
307
+ export const MAX_RATE_LIMIT_RETRIES = 2;
308
+ export const DEFAULT_BACKOFF_MS = 5_000;
309
+ export const MAX_BACKOFF_MS = 60_000;
310
+
311
+ export const retryDelayMs = (response, fallback = DEFAULT_BACKOFF_MS) => {
312
+ const header = Number(response?.headers?.get?.('retry-after'));
313
+ if (Number.isFinite(header) && header > 0) return Math.min(header * 1_000, MAX_BACKOFF_MS);
314
+ return fallback;
315
+ };
316
+
317
+ /**
318
+ * Minimal request layer, deliberately NOT `lib/api.js`'s `createClient`.
319
+ * That client answers a 401 with "run `commonly login`", which is the one
320
+ * instruction this command must never give: the token here comes from a file
321
+ * that `login` does not write. `fetchImpl` is injectable so the identity rule
322
+ * is testable without a server.
323
+ */
324
+ export const createInboxRequest = ({
325
+ baseUrl, token, tokenFile, fetchImpl = fetch, sleep = (ms) => new Promise((resolve) => { setTimeout(resolve, ms); }),
326
+ }) => {
327
+ const scrub = createScrubber(token);
328
+ return async (path, { method = 'GET', params = {}, body } = {}) => {
329
+ const url = new URL(`${baseUrl}${path}`);
330
+ for (const [key, value] of Object.entries(params)) {
331
+ if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
332
+ }
333
+ for (let attempt = 0; ; attempt += 1) {
334
+ // eslint-disable-next-line no-await-in-loop
335
+ const res = await fetchImpl(url.toString(), {
336
+ method,
337
+ headers: {
338
+ Authorization: `Bearer ${token}`,
339
+ 'Content-Type': 'application/json',
340
+ 'User-Agent': 'commonly-cli/0.1',
341
+ },
342
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
343
+ });
344
+ // eslint-disable-next-line no-await-in-loop
345
+ const text = await res.text();
346
+ let parsed;
347
+ try {
348
+ parsed = JSON.parse(text);
349
+ } catch {
350
+ parsed = { message: text };
351
+ }
352
+ if (res.ok) return parsed;
353
+ if (res.status === 429 && attempt < MAX_RATE_LIMIT_RETRIES) {
354
+ // eslint-disable-next-line no-await-in-loop
355
+ await sleep(retryDelayMs(res));
356
+ continue;
357
+ }
358
+ const detail = parsed?.error || parsed?.message || parsed?.msg || parsed?.code || `HTTP ${res.status}`;
359
+ const hint = res.status === 401
360
+ ? ` — the token in ${tokenFile} was rejected. This command never uses the saved login.`
361
+ : res.status === 429
362
+ ? ' — 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.'
363
+ : '';
364
+ throw new InboxRequestError(scrub(`${detail}${hint}`), res.status);
365
+ }
366
+ };
367
+ };
368
+
369
+ /**
370
+ * Page the queue through `hasMore`. Two independent stops, because a server
371
+ * that reports `hasMore: true` beside an empty page would otherwise spin
372
+ * forever, and one that lies about `count` would page past the end.
373
+ */
374
+ export const fetchQueue = async (request, { podId } = {}) => {
375
+ const items = [];
376
+ let meta = {};
377
+ let offset = 0;
378
+ for (;;) {
379
+ // eslint-disable-next-line no-await-in-loop
380
+ const page = await request('/api/activity/decision-queue', {
381
+ params: { limit: API_PAGE_LIMIT, offset, ...(podId ? { podId } : {}) },
382
+ });
383
+ const batch = Array.isArray(page?.items) ? page.items : [];
384
+ items.push(...batch);
385
+ meta = page || {};
386
+ if (!meta.hasMore) break;
387
+ if (batch.length === 0) break;
388
+ if (Number.isInteger(meta.count) && items.length >= meta.count) break;
389
+ offset += batch.length;
390
+ }
391
+ return { items, meta };
392
+ };
393
+
394
+ export const resolveAccountLabel = (user) => String(
395
+ user?.username || user?.email || user?._id || 'unknown',
396
+ );
397
+
398
+ export const formatAccountLine = ({ account, baseUrl }) => `account: ${account} @ ${baseUrl}`;