@commonlyai/cli 0.1.78 → 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 +1 -1
- package/src/commands/inbox.js +275 -0
- package/src/index.js +6 -0
- package/src/lib/inbox.js +398 -0
- package/src/lib/mcp-home.js +99 -9
- package/src/lib/mcp-server-version.js +22 -0
- package/src/lib/mcp-warm-child.mjs +19 -11
package/package.json
CHANGED
|
@@ -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>
|
package/src/lib/inbox.js
ADDED
|
@@ -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}`;
|
package/src/lib/mcp-home.js
CHANGED
|
@@ -27,6 +27,15 @@
|
|
|
27
27
|
* operator saying which build to run, and the npx cache already gives a pinned
|
|
28
28
|
* spec its own dir.
|
|
29
29
|
*
|
|
30
|
+
* The cold path is pinned too (TASK-174's second half). A spawn with no usable
|
|
31
|
+
* home still runs the declaration, but not with the shared `@latest` spec: the
|
|
32
|
+
* spec this spawn executes is the version the registry already answered with,
|
|
33
|
+
* read from the warm's own cache, so `@latest`'s one shared npx dir is not on
|
|
34
|
+
* the spawn path even once. The fallback is the declaration as written, and it
|
|
35
|
+
* is used exactly when nothing has ever resolved a version on this host — a
|
|
36
|
+
* fresh machine's first spawn, where `@latest` is also the only spec that could
|
|
37
|
+
* work — or when the cache is unreadable. Nothing here throws.
|
|
38
|
+
*
|
|
30
39
|
* Every filesystem answer here is injected, because the two states that matter
|
|
31
40
|
* (a home that has never been warmed, and a home whose pointed-at version dir was
|
|
32
41
|
* deleted under it) cannot both be produced on one host.
|
|
@@ -39,7 +48,7 @@ import { homedir } from 'node:os';
|
|
|
39
48
|
import { dirname, join, sep } from 'node:path';
|
|
40
49
|
import { fileURLToPath } from 'node:url';
|
|
41
50
|
import { isShippedCommonlyMcpCommand } from './declared-mcp-guard.js';
|
|
42
|
-
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
51
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
43
52
|
|
|
44
53
|
/** The env override, so tests (and an operator) can point the home elsewhere. */
|
|
45
54
|
export const MCP_HOME_ENV = 'COMMONLY_MCP_HOME';
|
|
@@ -113,8 +122,34 @@ export const isUsableWarmHome = (home) => (
|
|
|
113
122
|
export const resolveHomeDir = (home) => (isUsableWarmHome(home) ? home : mcpHomeDir());
|
|
114
123
|
|
|
115
124
|
export const currentPointerPath = (home) => join(home, 'current');
|
|
116
|
-
|
|
117
|
-
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Where a version dir name that is not a triple lands: inside the home, and
|
|
128
|
+
* never a version (a version starts with a digit), so every read of it misses
|
|
129
|
+
* and the spawn falls back to its declaration. The alternative — building the
|
|
130
|
+
* path from whatever string arrived — is how `0.3.13/../../../../tmp/x` resolved
|
|
131
|
+
* outside the home, and #1970's `startsWith(pkgDir)` confinement is computed
|
|
132
|
+
* FROM that dir, so it would have confined to the wrong place (Vera, 74938).
|
|
133
|
+
*/
|
|
134
|
+
const UNUSABLE_VERSION_DIR = '.unusable-version';
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A version dir, built from the parsed triple — and only when the string given
|
|
138
|
+
* IS that triple.
|
|
139
|
+
*
|
|
140
|
+
* Refusing beats rebuilding here: this result is what the warm RENAMES into and
|
|
141
|
+
* what the pruner REMOVES, so silently rewriting `0.3.14-rc.1` to `0.3.14` would
|
|
142
|
+
* aim both at a different build. Normalising belongs at the producer of a
|
|
143
|
+
* version value (`resolveRegistryLatest`, `readCurrentVersion`,
|
|
144
|
+
* `readRegistryCache`, `pinShippedSpec`), where the choice is deliberate and
|
|
145
|
+
* visible; a producer that names something else gets a name no version can have.
|
|
146
|
+
*/
|
|
147
|
+
export const versionDirFor = (home, version) => {
|
|
148
|
+
const triple = exactVersion(version);
|
|
149
|
+
const name = triple && triple === String(version).trim() ? triple : UNUSABLE_VERSION_DIR;
|
|
150
|
+
return join(home, name);
|
|
151
|
+
};
|
|
152
|
+
export const packageDirFor = (home, version) => join(versionDirFor(home, version), 'node_modules', MCP_PACKAGE);
|
|
118
153
|
export const lockPathFor = (home) => join(home, WARM_LOCK_NAME);
|
|
119
154
|
|
|
120
155
|
/** `<home>/<version>/.inuse/` — one empty file per claiming pid. */
|
|
@@ -236,8 +271,8 @@ const defaultExists = (path) => {
|
|
|
236
271
|
export const readCurrentVersion = (home, { readFile = defaultRead } = {}) => {
|
|
237
272
|
const raw = readFile(currentPointerPath(home));
|
|
238
273
|
if (typeof raw !== 'string') return null;
|
|
239
|
-
const version = raw.trim();
|
|
240
|
-
return
|
|
274
|
+
const version = exactVersion(raw.trim());
|
|
275
|
+
return version || null;
|
|
241
276
|
};
|
|
242
277
|
|
|
243
278
|
/**
|
|
@@ -282,6 +317,9 @@ const readBinInPackageDir = (pkgDir, {
|
|
|
282
317
|
|
|
283
318
|
/** The bin of a version dir inside the seat's home. */
|
|
284
319
|
export const readBinPath = (home, version, options = {}) => {
|
|
320
|
+
// Truthiness of `parseVersion` is truthiness of `exactVersion`; the check that
|
|
321
|
+
// matters here is `versionDirFor` refusing a name that is not a triple, so a
|
|
322
|
+
// reader naming `0.3.14-rc.1` gets nothing rather than 0.3.14's build.
|
|
285
323
|
if (!parseVersion(version)) return null;
|
|
286
324
|
return readBinInPackageDir(packageDirFor(home, version), options);
|
|
287
325
|
};
|
|
@@ -317,7 +355,10 @@ export const newestInstalledVersion = (home, {
|
|
|
317
355
|
readBin = (h, v) => readBinPath(h, v),
|
|
318
356
|
} = {}) => {
|
|
319
357
|
const versions = readDir(home)
|
|
320
|
-
|
|
358
|
+
// An exact triple, not a prefix match: a dir literally named `0.3.13-rc.1`
|
|
359
|
+
// is not `0.3.13`, and normalising the name would point this read at a dir
|
|
360
|
+
// the caller never mentioned.
|
|
361
|
+
.filter((name) => exactVersion(name) === name)
|
|
321
362
|
.filter((name) => readBin(home, name))
|
|
322
363
|
.sort((a, b) => {
|
|
323
364
|
const [am, an, ap] = parseVersion(a);
|
|
@@ -332,6 +373,39 @@ const isUnpinnedShippedSpec = (spec) => (
|
|
|
332
373
|
spec === MCP_PACKAGE || spec === `${MCP_PACKAGE}@latest`
|
|
333
374
|
);
|
|
334
375
|
|
|
376
|
+
/**
|
|
377
|
+
* The shipped command with its spec pinned to `version`, or the command
|
|
378
|
+
* unchanged when there is no version to pin to.
|
|
379
|
+
*
|
|
380
|
+
* Only slot 2 is touched, and only after `isShippedCommonlyMcpCommand` has
|
|
381
|
+
* proven the slot is the package spec — so `npx`, its `-y` and every other
|
|
382
|
+
* entry survive byte-for-byte, and a command that is not the shipped 3-arg form
|
|
383
|
+
* cannot be rewritten by construction. npx keys its cache dir by the spec
|
|
384
|
+
* string, which is the whole point: `@latest` is one dir every seat shares and a
|
|
385
|
+
* publish rewrites in place, while `@0.3.13` is a dir only this version ever
|
|
386
|
+
* uses.
|
|
387
|
+
*/
|
|
388
|
+
export const pinShippedSpec = (command, version) => {
|
|
389
|
+
// The triple, not the string: this spec is an argv entry we execute, so a
|
|
390
|
+
// version carrying a suffix must not reach it.
|
|
391
|
+
const safe = exactVersion(version);
|
|
392
|
+
if (!Array.isArray(command) || !safe) return command;
|
|
393
|
+
let changed = false;
|
|
394
|
+
const pinned = command.map((part, index) => {
|
|
395
|
+
// Slot 2 only. `isShippedCommonlyMcpCommand` already proves the package sits
|
|
396
|
+
// in slot 2 of a 3-arg command, so this bound is not reachable today — it is
|
|
397
|
+
// here so the function is safe to read on its own, and it is armed in the
|
|
398
|
+
// tests rather than argued from the shape check (Vera, 74940).
|
|
399
|
+
if (index !== 2 || !isUnpinnedShippedSpec(String(part))) return part;
|
|
400
|
+
changed = true;
|
|
401
|
+
return `${MCP_PACKAGE}@${safe}`;
|
|
402
|
+
});
|
|
403
|
+
// The same reference when nothing changed, so a caller can use identity as the
|
|
404
|
+
// "did this spawn's spec move" check — the convention the rest of this module
|
|
405
|
+
// already keeps (`ensureCommonlyMcpServer`, `withholdGrantBroker`).
|
|
406
|
+
return changed ? pinned : command;
|
|
407
|
+
};
|
|
408
|
+
|
|
335
409
|
/**
|
|
336
410
|
* What a spawn should execute, and where that decision came from.
|
|
337
411
|
*
|
|
@@ -370,7 +444,23 @@ export const planMcpSpawn = (command, {
|
|
|
370
444
|
};
|
|
371
445
|
}
|
|
372
446
|
}
|
|
373
|
-
|
|
447
|
+
// No usable home, so the declaration is what runs — and the one thing worth
|
|
448
|
+
// changing about it is the spec string. The cached registry answer is used
|
|
449
|
+
// rather than a fresh lookup because this function is synchronous and on the
|
|
450
|
+
// spawn's critical path, and the warm refreshes that same cache on the same
|
|
451
|
+
// TTL (`REGISTRY_TTL_MS`). A stale-but-real version still gives this run its
|
|
452
|
+
// own npx dir, which `@latest` does not, and a stale cache means the registry
|
|
453
|
+
// has been answering nothing for a while — the case where `@latest` is least
|
|
454
|
+
// usable. `pinned` is reported so the caller can log which spec it ran.
|
|
455
|
+
const cached = readRegistryCache(target, { readFile });
|
|
456
|
+
const pinned = cached ? pinShippedSpec(command, cached.version) : command;
|
|
457
|
+
return {
|
|
458
|
+
command: pinned,
|
|
459
|
+
source: 'declared',
|
|
460
|
+
version: null,
|
|
461
|
+
reason: 'home-empty',
|
|
462
|
+
pinned: cached ? cached.version : null,
|
|
463
|
+
};
|
|
374
464
|
};
|
|
375
465
|
|
|
376
466
|
/** The warm's cached registry answer, with its age. */
|
|
@@ -379,9 +469,9 @@ export const readRegistryCache = (home, { readFile = defaultRead } = {}) => {
|
|
|
379
469
|
if (typeof raw !== 'string') return null;
|
|
380
470
|
try {
|
|
381
471
|
const parsed = JSON.parse(raw);
|
|
382
|
-
const version = parsed &&
|
|
472
|
+
const version = exactVersion(parsed && parsed.version);
|
|
383
473
|
const checkedAt = parsed && Number.isFinite(parsed.checkedAt) ? parsed.checkedAt : null;
|
|
384
|
-
if (!
|
|
474
|
+
if (!version || checkedAt === null) return null;
|
|
385
475
|
return { version, checkedAt };
|
|
386
476
|
} catch {
|
|
387
477
|
return null;
|
|
@@ -23,6 +23,28 @@ export const parseVersion = (spec) => {
|
|
|
23
23
|
return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The version as a bare triple — or null when there is none to build.
|
|
28
|
+
*
|
|
29
|
+
* `parseVersion` is a PREFIX test, which is right for ordering two versions and
|
|
30
|
+
* wrong for building anything out of one: it accepts `0.3.13/../../../../tmp/x`,
|
|
31
|
+
* so a version read out of a file (`~/.commonly/mcp/.registry.json`, the
|
|
32
|
+
* `current` pointer) or out of `npm view` could become a path or an npm SPEC
|
|
33
|
+
* ARGUMENT that leaves the seat's home. Everything that turns a version into a
|
|
34
|
+
* STRING used outside this module's own comparisons goes through here instead,
|
|
35
|
+
* and the string it builds is the parsed triple, so no suffix can travel.
|
|
36
|
+
*
|
|
37
|
+
* The cost is deliberate and one-directional: a prerelease (`0.3.13-rc.1`)
|
|
38
|
+
* becomes `0.3.13` rather than falling back to `@latest` — the stable release of
|
|
39
|
+
* the same triple, which is a real published version, and strictly better than
|
|
40
|
+
* the shared `@latest` dir this whole change exists to get off. Anchoring the
|
|
41
|
+
* regex at the end instead would reject the prerelease and leave it unpinned.
|
|
42
|
+
*/
|
|
43
|
+
export const exactVersion = (spec) => {
|
|
44
|
+
const triple = parseVersion(spec);
|
|
45
|
+
return triple ? triple.join('.') : null;
|
|
46
|
+
};
|
|
47
|
+
|
|
26
48
|
/**
|
|
27
49
|
* True when `version` is older than `target`; null when there is no version to
|
|
28
50
|
* judge (an unpinned `@latest` tracks the published release, so it is never
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
WARM_RESULTS, currentPointerPath, isUsableWarmHome, liveInUsePids, lockPathFor,
|
|
35
35
|
readBinInPrefix, versionDirFor,
|
|
36
36
|
} from './mcp-home.js';
|
|
37
|
-
import { MCP_PACKAGE, parseVersion } from './mcp-server-version.js';
|
|
37
|
+
import { MCP_PACKAGE, exactVersion, parseVersion } from './mcp-server-version.js';
|
|
38
38
|
|
|
39
39
|
export const PROBE_API_URL_PLACEHOLDER = 'http://127.0.0.1:1';
|
|
40
40
|
export const PROBE_TOKEN_PLACEHOLDER = 'cm_agent_warm_probe';
|
|
@@ -147,9 +147,9 @@ export const resolveRegistryLatest = async (home, {
|
|
|
147
147
|
if (cached) {
|
|
148
148
|
try {
|
|
149
149
|
const parsed = JSON.parse(cached);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
return { version:
|
|
150
|
+
const cachedVersion = parsed ? exactVersion(parsed.version) : null;
|
|
151
|
+
if (cachedVersion && Number.isFinite(parsed.checkedAt) && now - parsed.checkedAt < ttlMs) {
|
|
152
|
+
return { version: cachedVersion, source: 'cache' };
|
|
153
153
|
}
|
|
154
154
|
} catch { /* unreadable cache is just a miss */ }
|
|
155
155
|
}
|
|
@@ -164,18 +164,24 @@ export const resolveRegistryLatest = async (home, {
|
|
|
164
164
|
version = raw.replace(/^"|"$/g, '');
|
|
165
165
|
}
|
|
166
166
|
}
|
|
167
|
-
|
|
167
|
+
// This answer is both persisted and, downstream, an argv entry
|
|
168
|
+
// (`npm install <pkg>@<version>`) and a path (`<home>/<version>`, the temp
|
|
169
|
+
// prefix). Rebuild it as a triple here, at the one place it enters, so the
|
|
170
|
+
// rest of the warm can keep treating a version as a name.
|
|
171
|
+
const safe = exactVersion(version);
|
|
172
|
+
if (safe) {
|
|
168
173
|
try {
|
|
169
|
-
writeFile(cachePath, JSON.stringify({ version, checkedAt: now }));
|
|
174
|
+
writeFile(cachePath, JSON.stringify({ version: safe, checkedAt: now }));
|
|
170
175
|
} catch { /* a cache we cannot write is not a warm we should fail */ }
|
|
171
|
-
return { version, source: 'registry' };
|
|
176
|
+
return { version: safe, source: 'registry' };
|
|
172
177
|
}
|
|
173
178
|
// Registry down: the last known answer is better than none, and better than
|
|
174
179
|
// moving the pointer backwards.
|
|
175
180
|
if (cached) {
|
|
176
181
|
try {
|
|
177
182
|
const parsed = JSON.parse(cached);
|
|
178
|
-
|
|
183
|
+
const stale = parsed ? exactVersion(parsed.version) : null;
|
|
184
|
+
if (stale) return { version: stale, source: 'stale-cache' };
|
|
179
185
|
} catch { /* fall through */ }
|
|
180
186
|
}
|
|
181
187
|
return { version: null, source: 'unavailable' };
|
|
@@ -267,7 +273,10 @@ export const pruneVersionDirs = (home, current, {
|
|
|
267
273
|
},
|
|
268
274
|
livePids = (version) => liveInUsePids(home, version),
|
|
269
275
|
} = {}) => {
|
|
270
|
-
|
|
276
|
+
// Exact triples only, for the same reason `newestInstalledVersion` uses them:
|
|
277
|
+
// a dir named `0.3.13-rc.1` is not the dir `0.3.13`, and this loop REMOVES what
|
|
278
|
+
// it names.
|
|
279
|
+
const versions = readDir(home).filter((name) => exactVersion(name) === name);
|
|
271
280
|
versions.sort((a, b) => {
|
|
272
281
|
const [am, an, ap] = parseVersion(a);
|
|
273
282
|
const [bm, bn, bp] = parseVersion(b);
|
|
@@ -309,8 +318,7 @@ export const warmMcpHome = async (home, {
|
|
|
309
318
|
readCurrent = (h) => {
|
|
310
319
|
const raw = readIfAny(currentPointerPath(h));
|
|
311
320
|
if (typeof raw !== 'string') return null;
|
|
312
|
-
|
|
313
|
-
return parseVersion(trimmed) ? trimmed : null;
|
|
321
|
+
return exactVersion(raw.trim()) || null;
|
|
314
322
|
},
|
|
315
323
|
keep = KEEP_VERSION_DIRS,
|
|
316
324
|
} = {}) => {
|