@commonlyai/cli 0.1.56 → 0.1.58
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
package/src/commands/daemon.js
CHANGED
|
@@ -34,6 +34,24 @@ import {
|
|
|
34
34
|
import { daemonLogsDir, daemonSeatLogPath, readLogTail } from '../lib/daemon-logs.js';
|
|
35
35
|
import { loadDaemonState, saveDaemonState } from '../lib/daemon-state.js';
|
|
36
36
|
|
|
37
|
+
// One supervised seat, as `commonly daemon status --verbose` names it. Two of these
|
|
38
|
+
// fields are worth reading closely: the seat's `instanceId` (the identity the
|
|
39
|
+
// platform routes by, so a seat attached to the wrong instance is otherwise
|
|
40
|
+
// invisible) and the supervisor's `restarts` flap counter — which is written,
|
|
41
|
+
// persisted and served, and was shown by no surface at all, so a seat that had
|
|
42
|
+
// been crash-looping looked exactly like a healthy one (TASK-072, 2026-09-19).
|
|
43
|
+
//
|
|
44
|
+
// `restarts` distinguishes 0 from absent on purpose: a state file written by an
|
|
45
|
+
// older daemon has no such field, and printing 0 for it would claim a reading the
|
|
46
|
+
// file does not carry.
|
|
47
|
+
export const formatSeatLine = (seat = {}) => {
|
|
48
|
+
const model = seat.model || 'default model';
|
|
49
|
+
const effort = seat.effort ? `/${seat.effort}` : '';
|
|
50
|
+
const error = seat.lastError ? ` error=${seat.lastError}` : '';
|
|
51
|
+
const restarts = seat.restarts === undefined || seat.restarts === null ? '-' : seat.restarts;
|
|
52
|
+
return ` ${seat.agentName}: ${seat.state} instance=${seat.instanceId || '-'} adapter=${seat.adapter || 'unknown'} model=${model}${effort} pid=${seat.pid || '-'} restarts=${restarts} lastTurn=${seat.lastTurnAt || 'never'}${error}`;
|
|
53
|
+
};
|
|
54
|
+
|
|
37
55
|
const requireDaemonRecord = () => {
|
|
38
56
|
const record = loadDaemonRecord();
|
|
39
57
|
if (!record) {
|
|
@@ -397,12 +415,7 @@ Examples:
|
|
|
397
415
|
console.log('Local supervisor state: no supervised seats.');
|
|
398
416
|
} else {
|
|
399
417
|
console.log('Local supervised seats:');
|
|
400
|
-
for (const seat of local.seats)
|
|
401
|
-
const model = seat.model || 'default model';
|
|
402
|
-
const effort = seat.effort ? `/${seat.effort}` : '';
|
|
403
|
-
const error = seat.lastError ? ` error=${seat.lastError}` : '';
|
|
404
|
-
console.log(` ${seat.agentName}: ${seat.state} adapter=${seat.adapter || 'unknown'} model=${model}${effort} pid=${seat.pid || '-'} lastTurn=${seat.lastTurnAt || 'never'}${error}`);
|
|
405
|
-
}
|
|
418
|
+
for (const seat of local.seats) console.log(formatSeatLine(seat));
|
|
406
419
|
}
|
|
407
420
|
}
|
|
408
421
|
} catch (error) {
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* pi extension: Commonly's tools for a pi seat, over MCP.
|
|
3
3
|
*
|
|
4
|
-
* Loaded by adapters/pi.js with `-e`. Reads
|
|
5
|
-
*
|
|
4
|
+
* Loaded by adapters/pi.js with `-e`. Reads fd 3 — a pipe the adapter writes the
|
|
5
|
+
* JSON into and ends at spawn, never the environment (see takeServers) — a JSON
|
|
6
|
+
* list of `{ name, command: [...], env: {...} }` for stdio servers and
|
|
6
7
|
* `{ name, url, headers: {...} }` for Streamable HTTP ones — connects to each,
|
|
7
8
|
* asks it for its tools, and registers every one with pi under its own name, so
|
|
8
9
|
* a pi seat calls `commonly_post_message` exactly as a claude or codex seat
|
|
@@ -20,9 +21,16 @@ import { Type } from 'typebox';
|
|
|
20
21
|
import { connectMcp, takeServers, toPiResult } from './pi-mcp-client.mjs';
|
|
21
22
|
|
|
22
23
|
export default async function commonlyMcpBridge(pi) {
|
|
23
|
-
// Read once and
|
|
24
|
-
//
|
|
25
|
-
|
|
24
|
+
// Read once and consume: the read drains the pipe, so the list (which carries
|
|
25
|
+
// the seat token) does not survive anywhere in this process that a child could
|
|
26
|
+
// reach — not the environment, which is why it does not arrive that way.
|
|
27
|
+
const servers = takeServers();
|
|
28
|
+
// The adapter loads this extension only when it has a list to hand over, so an
|
|
29
|
+
// empty read means the channel itself failed. Say so: the symptom otherwise is
|
|
30
|
+
// a seat that silently has no commonly_* tools at all.
|
|
31
|
+
if (!servers.length) {
|
|
32
|
+
process.stderr.write('[commonly-pi-bridge] no server list on fd 3 — this seat has no commonly_* tools\n');
|
|
33
|
+
}
|
|
26
34
|
const clients = [];
|
|
27
35
|
for (const server of servers) {
|
|
28
36
|
const client = connectMcp(server);
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { spawn } from 'node:child_process';
|
|
21
|
+
import { closeSync, readFileSync } from 'node:fs';
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The grant broker's path. wren's ruling for the daemon-side half of TASK-063:
|
|
@@ -166,14 +167,10 @@ const parseMessages = (text, contentType) => {
|
|
|
166
167
|
*
|
|
167
168
|
* `headers` is where a declared `Authorization` arrives, already substituted
|
|
168
169
|
* with the seat's runtime token by the adapter. The token therefore rides in an
|
|
169
|
-
* HTTP header built from the JSON list the bridge
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
173
|
-
* read the parent's environment (`ps eww $PPID`, `/proc/$PPID/environ`) and find
|
|
174
|
-
* both the token and the server list. Treat this as "not in argv" and not as a
|
|
175
|
-
* secrecy boundary; the fix is to hand the list over a 0600 file the bridge
|
|
176
|
-
* unlinks on load (row filed against the bridge's env channel, Vera, Connectors).
|
|
170
|
+
* HTTP header built from the JSON list the bridge reads off its own fd 3 (see
|
|
171
|
+
* takeServers) — never on argv, and never in this process's environment, which a
|
|
172
|
+
* same-user child of this process can read back whole (`ps eww $PPID`,
|
|
173
|
+
* `/proc/$PPID/environ`) no matter what this process deletes from its own copy.
|
|
177
174
|
*/
|
|
178
175
|
export const connectHttpMcp = ({ name, url, headers }, { fetchImpl = globalThis.fetch, timeoutMs = 60_000 } = {}) => {
|
|
179
176
|
if (typeof fetchImpl !== 'function') throw new Error(`${name}: no fetch implementation for the HTTP MCP transport`);
|
|
@@ -278,23 +275,58 @@ export const toPiResult = (result) => {
|
|
|
278
275
|
};
|
|
279
276
|
|
|
280
277
|
/**
|
|
281
|
-
* Read the server list
|
|
282
|
-
* server's substituted secrets — a stdio server's env and an HTTP server's
|
|
283
|
-
* `Authorization` header, the seat's bearer token among them — and pi's `bash`
|
|
284
|
-
* tool spawns with `{ ...process.env }` (pi's getShellEnv), so leaving it in place
|
|
285
|
-
* lets one `env` from the model print the token. The clients already hold what
|
|
286
|
-
* they need from spawn time; nothing else reads this variable.
|
|
278
|
+
* Read the server list off the inherited pipe on `fd` and CONSUME it.
|
|
287
279
|
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
291
|
-
*
|
|
292
|
-
*
|
|
280
|
+
* The list carries every server's substituted secrets — a stdio server's env and
|
|
281
|
+
* an HTTP server's `Authorization` header, the seat's bearer token among them —
|
|
282
|
+
* and pi's `bash` tool spawns with `{ ...process.env }` (pi's getShellEnv), so it
|
|
283
|
+
* has to arrive by a channel pi's children do not inherit and must not outlive
|
|
284
|
+
* the read. It arrives here as fd 3: a pipe the adapter writes the JSON into and
|
|
285
|
+
* ends at spawn (pi.js runPi). This reads it to EOF and closes the descriptor.
|
|
286
|
+
*
|
|
287
|
+
* WHY NOT THE ENVIRONMENT, which is what this replaced: deleting the variable
|
|
288
|
+
* scrubbed Node's copy only, while the kernel keeps the environment this process
|
|
289
|
+
* was STARTED with, so a same-user child still read the token back with
|
|
290
|
+
* `ps eww $PPID` on macOS and `/proc/$PPID/environ` on Linux. Unsetting it at
|
|
291
|
+
* spawn cannot help either, because this runs inside the pi process that holds
|
|
292
|
+
* it. WHY NOT a 0600 file the bridge unlinks on load: the mode protects nothing
|
|
293
|
+
* against a same-user reader, and that shape needs both the unlink and a close
|
|
294
|
+
* to leave no window, since `/proc/<pid>/fd` on Linux still reaches an unlinked
|
|
295
|
+
* inode. A pipe has neither a path nor a stored copy.
|
|
296
|
+
*
|
|
297
|
+
* THE READ IS WHAT REMOVES THE SECRET, not the close: a pipe is consumed, so a
|
|
298
|
+
* second reader gets nothing. Measured against pi 0.84.1 — after a read to EOF
|
|
299
|
+
* a second read of the same descriptor returned zero bytes, while a 5-byte
|
|
300
|
+
* partial read left the remainder readable. Closing afterwards is hygiene.
|
|
301
|
+
*
|
|
302
|
+
* Two further measurements this rests on, both against pi 0.84.1: pi does not
|
|
303
|
+
* close inherited descriptors before loading extensions, so fd 3 is still open
|
|
304
|
+
* here (this runs at extension load, before the first model turn — and before
|
|
305
|
+
* any bash tool can run); and pi's own spawns (`dist/core/tools/bash.js`,
|
|
306
|
+
* `dist/core/exec.js`) pass a THREE-element stdio list, so a shell tool child
|
|
307
|
+
* does not inherit fd 3 at all.
|
|
308
|
+
*
|
|
309
|
+
* An unreadable descriptor yields no servers rather than throwing: a seat whose
|
|
310
|
+
* bridge cannot read its list should run without Commonly tools, not fail to
|
|
311
|
+
* start. The bridge logs that empty result (pi-commonly-mcp.mjs).
|
|
312
|
+
*
|
|
313
|
+
* The descriptor is closed only after a successful read, and that is not
|
|
314
|
+
* tidiness: on macOS, `closeSync` on the descriptor Node opens for an `'ignore'`
|
|
315
|
+
* stdio entry aborts the process — measured 2026-09-19, Node 20 — with
|
|
316
|
+
* `Assertion failed: (errno == EINTR), function uv__io_poll, file kqueue.c`,
|
|
317
|
+
* where the read itself had already failed harmlessly with `ENXIO`. A close in
|
|
318
|
+
* `finally` therefore turns "no channel" into a SIGABRT in the seat.
|
|
293
319
|
*/
|
|
294
|
-
export const takeServers = (
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
320
|
+
export const takeServers = (fd = 3) => {
|
|
321
|
+
let raw = null;
|
|
322
|
+
try {
|
|
323
|
+
raw = readFileSync(fd, 'utf8');
|
|
324
|
+
} catch {
|
|
325
|
+
// Nothing to close: the read failed, so this was not a descriptor we consumed.
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
329
|
+
return readServers(raw);
|
|
298
330
|
};
|
|
299
331
|
|
|
300
332
|
/**
|
package/src/lib/adapters/pi.js
CHANGED
|
@@ -110,15 +110,13 @@ const originOf = (value) => {
|
|
|
110
110
|
* is a Streamable HTTP server (`agentBinding.ts` grantBrokerServer), so before
|
|
111
111
|
* this pi dropped the one entry that carries a grant to a seat, silently.
|
|
112
112
|
* Filling the headers here reuses the same substitution as the stdio env, and
|
|
113
|
-
* the result
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* load, which is a row against this env channel, not against this PR (Vera,
|
|
121
|
-
* Connectors).
|
|
113
|
+
* the result is written into pi's fd 3 at spawn — a pipe the bridge reads to EOF
|
|
114
|
+
* and closes (see takeServers), never the child's environment and never argv. The
|
|
115
|
+
* environment is not usable for this: the kernel keeps the copy the process was
|
|
116
|
+
* started with, so a same-user child could read the token back with
|
|
117
|
+
* `ps eww $PPID` / `/proc/$PPID/environ` even after the bridge deleted its own
|
|
118
|
+
* copy — which is what the previous channel did, and the row this closes
|
|
119
|
+
* (Vera, Connectors).
|
|
122
120
|
*
|
|
123
121
|
* WHICH shape an entry becomes is decided by `transport` — the same field, read
|
|
124
122
|
* with the same default and the same exact comparison the daemon's
|
|
@@ -314,11 +312,24 @@ export const extractReply = (stdout) => {
|
|
|
314
312
|
return { text, sawAssistant, errors };
|
|
315
313
|
};
|
|
316
314
|
|
|
317
|
-
const runPi = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
315
|
+
const runPi = ({ args, cwd, env, payload, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
318
316
|
let stdout = '';
|
|
319
317
|
let stderr = '';
|
|
320
318
|
let timedOut = false;
|
|
321
|
-
|
|
319
|
+
// fd 3 carries the MCP server list, so the 4th pipe exists exactly when there
|
|
320
|
+
// is a list to hand over. The bridge reads it at extension load (takeServers).
|
|
321
|
+
const withList = typeof payload === 'string';
|
|
322
|
+
const proc = spawnImpl('pi', args, { cwd, env, stdio: withList ? ['ignore', 'pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'] });
|
|
323
|
+
if (withList) {
|
|
324
|
+
const channel = proc.stdio && proc.stdio[3];
|
|
325
|
+
// A missing pipe is an invariant break, not a degraded mode: the bridge would
|
|
326
|
+
// read EBADF and the seat would silently have no commonly_* tools.
|
|
327
|
+
if (!channel) { reject(new Error('pi adapter: no fd 3 pipe to carry the MCP server list')); return; }
|
|
328
|
+
// The child may exit before draining; that surfaces on 'close', so a write
|
|
329
|
+
// error here must not become an unhandled error event.
|
|
330
|
+
channel.on('error', () => {});
|
|
331
|
+
channel.end(payload);
|
|
332
|
+
}
|
|
322
333
|
const timer = setTimeout(() => { timedOut = true; proc.kill('SIGTERM'); }, timeoutMs);
|
|
323
334
|
proc.stdout.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
324
335
|
proc.stderr.on('data', (chunk) => { stderr += chunk.toString(); });
|
|
@@ -378,7 +389,6 @@ export default {
|
|
|
378
389
|
...baseEnv,
|
|
379
390
|
PI_CODING_AGENT_DIR: agentDir,
|
|
380
391
|
PI_SKIP_VERSION_CHECK: '1',
|
|
381
|
-
...(servers.length ? { COMMONLY_PI_MCP: JSON.stringify(servers) } : {}),
|
|
382
392
|
};
|
|
383
393
|
const args = buildArgs({
|
|
384
394
|
prompt: fullPrompt, provider: provider.name, model, thinking, sessionId, isResume, sessionDir,
|
|
@@ -389,6 +399,7 @@ export default {
|
|
|
389
399
|
args,
|
|
390
400
|
cwd: ctx.cwd,
|
|
391
401
|
env: childEnv,
|
|
402
|
+
payload: servers.length ? JSON.stringify(servers) : undefined,
|
|
392
403
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
393
404
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
394
405
|
});
|