@cello-protocol/cli 0.0.234 → 0.0.235

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/arg-parse.d.ts +19 -0
  2. package/dist/arg-parse.d.ts.map +1 -0
  3. package/dist/arg-parse.js +29 -0
  4. package/dist/arg-parse.js.map +1 -0
  5. package/dist/bin/cello.d.ts +15 -0
  6. package/dist/bin/cello.d.ts.map +1 -0
  7. package/dist/bin/cello.js.map +1 -0
  8. package/dist/cli-args.d.ts +62 -0
  9. package/dist/cli-args.d.ts.map +1 -0
  10. package/dist/cli-args.js +119 -0
  11. package/dist/cli-args.js.map +1 -0
  12. package/dist/commands.d.ts +139 -0
  13. package/dist/commands.d.ts.map +1 -0
  14. package/dist/commands.js +1005 -0
  15. package/dist/commands.js.map +1 -0
  16. package/dist/hermes/assets.d.ts +35 -0
  17. package/dist/hermes/assets.d.ts.map +1 -0
  18. package/dist/hermes/assets.js +1396 -0
  19. package/dist/hermes/assets.js.map +1 -0
  20. package/dist/hermes/install-hermes.d.ts +44 -0
  21. package/dist/hermes/install-hermes.d.ts.map +1 -0
  22. package/dist/hermes/install-hermes.js +172 -0
  23. package/dist/hermes/install-hermes.js.map +1 -0
  24. package/dist/json-out.d.ts +41 -0
  25. package/dist/json-out.d.ts.map +1 -0
  26. package/dist/json-out.js +59 -0
  27. package/dist/json-out.js.map +1 -0
  28. package/dist/parity-commands.d.ts +358 -0
  29. package/dist/parity-commands.d.ts.map +1 -0
  30. package/dist/parity-commands.js +720 -0
  31. package/dist/parity-commands.js.map +1 -0
  32. package/dist/registry.d.ts +111 -0
  33. package/dist/registry.d.ts.map +1 -0
  34. package/dist/registry.js +1552 -0
  35. package/dist/registry.js.map +1 -0
  36. package/dist/screener-commands.d.ts +57 -0
  37. package/dist/screener-commands.d.ts.map +1 -0
  38. package/dist/screener-commands.js +229 -0
  39. package/dist/screener-commands.js.map +1 -0
  40. package/package.json +4 -4
@@ -0,0 +1,720 @@
1
+ /**
2
+ * DOD-CLI-PARITY-1 Phases 1-2 — every daemon capability that was MCP-only, reachable from `cello`.
3
+ *
4
+ * Bash is the universal agent adapter: with these commands any bash-capable agent (not just Claude
5
+ * Code or Hermes) operates a CELLO node — connect, send, receive, seal — with no MCP dependency.
6
+ *
7
+ * Each command is a THIN PASS-THROUGH: parse args → call the SAME daemon IPC handler the
8
+ * corresponding cello_* MCP tool calls → emit the response under the §3 contract (json-out.ts).
9
+ * No daemon changes, no second IPC client, no logic that the daemon owns (validation, tier bounds,
10
+ * agent_id resolution) is duplicated here — the CLI surfaces the daemon's verdict, verbatim.
11
+ *
12
+ * ── The per-invocation current-agent problem (why `use-agent` persists) ──────────────────────
13
+ * The daemon's "current agent" is PER-CONNECTION state. The MCP shim holds one long-lived socket,
14
+ * so `cello_use_agent` sticks for the whole session. The CLI is the opposite: a fresh process and a
15
+ * fresh connection per invocation, torn down microseconds later. A naive `cello use-agent alice`
16
+ * pass-through would therefore set state on a dying socket and report ok:true while changing NOTHING
17
+ * for the next command — a fabricated success of exactly the kind §3 forbids.
18
+ *
19
+ * So the selection is DURABLE: `use-agent` calls the real handler (which validates the agent and
20
+ * auto-starts it — AUTOSTART-1) and, only if the daemon says ok, persists the name. Every
21
+ * agent-scoped command then REPLAYS `cello_use_agent` on its new connection before dispatching.
22
+ * That is not a parallel path — it is the same replay the MCP proxy performs after a reconnect
23
+ * (ipc-proxy.ts invariant 1), reusing the existing handler.
24
+ *
25
+ * Agent resolution, in order: explicit `--agent` > the persisted selection > (omitted, so the
26
+ * daemon applies its own sole-online-agent fallback, and stays ambiguous → no_current_agent when
27
+ * two or more are online). If a replay FAILS, the command STOPS and surfaces that error — it never
28
+ * shrugs and lets the daemon's fallback quietly run the work as a different agent.
29
+ */
30
+ import { join } from "node:path";
31
+ import { readFile, writeFile, unlink } from "node:fs/promises";
32
+ import { connectToDaemon, readLock, isAgentRunning } from "@cello-protocol/daemon";
33
+ import { emitIpcResult, emitTransportError } from "./json-out.js";
34
+ /** Where `cello use-agent` records the selection: a plain text file the operator can read/delete. */
35
+ function currentAgentPath(celloDir) {
36
+ return join(celloDir, "current-agent");
37
+ }
38
+ /**
39
+ * The persisted agent selection, or undefined if none was ever made.
40
+ *
41
+ * ENOENT — never selected — is the ONLY swallowed error. A blanket catch here would let
42
+ * an unreadable file (EACCES, EISDIR, a corrupt mount) read as "no selection", after which the
43
+ * daemon's sole-online fallback would quietly run the command as whatever agent happens to be up:
44
+ * the operator's selection silently replaced by a different identity, exit 0. Anything that is not
45
+ * "the file isn't there" is a real failure and is thrown.
46
+ */
47
+ export async function readCurrentAgent(celloDir) {
48
+ try {
49
+ const raw = (await readFile(currentAgentPath(celloDir), "utf8")).trim();
50
+ return raw.length > 0 ? raw : undefined;
51
+ }
52
+ catch (err) {
53
+ if (err?.code === "ENOENT")
54
+ return undefined; // never selected
55
+ throw err;
56
+ }
57
+ }
58
+ /**
59
+ * Forget the persisted selection. Called by `set-agent-offline`: the daemon clears an agent from every
60
+ * connection's current-agent state when it is stopped, so the CLI's durable mirror of that state
61
+ * must be cleared too.
62
+ */
63
+ async function clearCurrentAgent(celloDir) {
64
+ try {
65
+ await unlink(currentAgentPath(celloDir));
66
+ }
67
+ catch (err) {
68
+ if (err?.code !== "ENOENT")
69
+ throw err;
70
+ }
71
+ }
72
+ async function writeCurrentAgent(celloDir, name) {
73
+ await writeFile(currentAgentPath(celloDir), name + "\n", "utf8");
74
+ }
75
+ /**
76
+ * Open a daemon connection, establish the current agent (if one is selected), run `fn`, and always
77
+ * close. Transport failures come back as structured JSON on stderr — so a bash agent branches on
78
+ * the same shape whether the daemon rejected the call or never received it.
79
+ */
80
+ async function withDaemon(celloDir, opts, agentScoped, fn) {
81
+ const lock = await readLock(join(celloDir, "daemon.lock"));
82
+ if (!lock) {
83
+ return emitTransportError("daemon_not_running", "No daemon is running. Start it with 'cello login', then retry.", opts);
84
+ }
85
+ let client;
86
+ try {
87
+ client = await connectToDaemon(lock.socketPath);
88
+ }
89
+ catch (err) {
90
+ return emitTransportError("daemon_unreachable", `Could not connect to the daemon at ${lock.socketPath}: ${err instanceof Error ? err.message : String(err)}. It may be mid-shutdown — check 'cello status'.`, opts);
91
+ }
92
+ try {
93
+ await client.send("ipc.connect", { clientType: "cli" });
94
+ if (agentScoped) {
95
+ // An EMPTY --agent must never be treated as "no --agent". `--agent "$VAR"` with
96
+ // VAR unset yields "", which is not nullish: it would suppress the persisted selection, fail
97
+ // the truthiness check below, run NO replay, and let the daemon's sole-online fallback execute
98
+ // the command as whatever agent happened to be up — exit 0, wrong identity, silently. That is
99
+ // the exact misroute this module exists to prevent, and the bash idiom makes it likely.
100
+ if (opts.agent !== undefined && opts.agent.trim() === "") {
101
+ return emitTransportError("missing_agent_value", "--agent was given an empty value (an unset shell variable?). Name an agent explicitly, or omit --agent to use the selection from 'cello use-agent'.", opts);
102
+ }
103
+ const selected = opts.agent ?? (await readCurrentAgent(celloDir));
104
+ if (selected) {
105
+ // The replay must not RESURRECT a stopped agent. cello_use_agent auto-starts an offline
106
+ // agent (AUTOSTART-1), so replaying it blindly would let `cello set-agent-offline alice` be
107
+ // silently undone by the very next read-only command (`cello inbox`), bringing alice back
108
+ // online and reachable with no signal. Stopping an agent is kill-switch-adjacent; a command
109
+ // that reads must never re-arm it. The MCP surface never does this — the daemon clears the
110
+ // agent from every connection on stop, and later calls get no_current_agent.
111
+ //
112
+ // So: only replay an agent that is ALREADY ONLINE. An offline selection fails loud, naming
113
+ // the remedy, rather than quietly starting it or quietly running as someone else.
114
+ if (!(await isAgentOnline(client, selected))) {
115
+ return emitTransportError("selected_agent_offline", `Agent '${selected}' is not online, so this command was not run as it. Bring it online with 'cello start-agent ${selected}' (or select another with 'cello use-agent <name>'). It is NOT auto-started here: that would silently undo a deliberate 'cello set-agent-offline'.`, opts);
116
+ }
117
+ // The agent is online — claim it for this connection. A refusal (retired/unknown) stops the
118
+ // command: continuing would let the sole-online fallback run it as a DIFFERENT agent.
119
+ const used = (await client.send("cello_use_agent", { name: selected }));
120
+ if (used.ok !== true) {
121
+ // Defensive: cello_use_agent is ok-bearing on every path today, but if it ever returned an
122
+ // ok-less body, emitIpcResult would print IT as this command's successful result. Fail loud
123
+ // on any shape that is not an explicit ok:false, rather than pass off the wrong body.
124
+ if (used.ok === false)
125
+ return emitIpcResult(used, opts);
126
+ return emitTransportError("unexpected_replay_response", `Selecting agent '${selected}' returned an unrecognized response, so the command was not run. This is a daemon/CLI version mismatch — check 'cello status'.`, opts);
127
+ }
128
+ }
129
+ else {
130
+ // NO selection. The daemon's fallback is "the sole ONLINE agent", and it refuses only when
131
+ // two or more are online — so with several agents known and exactly one up, it runs the
132
+ // command as that one. `cello set-agent-offline <selected>` clears the selection, which walks
133
+ // straight into it: the next command silently re-targets whoever else happens to be online.
134
+ //
135
+ // With ONE known agent the fallback is unambiguous and useful (a fresh operator who never ran
136
+ // `use-agent` still works). With more than one it is a guess about intent, and a guess must
137
+ // not be made silently on the operator's behalf.
138
+ const known = await listKnownAgents(client);
139
+ if (known === null) {
140
+ return emitTransportError("agent_list_unavailable", "The daemon's agent list could not be read, so this command was not run — without it there is no way to tell whether an unselected command would target the agent you meant. Check 'cello status'.", opts);
141
+ }
142
+ if (known.length > 1) {
143
+ return emitTransportError("no_agent_selected", `No agent is selected and the daemon knows ${known.length} (${known.join(", ")}), so this command was not run — it would otherwise have silently targeted whichever agent happened to be online. Choose one with 'cello use-agent <name>', or pass --agent <name>.`, opts);
144
+ }
145
+ }
146
+ }
147
+ const result = await fn(client);
148
+ return emitIpcResult(result, opts);
149
+ }
150
+ catch (err) {
151
+ return emitTransportError("ipc_error", `The daemon call failed: ${err instanceof Error ? err.message : String(err)}`, opts);
152
+ }
153
+ finally {
154
+ client.close();
155
+ }
156
+ }
157
+ /**
158
+ * Is this agent currently ONLINE? Asked via cello_list_agents (a real handler, no new IPC path) so
159
+ * the replay can refuse to auto-start a stopped agent. Fails CLOSED: any unexpected shape reads as
160
+ * "not online", which fails the command loud rather than resurrecting an agent the operator
161
+ * deliberately stopped.
162
+ */
163
+ async function isAgentOnline(client, name) {
164
+ const res = (await client.send("cello_list_agents"));
165
+ // RUNNING, not literally "online". `online` now additionally requires an attendee, so an agent that
166
+ // is running and reachable but has nobody at the desk reads `unattended` — and testing for the
167
+ // string here made every parity command refuse a perfectly healthy agent with
168
+ // `selected_agent_offline`. The string stayed valid, so the compiler caught none of it; this is
169
+ // why the predicate exists rather than a second literal comparison.
170
+ return (res.agents ?? []).some((a) => a.name === name && isAgentRunning(a.state));
171
+ }
172
+ /**
173
+ * Every agent the daemon KNOWS — loaded, whether online or not. Used to decide whether "no
174
+ * selection" is unambiguous (one agent) or a guess about the operator's intent (several).
175
+ *
176
+ * Counts KNOWN agents, not online ones. Counting only the online ones reopens the hole it exists to
177
+ * close: stopping the selected agent drops the count to one, so the guess looks safe again at
178
+ * exactly the moment the operator said they do not want that agent.
179
+ *
180
+ * FAILS CLOSED, like its sibling isAgentOnline. Returns null — never an empty list — when the daemon
181
+ * answers with a shape it does not recognize. An empty list would sail through a `length > 1` guard
182
+ * and hand the decision straight back to the daemon's sole-online fallback, which is the very thing
183
+ * the guard is there to prevent. A counter that cannot count must not answer "one".
184
+ */
185
+ async function listKnownAgents(client) {
186
+ const res = (await client.send("cello_list_agents"));
187
+ if (!Array.isArray(res.agents))
188
+ return null;
189
+ return res.agents
190
+ .map((a) => a.name)
191
+ .filter((n) => typeof n === "string");
192
+ }
193
+ /** The common case: one IPC call, agent-scoped unless stated otherwise. */
194
+ function ipcCommand(celloDir, method, params, opts, agentScoped = true) {
195
+ return withDaemon(celloDir, opts, agentScoped, async (client) => {
196
+ return (await client.send(method, params));
197
+ });
198
+ }
199
+ /** Drop undefined values so an omitted optional param is ABSENT, not an explicit `undefined`. */
200
+ function defined(params) {
201
+ return Object.fromEntries(Object.entries(params).filter(([, v]) => v !== undefined));
202
+ }
203
+ /**
204
+ * CLI command name → the daemon IPC method it calls. The ONE place a CLI command's daemon IPC
205
+ * method is named.
206
+ *
207
+ * The registry's `ipcMethod` field is set FROM this map, and every function below dispatches FROM
208
+ * this map — so the field that DoD §9's parity test audits is, by construction, the literal that is
209
+ * actually sent. Two independent strings would let the registry say `cello_contact_set_moniker`
210
+ * while the code calls `cello_contact_set_away`, and the audit — which only reads the metadata —
211
+ * would happily pass. A comment-in-a-field is not a guarantee; this is.
212
+ *
213
+ * The KEYS are the CLI/MCP capability names (DOD-ONBOARD-HELP-1 §2b: one vocabulary). The VALUES
214
+ * are the daemon's IPC WIRE names, which deliberately do NOT move — the shim maps tool
215
+ * `cello_agents` onto the existing `cello_list_agents` method. Renaming the wire would break a new
216
+ * daemon talking to an OLD connect shim; connect has no daemon dependency, so nothing pins the two
217
+ * together. That asymmetry is the whole reason this table exists rather than a string concat.
218
+ */
219
+ export const IPC_METHODS = {
220
+ agents: "cello_list_agents",
221
+ "start-agent": "cello_start_agent",
222
+ "set-agent-offline": "cello_set_agent_offline",
223
+ "use-agent": "cello_use_agent",
224
+ "stop-using-agent": "cello_stop_using_agent",
225
+ inbox: "cello_check_notifications",
226
+ sessions: "cello_list_sessions",
227
+ transcript: "cello_get_transcript",
228
+ quarantined: "cello_get_quarantined",
229
+ "sealed-receipt": "cello_get_sealed_receipt",
230
+ "initiate-session": "cello_initiate_session",
231
+ send: "cello_send",
232
+ receive: "cello_receive",
233
+ "close-session": "cello_close_session",
234
+ "await-session": "cello_await_session",
235
+ "name-session": "cello_name_session",
236
+ dismiss: "cello_dismiss",
237
+ contacts: "cello_contact_list",
238
+ "contact-add": "cello_contact_add",
239
+ "contact-remove": "cello_contact_remove",
240
+ "contact-set-tier": "cello_contact_set_tier",
241
+ "contact-set-away": "cello_contact_set_away",
242
+ "contact-set-moniker": "cello_contact_set_moniker",
243
+ "contact-set-signal": "cello_contact_set_signal",
244
+ "settings-get": "cello_settings_get",
245
+ "settings-set": "cello_settings_set",
246
+ "moniker-set": "cello_set_moniker",
247
+ "doc-propose": "cello_doc_propose",
248
+ "doc-invite": "cello_doc_invite",
249
+ "doc-remove": "cello_doc_remove",
250
+ "doc-inbox": "cello_doc_inbox",
251
+ "doc-accept": "cello_doc_accept",
252
+ "doc-refuse": "cello_doc_refuse",
253
+ "doc-list": "cello_doc_list",
254
+ "doc-read": "cello_doc_read",
255
+ "doc-diff": "cello_doc_diff",
256
+ "doc-watch": "cello_doc_watch",
257
+ "doc-write": "cello_doc_write",
258
+ "doc-publish": "cello_doc_publish",
259
+ "doc-close": "cello_doc_close",
260
+ "doc-kill": "cello_doc_kill",
261
+ "attestation-consent-list": "cello_attestation_consent_list",
262
+ "attestation-consent-accept": "cello_attestation_consent_accept",
263
+ "attestation-consent-refuse": "cello_attestation_consent_refuse",
264
+ };
265
+ // ─── Group A: agent lifecycle ──────────────────────────────────────────────────────────────────
266
+ /** `cello agents` → cello_list_agents. Daemon-wide, not agent-scoped. */
267
+ export function listAgents(celloDir, opts) {
268
+ return ipcCommand(celloDir, IPC_METHODS.agents, {}, opts, false);
269
+ }
270
+ /** `cello start-agent <name>` → cello_start_agent. Brings an agent online WITHOUT claiming current. */
271
+ export function startAgent(celloDir, name, opts) {
272
+ return ipcCommand(celloDir, IPC_METHODS["start-agent"], { name }, opts, false);
273
+ }
274
+ /**
275
+ * `cello set-agent-offline <name>` → cello_set_agent_offline. (Was `set-agent-offline`, renamed because
276
+ * "stop" read as the opposite of `use-agent` when it is the opposite of `start-agent` — see the
277
+ * handler comment in agent-handlers.ts.)
278
+ *
279
+ * Also CLEARS the persisted selection when it names this agent (review F1). The daemon clears an
280
+ * offline agent from every connection's current-agent state; the CLI's durable mirror of that state
281
+ * must follow, or the next command would try to act as an agent the operator just took offline.
282
+ */
283
+ export async function setAgentOffline(celloDir, name, opts) {
284
+ const out = await ipcCommand(celloDir, IPC_METHODS["set-agent-offline"], { name }, opts, false);
285
+ if (out.exitCode === 0 && (await readCurrentAgent(celloDir)) === name) {
286
+ await clearCurrentAgent(celloDir);
287
+ }
288
+ return out;
289
+ }
290
+ /**
291
+ * `cello stop-using-agent` — forget the CLI's persisted selection.
292
+ *
293
+ * IT DOES NOT RELEASE A LIVE MCP SESSION, AND MUST NOT CLAIM TO. Attendance is PER-CONNECTION. The
294
+ * MCP shim holds one long-lived socket, which is what `isAttended()` sees; every CLI invocation is a
295
+ * fresh ephemeral connection that starts with `currentAgent: null` and dies microseconds later. So
296
+ * calling the daemon handler from here would always take its idempotent branch and answer "this
297
+ * connection was not attending any agent" — true of the socket, and worthless to the operator, whose
298
+ * agent is attended somewhere else entirely.
299
+ *
300
+ * That is exactly the gesture to expect: attended in Claude Code, step over to a terminal, run
301
+ * `cello stop-using-agent`. Passing the daemon's reply through would print "nothing to release",
302
+ * exit 0, delete the persisted selection, and leave the agent attended with its away message still
303
+ * suppressed — a success message for the opposite of what happened, which is the same class of
304
+ * defect as the name that started all this (review finding 2).
305
+ *
306
+ * So the CLI reports ITS OWN effect and names the half it cannot reach. The daemon call is skipped
307
+ * rather than made-and-ignored: an IPC round-trip whose answer we would discard is not honesty, it
308
+ * is theatre.
309
+ */
310
+ export async function stopUsingAgent(celloDir, opts) {
311
+ const previous = await readCurrentAgent(celloDir);
312
+ await clearCurrentAgent(celloDir);
313
+ return emitIpcResult(previous
314
+ ? {
315
+ ok: true,
316
+ cleared: previous,
317
+ guidance: `Forgot the persisted CLI selection '${previous}'. A live MCP session attending this agent is ` +
318
+ `NOT released — do that in the session itself with cello_stop_using_agent. To make the agent ` +
319
+ `stop answering everywhere, use 'cello set-agent-offline ${previous}'.`,
320
+ }
321
+ : {
322
+ ok: true,
323
+ cleared: null,
324
+ guidance: "No CLI agent selection was persisted, so there was nothing to forget.",
325
+ }, opts);
326
+ }
327
+ /**
328
+ * `cello use-agent <name>` → cello_use_agent, and — only if the daemon accepts it — persists the
329
+ * selection so it survives this process. The handler auto-starts the agent if offline (AUTOSTART-1).
330
+ * A rejected selection is NEVER written: a later command must not silently act as an agent the
331
+ * daemon refused.
332
+ */
333
+ export async function useAgent(celloDir, name, opts) {
334
+ const out = await ipcCommand(celloDir, IPC_METHODS["use-agent"], { name }, opts, false);
335
+ if (out.exitCode === 0)
336
+ await writeCurrentAgent(celloDir, name);
337
+ return out;
338
+ }
339
+ /** `cello inbox [--scope current|all]` → cello_check_notifications (the push-loss reconciler). */
340
+ export function inbox(celloDir, opts) {
341
+ return ipcCommand(celloDir, IPC_METHODS.inbox, defined({ scope: opts.scope }), opts);
342
+ }
343
+ /**
344
+ * `cello sessions` → cello_list_sessions: THIS agent's sessions, matching the MCP tool exactly.
345
+ *
346
+ * DOD-CLI-SESSIONS-SCOPE-1. It used to call the daemon-wide `list_sessions` instead, whose comment
347
+ * explained why — "for the `cello sessions` CLI which has no current agent". That was true when it
348
+ * was written and stopped being true when `use-agent` became durable: the CLI has a persisted
349
+ * selection now, and every other agent-scoped command replays it. The effect of the stale premise
350
+ * was that `cello sessions` answered for ALL agents while `cello_sessions` answered for one, so the
351
+ * two surfaces reported different open-session sets for the same selection — and a multi-agent
352
+ * operator read another agent's rows as their own.
353
+ *
354
+ * The daemon-wide view is still reachable, as `--all-agents`, because it is genuinely useful for
355
+ * "what is open anywhere on this machine". It is opt-in: a listing that silently answers for a
356
+ * principal you did not ask about is the bug, not the feature.
357
+ */
358
+ export function listSessions(celloDir, opts) {
359
+ return ipcCommand(celloDir, IPC_METHODS.sessions, defined({ filter: opts.filter, limit: opts.limit }), opts);
360
+ }
361
+ /** `cello transcript <session-id>` → cello_get_transcript (durable, survives a daemon restart). */
362
+ export function transcript(celloDir, sessionId, opts) {
363
+ return ipcCommand(celloDir, IPC_METHODS.transcript, { session_id: sessionId }, opts);
364
+ }
365
+ /**
366
+ * `cello quarantined <session-id> [sequence]` → cello_get_quarantined — a message CELLO REFUSED.
367
+ *
368
+ * DOD-M15-REFUSEDEVIDENCE-1. With no sequence it lists what is retained (metadata only); with one
369
+ * it returns that message's original text wrapped in a warning, as the LAST field of the response.
370
+ * The CLI exists alongside the MCP tool for the same reason the MCP tool exists at all: an operator
371
+ * who cannot reach it here will get their agent to go looking, and find it unframed.
372
+ */
373
+ export function quarantined(celloDir, sessionId, sequence, opts) {
374
+ return ipcCommand(celloDir, IPC_METHODS.quarantined,
375
+ // `defined` drops undefined, and a real sequence may be 0 or negative — so this must not be a
376
+ // truthiness test. Both values are legitimate positions and both would be dropped by one.
377
+ defined({ session_id: sessionId, sequence }), opts);
378
+ }
379
+ /**
380
+ * `cello sealed-receipt <session-id>` → cello_get_sealed_receipt: the notarized SEAL receipt, the
381
+ * artifact the whole close ceremony exists to produce and the one an arbitrator reads.
382
+ *
383
+ * NOT necessarily bilateral — this said "bilateral" and that was wrong. `seal-escalation.ts:219`
384
+ * returns `seal_type: "unilateral"` when the counterparty never comes back, and that is exactly the
385
+ * receipt someone is holding when something went wrong. The response here carries no `seal_type`
386
+ * either (`session-read-handlers.ts`), so a reader distinguishes the two only via
387
+ * `legibility.participants[].attestation_mode === "absent"` — see `DOD-M15-UNILATERAL-1`.
388
+ *
389
+ * NOT the same handler as `cello relay-receipts <name>`, which calls cello_get_relay_receipts
390
+ * (per-message relay delivery proofs). The two are routinely conflated; they are different
391
+ * artifacts and must keep distinct names.
392
+ */
393
+ export function sealedReceipt(celloDir, sessionId, opts) {
394
+ return ipcCommand(celloDir, IPC_METHODS["sealed-receipt"], { session_id: sessionId }, opts);
395
+ }
396
+ // The WHOLE address book speaks the §3 contract — every sub-verb, not just some. A bash script
397
+ // branching on stderr must not get DIFFERENT conventions between sub-verbs of the same command.
398
+ // One command, one contract: contact failures print JSON on stderr, never stdout.
399
+ /** `cello contact <pubkey> add` → cello_contact_add. */
400
+ export function contactAdd(celloDir, pubkey, opts) {
401
+ return ipcCommand(celloDir, IPC_METHODS["contact-add"], { pubkey }, opts);
402
+ }
403
+ /** `cello contact remove <pubkey>` → cello_contact_remove. */
404
+ export function contactRemove(celloDir, pubkey, opts) {
405
+ return ipcCommand(celloDir, IPC_METHODS["contact-remove"], { pubkey }, opts);
406
+ }
407
+ /** `cello contacts` → cello_contact_list. */
408
+ export function contactList(celloDir, opts) {
409
+ return ipcCommand(celloDir, IPC_METHODS.contacts, {}, opts);
410
+ }
411
+ /** `cello contact set-tier <pubkey> <tier>` → cello_contact_set_tier. */
412
+ export function contactSetTier(celloDir, pubkey, tier, opts) {
413
+ return ipcCommand(celloDir, IPC_METHODS["contact-set-tier"], { pubkey, tier }, opts);
414
+ }
415
+ /** `cello contact set-away <pubkey> <message>` → cello_contact_set_away (empty message clears it). */
416
+ export function contactSetAway(celloDir, pubkey, message, opts) {
417
+ return ipcCommand(celloDir, IPC_METHODS["contact-set-away"], { pubkey, message }, opts);
418
+ }
419
+ /** `cello contact set-moniker <pubkey> <moniker>` → cello_contact_set_moniker (per-CONTACT pet name). */
420
+ export function contactSetMoniker(celloDir, pubkey, moniker, opts) {
421
+ return ipcCommand(celloDir, IPC_METHODS["contact-set-moniker"], { pubkey, moniker }, opts);
422
+ }
423
+ // ─── Agent settings and outbound name ──────────────────────────────────────────────────────────
424
+ //
425
+ // AGENT-SCOPED: they write to one agent's row, so they resolve their agent through withDaemon's
426
+ // use-agent replay like every other agent-scoped command. Do not give them a private connection
427
+ // helper — a second connection path is a second agent-resolution rule, and one operator gesture must
428
+ // not mean two different things depending on which command it reaches.
429
+ /** `cello settings get [key]` → cello_settings_get. Omitted key returns the whole set. */
430
+ export function settingsGet(celloDir, key, opts) {
431
+ return ipcCommand(celloDir, IPC_METHODS["settings-get"], defined({ key }), opts);
432
+ }
433
+ /** `cello settings set <key> <value>` → cello_settings_set. The DAEMON validates the key and, for
434
+ * bound keys, that the value is a finite positive integer; the CLI surfaces its verdict verbatim. */
435
+ export function settingsSet(celloDir, key, value, opts) {
436
+ // `value: null` CLEARS the setting (`cello settings clear <key>`), the same shape
437
+ // cello_contact_set_away has always taken. Sent explicitly rather than as an omitted field: the
438
+ // handler distinguishes "clear this" from "you forgot the value", and an absent key would read as
439
+ // the latter.
440
+ return ipcCommand(celloDir, IPC_METHODS["settings-set"], { key, value }, opts);
441
+ }
442
+ // ─── DOD-M9B-SURFACE-1: the security layer's control surface (policy D-4) ──────────────────────
443
+ //
444
+ // NOT agent-scoped: the security layer screens every message on this machine regardless of which
445
+ // agent is selected, so its config is per-INSTALL. Passing agentScoped=false keeps `cello config`
446
+ // working before any agent is selected — the state an operator is in when a misfiring guard has
447
+ // just blocked them and they need to fix it.
448
+ /** `cello config list` → every guard with its value AND its governance (version, direction, confirmed). */
449
+ export function gatewayConfigList(celloDir, opts) {
450
+ return ipcCommand(celloDir, "cello_config_list", {}, opts, false);
451
+ }
452
+ /** `cello config get <key>` → one guard, plus whether its version chain still verifies. */
453
+ export function gatewayConfigGet(celloDir, key, opts) {
454
+ return ipcCommand(celloDir, "cello_config_get", { key }, opts, false);
455
+ }
456
+ /**
457
+ * `cello config set <key> <value>` — and THE human confirmation the whole D-4 decision rests on.
458
+ *
459
+ * Two phases, and the first one is not a dry run: the daemon attempts the change unconfirmed. If it
460
+ * TIGHTENS, it is already applied and we print the result. If it LOOSENS, the store refuses it (no
461
+ * row written) and answers `needs_confirmation` — only then do we prompt, and only then do we
462
+ * re-send with `confirmed: true`.
463
+ *
464
+ * There is deliberately NO `--yes` flag (M9B-D16). A flag that lets a script confirm a loosening is
465
+ * the environment-variable bypass with a friendlier name, and removing that bypass is the sibling
466
+ * decision (D-5). If stdin is not a TTY there is no human here, so the answer is no.
467
+ */
468
+ export function gatewayConfigSet(celloDir, key, value, opts, prompt = confirmAtTty) {
469
+ return withDaemon(celloDir, opts, false, async (client) => {
470
+ const first = (await client.send("cello_config_set", { key, value }));
471
+ if (first.reason !== "needs_confirmation")
472
+ return first;
473
+ // Render what is ACTUALLY changing. `set` REPLACES a list, so showing only the new value would
474
+ // hide four dropped entries in a five-entry whitelist (review F7). `from: null` means the key
475
+ // has never been configured and the built-in tightest default applies.
476
+ const from = "from" in first ? first.from : undefined;
477
+ const fromText = from === null || from === undefined
478
+ ? "(never configured — the built-in default applies)"
479
+ : JSON.stringify(from);
480
+ const answer = await prompt(`This makes the security layer LESS protective:\n` +
481
+ ` ${key}\n` +
482
+ ` from: ${fromText}\n` +
483
+ ` to: ${value}\n` +
484
+ ` ${String(first.guidance ?? "")}\n` +
485
+ `Apply it?`);
486
+ if (answer === "no_tty") {
487
+ // NOT the same as "the operator said no" (review F3). A CI job or an agent was never shown a
488
+ // prompt, and telling it the human declined is a lie about what happened — and leaves it no
489
+ // way forward. Name the cause and hand over the command.
490
+ return {
491
+ ok: false,
492
+ reason: "not_a_tty",
493
+ guidance: `Weakening '${key}' needs a human at a terminal, and this session has no interactive ` +
494
+ `input. Run it yourself in a terminal: cello config set ${key} ${value}`,
495
+ };
496
+ }
497
+ if (answer === "no") {
498
+ // The operator was asked and said no. Not an error — and the absence of a stored row is the
499
+ // proof that nothing changed.
500
+ return { ok: false, reason: "declined", guidance: `'${key}' was NOT changed.` };
501
+ }
502
+ return (await client.send("cello_config_set", { key, value, confirmed: true }));
503
+ });
504
+ }
505
+ /**
506
+ * Ask a yes/no question on the terminal. Returns `no_tty` — distinct from `no` — when stdin is not
507
+ * a TTY: a pipe, a CI job or an agent spawning the CLI is not a human, and treating one as a human
508
+ * who declined is the side door INV-10 exists to close, told as a misleading story.
509
+ */
510
+ async function confirmAtTty(question) {
511
+ if (!process.stdin.isTTY)
512
+ return "no_tty";
513
+ process.stderr.write(`${question} [y/N] `);
514
+ const answer = await new Promise((resolve) => {
515
+ process.stdin.resume();
516
+ process.stdin.setEncoding("utf8");
517
+ const done = (value) => {
518
+ process.stdin.pause();
519
+ process.stdin.off("data", onData);
520
+ process.stdin.off("end", onEnd);
521
+ process.stdin.off("error", onEnd);
522
+ resolve(value);
523
+ };
524
+ // `end`/`error` as well as `data`: a terminal where the operator hits Ctrl-D closes stdin
525
+ // without ever emitting data, and a promise that never settles is a hang — INV-6 says a
526
+ // deadline always produces an answer, and "no" is the safe one.
527
+ const onData = (chunk) => done(chunk.trim().toLowerCase());
528
+ const onEnd = () => done("");
529
+ process.stdin.once("data", onData);
530
+ process.stdin.once("end", onEnd);
531
+ process.stdin.once("error", onEnd);
532
+ });
533
+ return answer === "y" || answer === "yes" ? "yes" : "no";
534
+ }
535
+ /**
536
+ * `cello policy log` → cello_policy_log (DOD-M9B-AUDIT-1, policy D-11).
537
+ *
538
+ * Ships with the enforcement flip by decision: it is the answer to "did this new error come from
539
+ * the security layer or from my own change?" — a lookup instead of a guess.
540
+ */
541
+ export function policyLog(celloDir, opts) {
542
+ return ipcCommand(celloDir, "cello_policy_log", defined({ limit: opts.limit, since_ms: opts.sinceMs }), opts, false);
543
+ }
544
+ /** `cello moniker set <name>` / `cello moniker clear` → cello_set_moniker. Null clears the override.
545
+ * This is the agent's OWN outbound name — not `contact set-moniker`, which names a COUNTERPARTY. */
546
+ export function monikerSet(celloDir, moniker, opts) {
547
+ return ipcCommand(celloDir, IPC_METHODS["moniker-set"], { moniker }, opts);
548
+ }
549
+ // ─── Group B: live conversation (mirrors the MCP params EXACTLY) ───────────────────────────────
550
+ /** `cello initiate-session <target-pubkey>` → cello_initiate_session. Prints the session_id. */
551
+ export function initiate(celloDir, targetPubkey, opts) {
552
+ const extra = {};
553
+ if (opts.include)
554
+ extra["include_signals"] = opts.include;
555
+ if (opts.exclude)
556
+ extra["exclude_signals"] = opts.exclude;
557
+ return ipcCommand(celloDir, IPC_METHODS["initiate-session"], { target_pubkey: targetPubkey, ...extra }, opts);
558
+ }
559
+ /**
560
+ * `cello send <session-id> <message>` → cello_send.
561
+ *
562
+ * Honors read-before-write exactly as the MCP tool does: if the daemon returns session_not_current
563
+ * (with its cursor), that verdict is surfaced VERBATIM and the command exits non-zero. It is never
564
+ * auto-fixed by silently reading the transcript first — the operator/agent must catch up explicitly,
565
+ * because a send that "worked" after a hidden read is a send whose ordering guarantees are a fiction.
566
+ */
567
+ export function send(celloDir, sessionId, content, opts) {
568
+ const { signal, estMinutes } = opts;
569
+ const token = signal === "over" ? " [[OVER]]" :
570
+ signal === "wrap" ? " [[WRAP]]" :
571
+ signal === "standby" ? ` [[STANDBY EST:${estMinutes}m]]` :
572
+ "";
573
+ return ipcCommand(celloDir, "cello_send", defined({ session_id: sessionId, content: content + token, governance_decisions: opts.governanceDecisions }), opts);
574
+ }
575
+ /**
576
+ * `cello receive <session-id> [--timeout-ms N]` → cello_receive. Every unread message at once;
577
+ * waits up to timeout_ms only when nothing is unread.
578
+ */
579
+ export function receive(celloDir, sessionId, opts) {
580
+ return ipcCommand(celloDir, "cello_receive", defined({ session_id: sessionId, timeout_ms: opts.timeoutMs }), opts);
581
+ }
582
+ /**
583
+ * `cello close-session <session-id> [--force]` → cello_close_session. Triggers the seal ceremony —
584
+ * bilateral when the counterparty co-signs, unilateral when they never return
585
+ * (`seal-escalation.ts:219`). This said "the bilateral seal ceremony", which named the good case as
586
+ * the only case. --force is passed ONLY when asked for (mirroring the shim), since it forfeits the
587
+ * seal outright.
588
+ */
589
+ export function closeSession(celloDir, sessionId, opts) {
590
+ const params = { session_id: sessionId };
591
+ if (opts.force)
592
+ params.force = true;
593
+ // DOD-SESSION-NAME-1 (AC-A14): only sent when the operator asked for it. Omitted means "no name",
594
+ // which is a meaningful state — never fill it in for them.
595
+ if (opts.sessionName !== undefined)
596
+ params.session_name = opts.sessionName;
597
+ return ipcCommand(celloDir, IPC_METHODS["close-session"], params, opts);
598
+ }
599
+ /**
600
+ * `cello name-session <session-id> <name...>` (or --clear) → cello_name_session.
601
+ *
602
+ * DOD-SESSION-NAME-1 (AC-A15). The name is taken from the remaining positionals and joined, so
603
+ * multi-word names work without quoting: `cello name-session ab12… the deploy postmortem`.
604
+ * `--clear` sends null, which is how you un-name a session.
605
+ */
606
+ export function nameSession(celloDir, sessionId, sessionName, opts) {
607
+ return ipcCommand(celloDir, IPC_METHODS["name-session"], { session_id: sessionId, session_name: sessionName }, opts);
608
+ }
609
+ /** `cello dismiss <session-id>` → cello_dismiss. Clears a terminal session from the inbox. */
610
+ export function dismissSession(celloDir, sessionId, opts) {
611
+ return ipcCommand(celloDir, IPC_METHODS.dismiss, { session_id: sessionId }, opts);
612
+ }
613
+ /** `cello await-session [--timeout-ms N]` → cello_await_session. Blocks for an inbound doorbell. */
614
+ export function awaitSession(celloDir, opts) {
615
+ return ipcCommand(celloDir, IPC_METHODS["await-session"], defined({ timeout_ms: opts.timeoutMs }), opts);
616
+ }
617
+ // ─── Group: attestation-consent (M10B / DOD-END-SURFACE-1) ─────────────────────────────────────────────────
618
+ // No `agent` argument on any of the three: they are scoped to the SELECTED agent by the daemon.
619
+ // Consent is a statement about oneself; naming another agent would be accepting on its behalf.
620
+ /** `cello attestation-consent list` → cello_attestation_consent_list. */
621
+ export function attestationConsentList(celloDir, opts) {
622
+ return ipcCommand(celloDir, IPC_METHODS["attestation-consent-list"], {}, opts);
623
+ }
624
+ /** `cello attestation-consent accept <signal-hash>` → cello_attestation_consent_accept. */
625
+ export function attestationConsentAccept(celloDir, hashPrefix, opts) {
626
+ return ipcCommand(celloDir, IPC_METHODS["attestation-consent-accept"], { hash_prefix: hashPrefix }, opts);
627
+ }
628
+ /** `cello attestation-consent refuse <hash> [message…]` → cello_attestation_consent_refuse. An empty message is OMITTED, not
629
+ * sent as "": silence is the default and it must be the literal absence of the field (M10B-D4). */
630
+ export function attestationConsentRefuse(celloDir, hashPrefix, message, opts) {
631
+ const params = { hash_prefix: hashPrefix };
632
+ if (message !== null && message.length > 0)
633
+ params.message = message;
634
+ return ipcCommand(celloDir, IPC_METHODS["attestation-consent-refuse"], params, opts);
635
+ }
636
+ /** `cello contact <pubkey> set-signal <hash> <on|off|clear>` → cello_contact_set_signal.
637
+ * `clear` sends null — distinct from `off`, which is an explicit "never show this to them". */
638
+ export function contactSetSignal(celloDir, pubkey, hashPrefix, present, opts) {
639
+ return ipcCommand(celloDir, IPC_METHODS["contact-set-signal"], { pubkey, hash_prefix: hashPrefix, present }, opts);
640
+ }
641
+ // ─── M14 / DOD-DOC-TOOLS-1 — federated documents ────────────────────────────────────────────────
642
+ /**
643
+ * `cello doc propose <peer-pubkey> [--type <t>] [--content <text>] [--append-only] [--admins <hex,hex>] [--retry <id>]`
644
+ * → cello_doc_propose.
645
+ *
646
+ * `--retry` re-sends an offer that was created locally but never reached the peer. The daemon has
647
+ * always had that branch and its failure guidance names it by id — but no surface forwarded the
648
+ * parameter, so the instruction could not be carried out and the closest thing an operator could do
649
+ * (propose again) minted a SECOND document, which is what the guidance warns against.
650
+ *
651
+ * The doc comment advertised a `--from-file` flag that was never implemented; corrected here rather
652
+ * than left for the next reader to try.
653
+ */
654
+ export function docPropose(celloDir, peerPubkey, opts) {
655
+ return ipcCommand(celloDir, IPC_METHODS["doc-propose"], defined({
656
+ peer_pubkey: peerPubkey,
657
+ document_type: opts.documentType,
658
+ append_only: opts.appendOnly === true ? true : undefined,
659
+ admins: opts.admins,
660
+ starting_content: opts.startingContent,
661
+ document_id: opts.documentId,
662
+ }), opts);
663
+ }
664
+ /** `cello doc invite <document-id> <invitee-pubkey>` → cello_doc_invite. */
665
+ export function docInvite(celloDir, documentId, inviteePubkey, opts) {
666
+ return ipcCommand(celloDir, IPC_METHODS["doc-invite"], { document_id: documentId, invitee_pubkey: inviteePubkey }, opts);
667
+ }
668
+ /** `cello doc remove <document-id> <holder-pubkey>` → cello_doc_remove. */
669
+ export function docRemove(celloDir, documentId, holderPubkey, opts) {
670
+ return ipcCommand(celloDir, IPC_METHODS["doc-remove"], { document_id: documentId, holder_pubkey: holderPubkey }, opts);
671
+ }
672
+ /** `cello doc inbox` → cello_doc_inbox. Proposals awaiting a consent decision. */
673
+ export function docInbox(celloDir, opts) {
674
+ return ipcCommand(celloDir, IPC_METHODS["doc-inbox"], {}, opts);
675
+ }
676
+ /** `cello doc accept <document-id>` → cello_doc_accept. */
677
+ export function docAccept(celloDir, documentId, opts) {
678
+ return ipcCommand(celloDir, IPC_METHODS["doc-accept"], { document_id: documentId }, opts);
679
+ }
680
+ /** `cello doc refuse <document-id> [why…]` → cello_doc_refuse. An empty reason is OMITTED, so the
681
+ * daemon applies its own default rather than recording the empty string as the operator's words. */
682
+ export function docRefuse(celloDir, documentId, reason, opts) {
683
+ return ipcCommand(celloDir, IPC_METHODS["doc-refuse"], defined({ document_id: documentId, reason: reason !== null && reason.length > 0 ? reason : undefined }), opts);
684
+ }
685
+ /** `cello doc list` → cello_doc_list. */
686
+ export function docList(celloDir, opts) {
687
+ return ipcCommand(celloDir, IPC_METHODS["doc-list"], {}, opts);
688
+ }
689
+ /** `cello doc read <document-id>` → cello_doc_read. */
690
+ export function docRead(celloDir, documentId, opts) {
691
+ return ipcCommand(celloDir, IPC_METHODS["doc-read"], { document_id: documentId }, opts);
692
+ }
693
+ /** `cello doc diff <document-id>` → cello_doc_diff. What changed since you last read it. */
694
+ export function docDiff(celloDir, documentId, opts) {
695
+ return ipcCommand(celloDir, IPC_METHODS["doc-diff"], { document_id: documentId }, opts);
696
+ }
697
+ /** `cello doc watch <document-id> [paths…]` → cello_doc_watch. No paths LISTS; `--clear` clears. */
698
+ export function docWatch(celloDir, documentId, paths, opts) {
699
+ return ipcCommand(celloDir, IPC_METHODS["doc-watch"], paths === null ? { document_id: documentId } : { document_id: documentId, paths }, opts);
700
+ }
701
+ /** `cello doc write <document-id> <content…>` → cello_doc_write.
702
+ *
703
+ * The COMPLETE new text, never a patch — the daemon diffs it against current state, so an offset
704
+ * cannot go stale under the peer's concurrent edit. */
705
+ export function docWrite(celloDir, documentId, content, opts) {
706
+ return ipcCommand(celloDir, IPC_METHODS["doc-write"], { document_id: documentId, content }, opts);
707
+ }
708
+ /** `cello doc close <document-id>` → cello_doc_close. Every current holder is told; `holdersNotified` says who took it. */
709
+ export function docClose(celloDir, documentId, opts) {
710
+ return ipcCommand(celloDir, IPC_METHODS["doc-close"], { document_id: documentId }, opts);
711
+ }
712
+ /** `cello doc kill <document-id>` → cello_doc_kill. One-sided and immediate; every holder is told best-effort. */
713
+ export function docKill(celloDir, documentId, opts) {
714
+ return ipcCommand(celloDir, IPC_METHODS["doc-kill"], { document_id: documentId }, opts);
715
+ }
716
+ /** `cello doc publish <document-id>` → cello_doc_publish. Publishes what is in the FILE right now. */
717
+ export function docPublish(celloDir, documentId, opts) {
718
+ return ipcCommand(celloDir, IPC_METHODS["doc-publish"], { document_id: documentId }, opts);
719
+ }
720
+ //# sourceMappingURL=parity-commands.js.map