<system-reminder>[Truncated: PARTIAL view — showing lines 1-1265 of 3964 total (66547 tokens, cap 25000). Call Read with offset=1266 limit=1265 for the next page, or Grep to find a specific section. Do NOT answer from this page alone if the answer may be further in the file.]</system-reminder>

1	//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
2	//!
3	//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
4	//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
5	//! structured outcome, return an exit code. No messaging logic lives in this
6	//! layer.
7	//!
8	//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
9	//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
10	//! (clap, for unknown/missing args).
11	// [impl->REQ-MSG-2]
12
13	use std::io::{Read, Write};
14	use std::path::PathBuf;
15	use std::time::Duration;
16
17	use clap::{Parser, Subcommand};
18	use spt_msg::deliver::{self, SendOutcome};
19	use spt_msg::ready::ReadyAgent;
20	use spt_msg::ring::{self, RingOutcome};
21	use spt_net::net::pairing::totp::TotpSeed;
22	use spt_store::perch::{self, ParentHint};
23	use spt_store::registry;
24	use spt_store::subnet::{SubnetError, SubnetStore};
25
26	use crate::elevation::{self, Elevation, Gate};
27	use crate::roster;
28
29	/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
30	/// issuer label). Stable user-facing string.
31	const ISSUER: &str = "spt";
32
33	/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
34	/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
35	/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
36	const EXIT_NOT_ELEVATED: i32 = 3;
37
38	#[derive(Parser)]
39	#[command(
40	    name = "spt",
41	    version,
42	    about = "spt — a harness-independent core for an agent ecosystem: inter-agent \
43	             messaging, live-agent lifecycle, terminal hosting, P2P networking, \
44	             seamless self-update. Docs: https://sabermage.github.io/spt-releases",
45	    long_about = None
46	)]
47	struct Cli {
48	    #[command(subcommand)]
49	    cmd: Cmd,
50	}
51
52	#[derive(Subcommand)]
53	enum Cmd {
54	    /// Send a message (body read from stdin); fire-and-forget.
55	    Send {
56	        /// Target perch id. May be omitted when --reply-to is given.
57	        target: Option<String>,
58	        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
59	        #[arg(long)]
60	        from: Option<String>,
61	        /// Reply to this sender; sets the target and labels output REPLIED.
62	        #[arg(long)]
63	        reply_to: Option<String>,
64	        /// Spool only (deferred / hook channel) — no live wake.
65	        #[arg(long)]
66	        deferred: bool,
67	    },
68	    /// Send and block for a reply (body read from stdin).
69	    ///
70	    /// The reply body is printed to stdout; gives up after --timeout seconds.
71	    Ring {
72	        /// Target perch id.
73	        target: String,
74	        /// Sender id (auto-detected from session if omitted).
75	        #[arg(long)]
76	        from: Option<String>,
77	        /// Seconds to wait for a reply before giving up.
78	        #[arg(long, default_value = "60")]
79	        timeout: u64,
80	    },
81	    /// Become reachable: register the perch and listen (blocks).
82	    Ready {
83	        /// This agent's perch id.
84	        id: String,
85	        /// Home subnet for a NEW endpoint (required on a multi-subnet
86	        /// node — home is assigned at creation, never guessed).
87	        #[arg(long)]
88	        subnet: Option<String>,
89	    },
90	    /// Receive messages: drain backlog, then listen.
91	    ///
92	    /// Each received message prints to stdout.
93	    Poll {
94	        /// This agent's perch id.
95	        id: String,
96	        /// Run a single drain+receive cycle, then exit (legacy one-shot).
97	        #[arg(long)]
98	        once: bool,
99	        /// Home subnet for a NEW endpoint (see `ready`).
100	        #[arg(long)]
101	        subnet: Option<String>,
102	    },
103	    /// List this node's perches (id, state, address, ready, alive).
104	    List,
105	    /// Soft-stop a perch (spool preserved).
106	    ///
107	    /// Removes the ready marker and unregisters the perch; the spool is
108	    /// preserved.
109	    Stop {
110	        /// Perch id to stop.
111	        id: String,
112	    },
113	    /// Print this session's own perch id.
114	    ///
115	    /// Resolved from $OWL_SESSION_ID / $SPT_AGENT_ID.
116	    Whoami,
117	    /// Subnet pairing: show this node's current pairing code.
118	    ///
119	    /// Also mints new subnets (`show-totp --create-new`). Pairing is how
120	    /// machines join a private subnet.
121	    Pair {
122	        #[command(subcommand)]
123	        action: PairCmd,
124	    },
125	    /// Show a hosted session's live activity buffer (PTY digest).
126	    ///
127	    /// The at-a-glance "what is this agent doing now" view. Pulls a
128	    /// snapshot, or `--follow`s the delta-stream. Local endpoints only.
129	    Digest {
130	        /// The (local) endpoint id to read.
131	        id: String,
132	        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
133	        #[arg(long)]
134	        follow: bool,
135	        /// Emit structured JSON instead of the human-glanceable render.
136	        #[arg(long)]
137	        json: bool,
138	    },
139	    /// Rename an endpoint's logical id across its on-disk state.
140	    ///
141	    /// Rippled everywhere the id appears: the endpoint's perch dir, its
142	    /// nested companion/worker perches, and every record naming it.
143	    /// Refuses while the perch is live (stop it first).
144	    Rename {
145	        /// The endpoint's current (bare) id.
146	        old_id: String,
147	        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
148	        new_id: String,
149	    },
150	    /// Endpoint access whitelist for unsolicited off-node inbound.
151	    ///
152	    /// Controls which origin nodes may send an endpoint unsolicited
153	    /// off-node inbound. Absent entry = open; `allow` flips the endpoint
154	    /// to restricted; revoking the last node leaves it locked down;
155	    /// `open` deletes the restriction.
156	    Access {
157	        #[command(subcommand)]
158	        action: AccessCmd,
159	    },
160	    /// Consent grant store: gated capabilities held on this node.
161	    ///
162	    /// Default-deny (the access whitelist's opposite polarity). An
163	    /// ungranted ask escalates interactively; `add` is the durable
164	    /// allow-always answer.
165	    Grant {
166	        #[command(subcommand)]
167	        action: GrantCmd,
168	    },
169	    /// Adapter registration: what this node can drive/launch.
170	    ///
171	    /// The node-local registered set (one command for harness and shell
172	    /// adapters). Feeds creation-time adapter selection, shell discovery,
173	    /// and the self-update ripple.
174	    Adapter {
175	        #[command(subcommand)]
176	        action: AdapterCmd,
177	    },
178	    /// Shell instances: mint, list, drive, tear down owned surfaces.
179	    ///
180	    /// The driven surfaces this agent owns. `spawn` MINTS a new instance
181	    /// identity (`<adapter>-<n>`) — it is not the online switch; bringing
182	    /// an existing offline instance back is `relink` / `persistent` /
183	    /// wake.
184	    Shell {
185	        #[command(subcommand)]
186	        action: ShellCmd,
187	    },
188	    /// Issue a subnet-wide user notification.
189	    ///
190	    /// Produced into the replicated notification spool and first-fired at
191	    /// the user's most-recently-active endpoint in that subnet. Body from
192	    /// the trailing arg, or stdin when omitted.
193	    Notify {
194	        /// Notification body (read from stdin when omitted).
195	        body: Option<String>,
196	        /// Which subnet to notify. Required only when the node holds several
197	        /// (never guessed).
198	        #[arg(long)]
199	        subnet: Option<String>,
200	        /// Issuer endpoint id (auto-detected from session if omitted).
201	        #[arg(long)]
202	        from: Option<String>,
203	    },
204	    /// Inspect and acknowledge notifications.
205	    ///
206	    /// Dismissal is the explicit ack — it latches and replicates
207	    /// subnet-wide.
208	    Notif {
209	        #[command(subcommand)]
210	        action: NotifCmd,
211	    },
212	    /// Self-update operations.
213	    ///
214	    /// `apply` is the explicit ack named by the update-consent
215	    /// notification; it re-verifies the staged release before touching
216	    /// the live daemon.
217	    Update {
218	        #[command(subcommand)]
219	        action: UpdateCmd,
220	    },
221	    /// Fork an endpoint into another subnet as a NEW identity.
222	    ///
223	    /// Home subnets are immutable — fork is the cross-subnet move, never
224	    /// a re-home. Seeds the fork with a one-time copy of the source's
225	    /// mind (live + project tiers); the two diverge immediately (no
226	    /// ongoing sync). The source is untouched unless --delete-source.
227	    /// Same-node only today (one node holds one perch per name, so a
228	    /// local fork needs a new id).
229	    Fork {
230	        /// The source endpoint (must exist on this node).
231	        src: String,
232	        /// The fork's id (must differ from the source on the same node).
233	        new_id: String,
234	        /// The fork's home subnet — the target (must be a member).
235	        #[arg(long)]
236	        subnet: String,
237	        /// Delete the source endpoint (perch + tracked mind) after the copy.
238	        #[arg(long)]
239	        delete_source: bool,
240	    },
241	    /// The subnet resource registry — an agent yellow-pages.
242	    ///
243	    /// Per-endpoint free-text service blurbs. `set` authors your own,
244	    /// `show` reads a local endpoint's, `list` projects the registry view
245	    /// over visible rows.
246	    Resources {
247	        #[command(subcommand)]
248	        action: ResourcesCmd,
249	    },
250	    /// Rest an endpoint cold (the suspend edge).
251	    ///
252	    /// The resting state machine's suspend edge. From dormant — or
253	    /// straight from active, in which case the final
254	    /// context save still fires first. Accepts a qualified `id@node` to
255	    /// suspend an instance on a paired peer.
256	    Suspend {
257	        /// The endpoint id (qualified `id@node` reaches a paired peer).
258	        id: String,
259	    },
260	    /// Wake a resting endpoint in place.
261	    ///
262	    /// Re-activates the existing seat (state's already there — no fresh
263	    /// spawn), resurfaces undismissed notifications, and requests an
264	    /// immediate context freshness pull from trusted peers. Accepts a
265	    /// qualified `id@node` for an instance on a paired peer.
266	    Wake {
267	        /// The endpoint id (qualified `id@node` reaches a paired peer).
268	        id: String,
269	    },
270	    /// Gracefully shut down an agent's own endpoint.
271	    ///
272	    /// Soft-stops the listener, then the suspend edge — the final context
273	    /// save fires and persistent shells cascade offline with it.
274	    Shutdown {
275	        /// The endpoint id (defaults to the session's own perch).
276	        id: Option<String>,
277	    },
278	    /// Harness-contract inbound surface (hook entry points).
279	    ///
280	    /// The entry points a harness's hooks fire to keep spt-core's on-disk
281	    /// state in sync.
282	    Api(crate::api::ApiArgs),
283	    /// Run the per-machine daemon. Internal: any `spt` invocation that needs
284	    /// it auto-starts it; operators rarely run it directly.
285	    #[command(hide = true)]
286	    Daemon,
287	}
288
289	/// `spt access <action>` subcommands (REQ-SEC-1).
290	#[derive(Subcommand)]
291	enum AccessCmd {
292	    /// Whitelist a node for an endpoint (creates the restriction if absent).
293	    Allow {
294	        /// The endpoint to restrict.
295	        endpoint: String,
296	        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
297	        node: String,
298	    },
299	    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
300	    /// last node leaves the endpoint locked down (all unsolicited refused).
301	    Revoke {
302	        endpoint: String,
303	        node: String,
304	    },
305	    /// Delete an endpoint's restriction entirely — back to default-open.
306	    Open {
307	        endpoint: String,
308	    },
309	    /// List restrictions (all endpoints, or one).
310	    List {
311	        endpoint: Option<String>,
312	    },
313	}
314
315	/// `spt adapter <action>` subcommands (REQ-INSTALL-4).
316	#[derive(Subcommand)]
317	enum AdapterCmd {
318	    /// Register an adapter from a local path (a dir holding `manifest.toml`,
319	    /// or the manifest file itself) or from GitHub (`--github user/repo`,
320	    /// cloned under `adapters/_github/`). Manifest-first: an invalid manifest
321	    /// registers nothing. Install is the first update — the declared
322	    /// `[update]` avenue is conducted once after recording.
323	    Add {
324	        /// Local manifest source (omit when using --github).
325	        path: Option<String>,
326	        /// GitHub source `user/repo` — manifest-first, then install via the
327	        /// declared `[update]` avenue.
328	        #[arg(long)]
329	        github: Option<String>,
330	    },
331	    /// Soft-deregister: hidden from new-creation/discovery; existing and live
332	    /// instances keep running. The manifest's optional `uninstall` template
333	    /// is conducted only with --force until quiesce detection lands.
334	    Remove {
335	        name: String,
336	        /// Conduct the manifest `uninstall` template now, without waiting for
337	        /// quiesce.
338	        #[arg(long)]
339	        force: bool,
340	    },
341	    /// List registered adapters (active and soft-deregistered).
342	    List,
343	}
344
345	/// `spt grant <action>` subcommands (REQ-CONSENT-1).
346	#[derive(Subcommand)]
347	enum GrantCmd {
348	    /// Record a grant: `agent` may exercise `capability` on this node.
349	    /// Refuses the reserved deferred capability ids (remote-exec,
350	    /// instantiate-anywhere) — their gate refuses unconditionally, so a row
351	    /// would only be a footgun-in-waiting.
352	    Add {
353	        /// The gated capability id (e.g. spawn-shell, owner-shutdown).
354	        capability: String,
355	        /// The subject agent (endpoint id).
356	        agent: String,
357	        /// Narrower target within the node (e.g. the shell-adapter name for
358	        /// spawn-shell). Omitted = the node-wide row; the two never match
359	        /// each other.
360	        #[arg(long)]
361	        qualifier: Option<String>,
362	    },
363	    /// Remove the exact grant row. Never widens or narrows neighbours: only
364	    /// the named (capability, agent, qualifier) tuple goes.
365	    Revoke {
366	        capability: String,
367	        agent: String,
368	        #[arg(long)]
369	        qualifier: Option<String>,
370	    },
371	    /// List grant rows (all, or one agent's).
372	    List {
373	        agent: Option<String>,
374	    },
375	}
376
377	/// `spt shell <action>` subcommands (M5-D3, REQ-EP-5). Owner-scoped: every
378	/// action operates on the invoking agent's own instances (`--owner`
379	/// overrides; auto-detected from the session otherwise).
380	#[derive(Subcommand)]
381	enum ShellCmd {
382	    /// Mint a NEW shell instance of a registered `kind="shell"` adapter:
383	    /// canonical id `<adapter>-<n>` (smallest free n; teardown frees slots),
384	    /// starting offline (the launch + bind handshake brings it online).
385	    Spawn {
386	        /// The providing shell adapter (must be registered + active).
387	        adapter: String,
388	        /// Optional owner-unique friendly label (interchangeable with the
389	        /// canonical id for addressing; never obscures the adapter).
390	        #[arg(long)]
391	        alias: Option<String>,
392	        /// Owning endpoint id (auto-detected from session if omitted).
393	        #[arg(long)]
394	        owner: Option<String>,
395	    },
396	    /// List this owner's instances: canonical id, alias, adapter, status.
397	    List {
398	        #[arg(long)]
399	        owner: Option<String>,
400	    },
401	    /// Destroy an instance (perch removed; mint slot + alias freed).
402	    Teardown {
403	        /// Canonical id or alias.
404	        shell_ref: String,
405	        #[arg(long)]
406	        owner: Option<String>,
407	    },
408	    /// Set/replace an instance's alias (owner-unique).
409	    Rename {
410	        /// Canonical id or current alias.
411	        shell_ref: String,
412	        /// The new alias.
413	        alias: String,
414	        #[arg(long)]
415	        owner: Option<String>,
416	    },
417	    /// Drive the shell with a typed capability command (the durable command
418	    /// channel): the op + positional args are vocabulary-checked against the
419	    /// manifest's `[shell.capabilities]`, spooled on the shell perch, and
420	    /// drained by the manifest's `command_receipt` mode (relay / stdin).
421	    Cmd {
422	        /// Canonical id or alias.
423	        shell_ref: String,
424	        /// The capability op + args (vocabulary-checked against the manifest).
425	        #[arg(trailing_var_arg = true)]
426	        op: Vec<String>,
427	        #[arg(long)]
428	        owner: Option<String>,
429	    },
430	    /// Send a text and/or file payload down the durable 2-way text+file
431	    /// channel (agent→shell; the shell answers via ordinary `spt send`).
432	    /// File transfers are progress-queryable by xfer id.
433	    Send {
434	        /// Canonical id or alias.
435	        shell_ref: String,
436	        /// The text payload.
437	        text: Option<String>,
438	        /// A file to transfer to the shell.
439	        #[arg(long)]
440	        file: Option<PathBuf>,
441	        #[arg(long)]
442	        owner: Option<String>,
443	    },
444	    /// Bring an existing offline (persistent) instance back online: re-spawns
445	    /// the binary with a fresh link token; the perch onlines at its bind.
446	    Relink {
447	        /// Canonical id or alias.
448	        shell_ref: String,
449	        #[arg(long)]
450	        owner: Option<String>,
451	    },
452	}
453
454	/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).
455	#[derive(Subcommand)]
456	enum ResourcesCmd {
457	    /// Author this endpoint's blurb (the agent refines its own at runtime;
458	    /// an empty string clears it back to the node-config seed).
459	    Set {
460	        /// The blurb text ("" clears).
461	        text: String,
462	        /// Which local endpoint (auto-detected from the session if omitted).
463	        #[arg(long)]
464	        id: Option<String>,
465	    },
466	    /// Show a local endpoint's authored blurb.
467	    Show {
468	        /// The local endpoint id (auto-detected if omitted).
469	        id: Option<String>,
470	    },
471	    /// The subnet resource-registry projection: `(id, node, status, blurb)`
472	    /// over VISIBLE rows only (hidden endpoints never appear — discovery
473	    /// leaks nothing a viewer couldn't reach).
474	    List {
475	        /// Limit to one subnet (all member subnets otherwise).
476	        #[arg(long)]
477	        subnet: Option<String>,
478	    },
479	}
480
481	/// `spt notif <action>` subcommands (ADR-0007).
482	#[derive(Subcommand)]
483	enum NotifCmd {
484	    /// List notifications (all member subnets, or one).
485	    List {
486	        /// Limit to one subnet.
487	        #[arg(long)]
488	        subnet: Option<String>,
489	    },
490	    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
491	    Dismiss {
492	        /// The notif id (as shown by `spt notif list`).
493	        notif_id: String,
494	    },
495	}
496
497	/// `spt update <action>` subcommands (REQ-UPD-4).
498	#[derive(Subcommand)]
499	enum UpdateCmd {
500	    /// Apply the staged, verified self-update now.
501	    Apply,
502	}
503
504	/// `spt pair <action>` subcommands.
505	#[derive(Subcommand)]
506	enum PairCmd {
507	    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI —
508	    /// gated behind OS elevation (or read the code from your authenticator
509	    /// app). With no flag the node's sole subnet is used; if it holds
510	    /// several, `--subnet <name>` is required (never guessed).
511	    /// `--create-new <name>` mints a fresh subnet first.
512	    ShowTotp {
513	        /// Which subnet's code to show. Required only when the node holds several.
514	        #[arg(long)]
515	        subnet: Option<String>,
516	        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
517	        /// then show its code. Mutually exclusive with `--subnet`.
518	        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
519	        create_new: Option<String>,
520	    },
521	}
522
523	/// Top-level commands an agent is meant to run from its own session; every
524	/// other visible command lands in the operator-facing "User commands" group.
525	/// Grouping is render-time only (clap has no native subcommand help
526	/// headings); `help_groups_cover_every_command` keeps this set honest.
527	const AGENT_COMMANDS: &[&str] =
528	    &["api", "notify", "poll", "ready", "resources", "ring", "send", "shell", "shutdown", "whoami"];
529
530	/// Build the top-level help template: the default clap layout, with the flat
531	/// `Commands:` block replaced by two alphabetized groups (user-facing first,
532	/// agent-facing second). Entries are generated from the derived `Command`
533	/// itself, so abouts can never drift from the list.
534	fn grouped_help_template(cmd: &mut clap::Command) -> String {
535	    cmd.build(); // materialize the implicit `help` subcommand
536	    let (mut user, mut agent) = (Vec::new(), Vec::new());
537	    for sc in cmd.get_subcommands() {
538	        if sc.is_hide_set() {
539	            continue;
540	        }
541	        let name = sc.get_name().to_string();
542	        let about = sc.get_about().map(ToString::to_string).unwrap_or_default();
543	        let group = if AGENT_COMMANDS.contains(&name.as_str()) { &mut agent } else { &mut user };
544	        group.push((name, about));
545	    }
546	    user.sort();
547	    agent.sort();
548	    // One aligned name column across both groups; abouts are one-liners by
549	    // contract (see the unit test), so no entry ever wraps.
550	    let pad = user.iter().chain(&agent).map(|(n, _)| n.len()).max().unwrap_or(0);
551	    let render = |rows: &[(String, String)]| {
552	        rows.iter().map(|(n, a)| format!("  {n:<pad$}  {a}\n")).collect::<String>()
553	    };
554	    format!(
555	        "{{before-help}}{{about-with-newline}}\n{{usage-heading}} {{usage}}\n\n\
556	         User commands:\n{}\n\
557	         Agent commands:\n{}\n\
558	         Options:\n{{options}}{{after-help}}",
559	        render(&user),
560	        render(&agent),
561	    )
562	}
563
564	/// Parse args and dispatch. Returns the process exit code.
565	pub fn run() -> i32 {
566	    use clap::{CommandFactory, FromArgMatches};
567	    let mut cmd = Cli::command();
568	    let template = grouped_help_template(&mut cmd);
569	    let matches = cmd.help_template(template).get_matches();
570	    let cli = match Cli::from_arg_matches(&matches) {
571	        Ok(cli) => cli,
572	        Err(e) => e.exit(),
573	    };
574	    match cli.cmd {
575	        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
576	        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
577	        Cmd::Ready { id, subnet } => run_listen(&id, false, subnet.as_deref()),
578	        Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref()),
579	        Cmd::List => cmd_list(),
580	        Cmd::Stop { id } => cmd_stop(&id),
581	        Cmd::Whoami => cmd_whoami(),
582	        Cmd::Pair { action } => match action {
583	            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
584	        },
585	        Cmd::Access { action } => cmd_access(action),
586	        Cmd::Grant { action } => cmd_grant(action),
587	        Cmd::Adapter { action } => cmd_adapter(action),
588	        Cmd::Shell { action } => cmd_shell(action),
589	        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
590	        Cmd::Notif { action } => match action {
591	            NotifCmd::List { subnet } => cmd_notif_list(subnet),
592	            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
593	        },
594	        Cmd::Update { action } => match action {
595	            UpdateCmd::Apply => cmd_update_apply(),
596	        },
597	        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
598	        Cmd::Fork { src, new_id, subnet, delete_source } => {
599	            cmd_fork(&src, &new_id, &subnet, delete_source)
600	        }
601	        Cmd::Resources { action } => cmd_resources(action),
602	        Cmd::Suspend { id } => cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED"),
603	        Cmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
604	        Cmd::Shutdown { id } => cmd_shutdown(id),
605	        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
606	        Cmd::Api(args) => crate::api::run(args),
607	        Cmd::Daemon => cmd_daemon(),
608	    }
609	}
610
611	/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
612	/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
613	/// follows the delta-stream over the digest-control channel. Local addressing
614	/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
615	// [impl->REQ-TERM-4]
616	fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
617	    // Local addressing only — the qualified cross-node form is M4 (spt-net).
618	    if id.contains('@') || id.contains(':') {
619	        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
620	        return 1;
621	    }
622	    // The digest lives in the daemon; bring it up if absent (best-effort).
623	    if let Err(e) = spt_daemon::ensure_running() {
624	        eprintln!("DAEMON_START_WARN: {e}");
625	    }
626	    let name = spt_daemon::digest_socket_name();
627
628	    if follow {
629	        let res = spt_daemon::follow(&name, id, 0, |up| {
630	            if json {
631	                println!("{}", spt_daemon::update_to_json(up));
632	            } else {
633	                print!("{}", spt_daemon::render_update(up));
634	            }
635	            let _ = std::io::stdout().flush();
636	            true // stream until the connection ends / the user interrupts
637	        });
638	        return match res {
639	            Ok(()) => 0,
640	            Err(e) => {
641	                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
642	                1
643	            }
644	        };
645	    }
646
647	    match spt_daemon::pull_snapshot(&name, id) {
648	        Ok(Some((digest, version))) => {
649	            if json {
650	                println!("{}", spt_daemon::digest_to_json(&digest));
651	            } else {
652	                print!("{}", spt_daemon::render_digest(&digest));
653	                let _ = std::io::stdout().flush();
654	            }
655	            eprintln!("DIGEST:{id} version={version}");
656	            0
657	        }
658	        Ok(None) => {
659	            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
660	            1
661	        }
662	        Err(e) => {
663	            eprintln!("DIGEST_FAIL:{id}: {e}");
664	            1
665	        }
666	    }
667	}
668
669	/// `spt fork <src> <new_id> --subnet <target>` — the cross-subnet move
670	/// (REQ-INST-15, ADR-0010). Order of operations is refusal-first: every gate
671	/// runs before anything mutates, so a refused fork leaves no half-fork.
672	///
673	/// 1. charset + same-id guard (a node holds one perch per name — the
674	///    same-name fork is the M5 remote arm);
675	/// 2. target membership (the fork's home must be a subnet we hold);
676	/// 3. source exists locally (the remote-source arm is M5);
677	/// 4. join-time collision in the TARGET subnet (the snapshot's rows — any
678	///    node, any status: a resting holder still owns its name) and locally;
679	/// 5. context fork (one-time copy, copied-then-independent) + perch mint
680	///    (home = target, adapter/blurb carried — the fork starts as the same
681	///    kind of agent) + sync scope seeded [target];
682	/// 6. `--delete-source` LAST: the source vanishes only after the fork is
683	///    whole (perch + tracked mind + address/ready marker).
684	// [impl->REQ-INST-15]
685	fn cmd_fork(src: &str, new_id: &str, subnet: &str, delete_source: bool) -> i32 {
686	    use spt_store::contextstore::ContextStore;
687
688	    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
689	        eprintln!("ERROR: invalid new id: {e}");
690	        return 2;
691	    }
692	    if src == new_id {
693	        eprintln!(
694	            "SAME_ID:{src} — a same-node fork needs a new id (one perch per name per node); \
695	             the same-name fork rides the M5 remote arm"
696	        );
697	        return 1;
698	    }
699	    let subnets = SubnetStore::load();
700	    if subnets.find(subnet).is_none() {
701	        eprintln!("NO_SUBNET:{subnet} — this node is not a member; the fork's home must be held");
702	        return 1;
703	    }
704	    let src_perch = perch::resolve_perch_path(src, ParentHint::Infer);
705	    let Some(src_rec) = spt_store::info::read_info(&src_perch) else {
706	        eprintln!("NO_SOURCE:{src} — not on this node (the cross-node fork arm is M5)");
707	        return 1;
708	    };
709	    let new_perch = perch::resolve_perch_path(new_id, ParentHint::Infer);
710	    if spt_store::info::read_info(&new_perch).is_some() {
711	        eprintln!("TAKEN:{new_id} — a perch already holds this id on this node");
712	        return 1;
713	    }
714	    // Join-time collision in the target (ADR-0006 §1 via the stale-tolerant
715	    // snapshot): ANY holder — any node, any status — owns the name.
716	    let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
717	    if let Some(reg) = regs.get(subnet) {
718	        let holders = reg.instances(new_id);
719	        if !holders.is_empty() {
720	            let nodes: Vec<&str> = holders.iter().map(|i| i.node.as_str()).collect();
721	            eprintln!(
722	                "COLLISION:{new_id} already exists in subnet '{subnet}' on node(s): {} — \
723	                 pick another id (or rename the holder first)",
724	                nodes.join(", ")
725	            );
726	            return 1;
727	        }
728	    }
729
730	    // The one-time mind copy (copied-then-independent, ADR-0010).
731	    let cs = match ContextStore::open_or_init() {
732	        Ok(cs) => cs,
733	        Err(e) => {
734	            eprintln!("CONTEXT_FAIL: {e}");
735	            return 1;
736	        }
737	    };
738	    if let Err(e) = cs.fork_endpoint(src, new_id) {
739	        eprintln!("FORK_FAIL:{src} -> {new_id}: {e}");
740	        return 1;
741	    }
742
743	    // Mint the fork's perch: a NEW identity homed in the target; the adapter
744	    // and resource blurb carry over (the fork starts as the same kind of
745	    // agent — the agent refines from there).
746	    if let Err(e) = std::fs::create_dir_all(&new_perch) {
747	        eprintln!("FORK_FAIL:{new_id}: {e}");
748	        return 1;
749	    }
750	    let mut rec = spt_store::info::InfoJson::new(
751	        new_id,
752	        &now_unix().to_string(),
753	        std::process::id(),
754	        "",
755	        "ready_agent",
756	    );
757	    rec.home_subnet = Some(subnet.to_string());
758	    rec.adapter = src_rec.adapter.clone();
759	    rec.resources = src_rec.resources.clone();
760	    if let Err(e) = spt_store::info::write_info(&new_perch, &rec) {
761	        eprintln!("FORK_FAIL:{new_id}: {e}");
762	        return 1;
763	    }
764	    let mut vis = spt_store::visibility::VisibilityStore::load();
765	    spt_store::home::seed_endpoint_scopes(&mut vis, new_id, subnet);
766	    let _ = vis.save();
767
768	    // --delete-source last: only after the fork is whole.
769	    if delete_source {
770	        if let Err(e) = cs.remove_endpoint(src) {
771	            eprintln!("DELETE_SOURCE_FAIL:{src}: {e} (the fork is complete; re-run to delete)");
772	            return 1;
773	        }
774	        let _ = std::fs::remove_file(perch::resolve_ready_file(src, ParentHint::Infer));
775	        let _ = registry::unregister_address(src, &perch::owlery_dir());
776	        if let Err(e) = std::fs::remove_dir_all(&src_perch) {
777	            eprintln!("DELETE_SOURCE_FAIL:{src}: {e} (the fork is complete; re-run to delete)");
778	            return 1;
779	        }
780	        eprintln!("SOURCE_DELETED:{src}");
781	    }
782	    eprintln!("FORKED:{src} -> {new_id} (home={subnet}, copied-then-independent)");
783	    0
784	}
785
786	/// `spt resources set|show|list` — the subnet resource registry surface
787	/// (D9-4, REQ-INST-14). `set`/`show` operate the LOCAL endpoint's authored
788	/// blurb on its perch record; `list` is the registry **projection** read off
789	/// the daemon's stale-tolerant snapshots (`identity/registry/<subnet>.json`),
790	/// visibility-filtered exactly like resolution — a hidden endpoint never
791	/// appears (ADR-0006 §6, exclusion by construction).
792	// [impl->REQ-INST-14]
793	fn cmd_resources(action: ResourcesCmd) -> i32 {
794	    use spt_net::net::registry::resource_projection;
795	    use spt_store::visibility::VisibilityStore;
796
797	    /// Resolve the operated endpoint: explicit `--id`, else session detect.
798	    fn target(id: Option<String>) -> Result<String, i32> {
799	        match id.or_else(roster::detect_self_id) {
800	            Some(id) => Ok(id),
801	            None => {
802	                eprintln!("NO_SELF: name an endpoint (--id <id> or $OWL_SESSION_ID)");
803	                Err(1)
804	            }
805	        }
806	    }
807
808	    match action {
809	        ResourcesCmd::Set { text, id } => {
810	            let id = match target(id) {
811	                Ok(id) => id,
812	                Err(code) => return code,
813	            };
814	            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
815	            let blurb = (!text.trim().is_empty()).then_some(text.trim());
816	            match spt_store::info::set_resources(&perch_path, blurb) {
817	                Ok(()) => {
818	                    match blurb {
819	                        Some(b) => eprintln!("RESOURCES_SET:{id}: {b}"),
820	                        None => eprintln!("RESOURCES_CLEARED:{id} (node seed advertises, if any)"),
821	                    }
822	                    0
823	                }
824	                Err(e) => {
825	                    eprintln!("RESOURCES_FAIL:{id}: {e}");
826	                    1
827	                }
828	            }
829	        }
830	        ResourcesCmd::Show { id } => {
831	            let id = match target(id) {
832	                Ok(id) => id,
833	                Err(code) => return code,
834	            };
835	            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
836	            match spt_store::info::read_info(&perch_path) {
837	                Some(rec) => {
838	                    match rec.resources {
839	                        Some(b) => println!("{b}"),
840	                        None => println!("(no authored blurb — the node seed advertises, if any)"),
841	                    }
842	                    0
843	                }
844	                None => {
845	                    eprintln!("NO_ENDPOINT:{id}");
846	                    1
847	                }
848	            }
849	        }
850	        ResourcesCmd::List { subnet } => {
851	            let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
852	            let subnets = SubnetStore::load();
853	            let vis = VisibilityStore::load();
854	            let names: Vec<String> = match subnet {
855	                Some(one) => vec![one],
856	                None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
857	            };
858	            let mut shown = 0usize;
859	            for name in names {
860	                let Some(reg) = regs.get(&name) else { continue };
861	                let hide_new = subnets.find(&name).map(|s| s.hide_new_endpoints).unwrap_or(false);
862	                // The same exclusion the resolver enforces — the projection
863	                // can never list what resolution would refuse to route to.
864	                let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
865	                for row in rows {
866	                    println!(
867	                        "{}\t{}\t{}\t{:?}\t{}",
868	                        name,
869	                        row.endpoint_id,
870	                        row.node,
871	                        row.status,
872	                        row.resources.as_deref().unwrap_or("-")
873	                    );
874	                    shown += 1;
875	                }
876	            }
877	            eprintln!("RESOURCES:{shown}");
878	            0
879	        }
880	    }
881	}
882
883	/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
884	/// (REQ-INST-3): apply the event against the local endpoint's durable record
885	/// through the daemon-side transition host (echo gate-armed for the
886	/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
887	/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
888	/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
889	/// the TARGET node's transition host run the edge — exit codes mirror local
890	/// (edge / NO_EDGE = 0, refusal or failure = 1).
891	// [impl->REQ-INST-3]
892	// [impl->REQ-INST-6]
893	fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
894	    if id.contains('@') || id.contains(':') {
895	        let tag = match event {
896	            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
897	            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
898	            // Only the manual verbs route here; the other events are
899	            // daemon-internal feeds with no CLI surface.
900	            _ => {
901	                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
902	                return 1;
903	            }
904	        };
905	        return match crate::wansend::wan_rest(id, tag) {
906	            crate::wansend::WanRestOutcome::Edge { node, detail } => {
907	                eprintln!("{verb}:{id} ({detail}) at {node}");
908	                0
909	            }
910	            crate::wansend::WanRestOutcome::NoEdge { node } => {
911	                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
912	                0
913	            }
914	            crate::wansend::WanRestOutcome::NoReply { node } => {
915	                eprintln!("{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)");
916	                1
917	            }
918	            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
919	                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
920	                1
921	            }
922	            crate::wansend::WanRestOutcome::Ambiguous(a) => {
923	                eprintln!("AMBIGUOUS:{id} — qualify the address: {a}");
924	                1
925	            }
926	            crate::wansend::WanRestOutcome::NotFound => {
927	                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
928	                1
929	            }
930	            crate::wansend::WanRestOutcome::Failed(e) => {
931	                eprintln!("{verb}_FAIL:{id}: {e}");
932	                1
933	            }
934	        };
935	    }
936	    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
937	    match spt_daemon::daemon_rest_event(id, event, knob) {
938	        Ok(Some(report)) => {
939	            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
940	            0
941	        }
942	        Ok(None) => {
943	            // Idempotent UX: the machine reports no edge (already there).
944	            eprintln!("NO_EDGE:{id} (already in the target state)");
945	            0
946	        }
947	        Err(e) => {
948	            eprintln!("{verb}_FAIL:{id}: {e}");
949	            1
950	        }
951	    }
952	}
953
954	/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
955	/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
956	/// → suspended; fires the transition echo-commune; cascades its persistent
957	/// shells offline"). Two legs through existing machinery: (1) soft-stop the
958	/// listener (drop the ready marker — the session-over half `spt suspend`
959	/// does not do); (2) the suspend edge via the daemon-side transition host —
960	/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
961	/// every rest verb.
962	// [impl->REQ-SHELL-2]
963	fn cmd_shutdown(id: Option<String>) -> i32 {
964	    let Some(id) = id.or_else(roster::detect_self_id) else {
965	        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
966	        return 1;
967	    };
968	    if id.contains('@') || id.contains(':') {
969	        // Deliberately NOT lifted with the D5b remote rest arm: an agent
970	        // shuts down *its own* endpoint — remote shutdown is not a surface;
971	        // `spt suspend <id@node>` covers the remote need.
972	        eprintln!("LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely");
973	        return 1;
974	    }
975	    // Leg 1: soft-stop — the listener marker goes first so nothing new is
976	    // accepted while the edge runs.
977	    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
978	    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
979	    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")
980	}
981
982	/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
983	/// serving the broker + seed-control channels until killed; a bind contention
984	/// (another daemon already owns this home) surfaces as a non-zero exit.
985	// [impl->REQ-DAEMON-3]
986	fn cmd_daemon() -> i32 {
987	    match spt_daemon::Daemon::run() {
988	        Ok(()) => 0,
989	        Err(e) => {
990	            eprintln!("DAEMON_EXIT: {e}");
991	            1
992	        }
993	    }
994	}
995
996	/// Read stdin to a string (message body source for send/ring).
997	fn read_stdin() -> String {
998	    let mut body = String::new();
999	    let _ = std::io::stdin().read_to_string(&mut body);
1000	    body
1001	}
1002
1003	/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
1004	/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
1005	/// force qualification (no guessing — the D3c/`show-totp` stance).
1006	// [impl->REQ-NOTIF-2]
1007	fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
1008	    match flag {
1009	        Some(name) => {
1010	            if store.find(&name).is_some() {
1011	                Ok(name)
1012	            } else {
1013	                eprintln!("NO_SUBNET:{name} — this node is not a member");
1014	                Err(1)
1015	            }
1016	        }
1017	        None => match store.subnets.len() {
1018	            0 => {
1019	                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
1020	                Err(1)
1021	            }
1022	            1 => Ok(store.subnets[0].name.clone()),
1023	            _ => {
1024	                eprintln!(
1025	                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
1026	                );
1027	                Err(1)
1028	            }
1029	        },
1030	    }
1031	}
1032
1033	/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
1034	/// into the replicated spool (id minted from this node's EpochSource), then
1035	/// first-fire at the user's most-recently-active visible endpoint. The
1036	/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
1037	/// the subnet rides the envelope's own attribute.
1038	// [impl->REQ-NOTIF-2]
1039	fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
1040	    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
1041	    if issuer.is_empty() {
1042	        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
1043	        return 1;
1044	    }
1045	    let subnets = SubnetStore::load();
1046	    let subnet = match resolve_notif_subnet(&subnets, subnet) {
1047	        Ok(s) => s,
1048	        Err(code) => return code,
1049	    };
1050	    let body = body.unwrap_or_else(read_stdin);
1051	    let body = body.trim();
1052	    if body.is_empty() {
1053	        eprintln!("EMPTY_BODY: nothing to notify");
1054	        return 1;
1055	    }
1056	    let node_hex = match spt_store::nodeid::load_or_create() {
1057	        Ok(identity) => identity.public_key().to_hex(),
1058	        Err(e) => {
1059	            eprintln!("NODE_ID_FAIL: {e}");
1060	            return 1;
1061	        }
1062	    };
1063	    let store = match spt_store::notif::NotifStore::open() {
1064	        Ok(s) => s,
1065	        Err(e) => {
1066	            eprintln!("NOTIF_STORE_FAIL: {e}");
1067	            return 1;
1068	        }
1069	    };
1070	    let mut epochs = spt_store::epoch::EpochSource::load();
1071	    let policy = spt_daemon::NotifSurfacePolicy::load();
1072	    let from_id = format!("{issuer}@{node_hex}");
1073	    match spt_daemon::produce_and_first_fire(
1074	        &store,
1075	        &node_hex,
1076	        &mut epochs,
1077	        &subnet,
1078	        spt_daemon::NOTIF_KIND_AGENT,
1079	        &from_id,
1080	        body,
1081	        &policy,
1082	        &perch::owlery_dir(),
1083	        now_ms(),
1084	    ) {
1085	        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
1086	            println!("NOTIFIED:{} -> {to}", row.notif_id);
1087	            0
1088	        }
1089	        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
1090	            println!(
1091	                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
1092	                row.notif_id
1093	            );
1094	            0
1095	        }
1096	        // REQ-PRES-1: the user is most recently active on another node — the
1097	        // row replicates there and that node surfaces it.
1098	        Ok((row, spt_daemon::FirstFireOutcome::RemoteTarget { to, node })) => {
1099	            println!("NOTIF_ROUTED:{} -> {to}@{node} (surfaces there via replication)", row.notif_id);
1100	            0
1101	        }
1102	        Err(e) => {
1103	            eprintln!("NOTIFY_FAIL: {e}");
1104	            1
1105	        }
1106	    }
1107	}
1108
1109	/// `spt notif list` — the ack surface's read side.
1110	// [impl->REQ-NOTIF-2]
1111	fn cmd_notif_list(subnet: Option<String>) -> i32 {
1112	    let store = match spt_store::notif::NotifStore::open() {
1113	        Ok(s) => s,
1114	        Err(e) => {
1115	            eprintln!("NOTIF_STORE_FAIL: {e}");
1116	            return 1;
1117	        }
1118	    };
1119	    let subnets = SubnetStore::load();
1120	    let names: Vec<String> = match &subnet {
1121	        Some(one) => vec![one.clone()],
1122	        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
1123	    };
1124	    let mut shown = 0usize;
1125	    for name in names {
1126	        for row in store.list(&name).unwrap_or_default() {
1127	            let state = if row.dismissed {
1128	                "dismissed".to_string()
1129	            } else if row.seen.is_empty() {
1130	                "fresh".to_string()
1131	            } else {
1132	                format!("seen:{}", row.seen.len())
1133	            };
1134	            // First body line only — the glanceable view.
1135	            let head = row.body.lines().next().unwrap_or_default();
1136	            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);
1137	            shown += 1;
1138	        }
1139	    }
1140	    eprintln!("NOTIFS:{shown}");
1141	    0
1142	}
1143
1144	/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
1145	/// subnet-wide via the D8-2 feed.
1146	// [impl->REQ-NOTIF-2]
1147	fn cmd_notif_dismiss(notif_id: &str) -> i32 {
1148	    let store = match spt_store::notif::NotifStore::open() {
1149	        Ok(s) => s,
1150	        Err(e) => {
1151	            eprintln!("NOTIF_STORE_FAIL: {e}");
1152	            return 1;
1153	        }
1154	    };
1155	    match store.dismiss(notif_id) {
1156	        Ok(true) => {
1157	            println!("DISMISSED:{notif_id}");
1158	            0
1159	        }
1160	        Ok(false) => {
1161	            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
1162	            1
1163	        }
1164	        Err(e) => {
1165	            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
1166	            1
1167	        }
1168	    }
1169	}
1170
1171	/// `spt update apply` — the explicit ack named by the update-consent notif.
1172	// [impl->REQ-UPD-4]
1173	// [impl->REQ-UPD-3]
1174	fn cmd_update_apply() -> i32 {
1175	    if let Err(e) = spt_daemon::ensure_running() {
1176	        eprintln!("DAEMON_START_FAIL: {e}");
1177	        return 1;
1178	    }
1179	    let exe = match std::env::current_exe() {
1180	        Ok(p) => p,
1181	        Err(e) => {
1182	            eprintln!("CURRENT_EXE_FAIL: {e}");
1183	            return 1;
1184	        }
1185	    };
1186	    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
1187	    let keys = perch::identity_dir().join("release-keys.json");
1188	    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
1189	        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
1190	            println!("NO_UPDATE");
1191	            0
1192	        }
1193	        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
1194	            println!("APPLIED:{version}");
1195	            0
1196	        }
1197	        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
1198	            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
1199	            1
1200	        }
1201	        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
1202	            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
1203	            1
1204	        }
1205	        Err(e) => {
1206	            eprintln!("UPDATE_APPLY_FAIL:{e}");
1207	            1
1208	        }
1209	    }
1210	}
1211
1212	/// Epoch milliseconds (the notif surfacing clock).
1213	fn now_ms() -> u64 {
1214	    std::time::SystemTime::now()
1215	        .duration_since(std::time::UNIX_EPOCH)
1216	        .map(|d| d.as_millis() as u64)
1217	        .unwrap_or(0)
1218	}
1219
1220	/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
1221	fn resolve_from(from: Option<String>) -> String {
1222	    from.or_else(roster::detect_self_id).unwrap_or_default()
1223	}
1224
1225	fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
1226	    let is_reply = reply_to.is_some();
1227	    // --reply-to provides the target when no positional target is given.
1228	    let target = match target.or_else(|| reply_to.clone()) {
1229	        Some(t) => t,
1230	        None => {
1231	            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
1232	            return 2;
1233	        }
1234	    };
1235	    let from = resolve_from(from);
1236	    let body = read_stdin();
1237	    let owlery = perch::owlery_dir();
1238
1239	    let outcome = if deferred {
1240	        deliver::send_deferred(&target, &from, &body)
1241	    } else {
1242	        deliver::send(&target, &from, &body, &owlery)
1243	    };
1244
1245	    match outcome {
1246	        SendOutcome::Sent => {
1247	            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
1248	            0
1249	        }
1250	        SendOutcome::Queued => {
1251	            let label = if deferred {
1252	                "DEFERRED"
1253	            } else if is_reply {
1254	                "REPLIED(QUEUED)"
1255	            } else {
1256	                "QUEUED"
1257	            };
1258	            eprintln!("{}:{}", label, target);
1259	            0
1260	        }
1261	        SendOutcome::NoPerch => {
1262	            if deferred {
1263	                // The wire record carries no deferred class (v1) — a remote
1264	                // deferred send is a documented seam, not a silent reclass.
1265	                eprintln!("NO_PERCH:{} is not listening (deferred stays local-only)", target);

//! CLI surface + dispatch for the `spt` binary (REQ-MSG-2).
//!
//! Stable arg shapes and exit codes are the contract here. Each subcommand is a
//! thin shim: read I/O, call the matching `spt-msg` entry point, render the
//! structured outcome, return an exit code. No messaging logic lives in this
//! layer.
//!
//! Exit codes: `0` success (delivered, queued, replied, listed) · `1` runtime
//! refusal (no perch, empty body, unidentifiable self) · `2` usage error
//! (clap, for unknown/missing args).
// [impl->REQ-MSG-2]

use std::io::{Read, Write};
use std::path::PathBuf;
use std::time::Duration;

use clap::{Parser, Subcommand};
use spt_msg::deliver::{self, SendOutcome};
use spt_msg::ready::ReadyAgent;
use spt_msg::ring::{self, RingOutcome};
use spt_net::net::pairing::totp::TotpSeed;
use spt_store::perch::{self, ParentHint};
use spt_store::registry;
use spt_store::subnet::{SubnetError, SubnetStore};

use crate::elevation::{self, Elevation, Gate};
use crate::roster;

/// Product tag embedded in the `otpauth://` provisioning URI (the authenticator-app
/// issuer label). Stable user-facing string.
const ISSUER: &str = "spt";

/// `spt pair show-totp` refused the fetch for lack of OS elevation. Distinct from the
/// crate-wide codes (`0` ok · `1` runtime refusal · `2` usage): `3` says the fetch was
/// *refused, not failed* — re-run elevated, or read the code from your authenticator app.
const EXIT_NOT_ELEVATED: i32 = 3;

#[derive(Parser)]
#[command(
    name = "spt",
    version,
    about = "spt — a harness-independent core for an agent ecosystem: inter-agent \
             messaging, live-agent lifecycle, terminal hosting, P2P networking, \
             seamless self-update. Docs: https://sabermage.github.io/spt-releases",
    long_about = None
)]
struct Cli {
    #[command(subcommand)]
    cmd: Cmd,
}

#[derive(Subcommand)]
enum Cmd {
    /// Send a message (body read from stdin); fire-and-forget.
    Send {
        /// Target perch id. May be omitted when --reply-to is given.
        target: Option<String>,
        /// Sender id written into __REPLY_TO__ (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Reply to this sender; sets the target and labels output REPLIED.
        #[arg(long)]
        reply_to: Option<String>,
        /// Spool only (deferred / hook channel) — no live wake.
        #[arg(long)]
        deferred: bool,
    },
    /// Send and block for a reply (body read from stdin).
    ///
    /// The reply body is printed to stdout; gives up after --timeout seconds.
    Ring {
        /// Target perch id.
        target: String,
        /// Sender id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
        /// Seconds to wait for a reply before giving up.
        #[arg(long, default_value = "60")]
        timeout: u64,
    },
    /// Become reachable: register the perch and listen (blocks).
    Ready {
        /// This agent's perch id.
        id: String,
        /// Home subnet for a NEW endpoint (required on a multi-subnet
        /// node — home is assigned at creation, never guessed).
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Receive messages: drain backlog, then listen.
    ///
    /// Each received message prints to stdout.
    Poll {
        /// This agent's perch id.
        id: String,
        /// Run a single drain+receive cycle, then exit (legacy one-shot).
        #[arg(long)]
        once: bool,
        /// Home subnet for a NEW endpoint (see `ready`).
        #[arg(long)]
        subnet: Option<String>,
    },
    /// List this node's perches (id, state, address, ready, alive).
    List,
    /// Soft-stop a perch (spool preserved).
    ///
    /// Removes the ready marker and unregisters the perch; the spool is
    /// preserved.
    Stop {
        /// Perch id to stop.
        id: String,
    },
    /// Print this session's own perch id.
    ///
    /// Resolved from $OWL_SESSION_ID / $SPT_AGENT_ID.
    Whoami,
    /// Subnet pairing: show this node's current pairing code.
    ///
    /// Also mints new subnets (`show-totp --create-new`). Pairing is how
    /// machines join a private subnet.
    Pair {
        #[command(subcommand)]
        action: PairCmd,
    },
    /// Show a hosted session's live activity buffer (PTY digest).
    ///
    /// The at-a-glance "what is this agent doing now" view. Pulls a
    /// snapshot, or `--follow`s the delta-stream. Local endpoints only.
    Digest {
        /// The (local) endpoint id to read.
        id: String,
        /// Stream live changes instead of a one-shot snapshot (Ctrl-C to stop).
        #[arg(long)]
        follow: bool,
        /// Emit structured JSON instead of the human-glanceable render.
        #[arg(long)]
        json: bool,
    },
    /// Rename an endpoint's logical id across its on-disk state.
    ///
    /// Rippled everywhere the id appears: the endpoint's perch dir, its
    /// nested companion/worker perches, and every record naming it.
    /// Refuses while the perch is live (stop it first).
    Rename {
        /// The endpoint's current (bare) id.
        old_id: String,
        /// The new (bare) id — charset-validated; `:`/`@` are reserved.
        new_id: String,
    },
    /// Endpoint access whitelist for unsolicited off-node inbound.
    ///
    /// Controls which origin nodes may send an endpoint unsolicited
    /// off-node inbound. Absent entry = open; `allow` flips the endpoint
    /// to restricted; revoking the last node leaves it locked down;
    /// `open` deletes the restriction.
    Access {
        #[command(subcommand)]
        action: AccessCmd,
    },
    /// Consent grant store: gated capabilities held on this node.
    ///
    /// Default-deny (the access whitelist's opposite polarity). An
    /// ungranted ask escalates interactively; `add` is the durable
    /// allow-always answer.
    Grant {
        #[command(subcommand)]
        action: GrantCmd,
    },
    /// Adapter registration: what this node can drive/launch.
    ///
    /// The node-local registered set (one command for harness and shell
    /// adapters). Feeds creation-time adapter selection, shell discovery,
    /// and the self-update ripple.
    Adapter {
        #[command(subcommand)]
        action: AdapterCmd,
    },
    /// Shell instances: mint, list, drive, tear down owned surfaces.
    ///
    /// The driven surfaces this agent owns. `spawn` MINTS a new instance
    /// identity (`<adapter>-<n>`) — it is not the online switch; bringing
    /// an existing offline instance back is `relink` / `persistent` /
    /// wake.
    Shell {
        #[command(subcommand)]
        action: ShellCmd,
    },
    /// Issue a subnet-wide user notification.
    ///
    /// Produced into the replicated notification spool and first-fired at
    /// the user's most-recently-active endpoint in that subnet. Body from
    /// the trailing arg, or stdin when omitted.
    Notify {
        /// Notification body (read from stdin when omitted).
        body: Option<String>,
        /// Which subnet to notify. Required only when the node holds several
        /// (never guessed).
        #[arg(long)]
        subnet: Option<String>,
        /// Issuer endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        from: Option<String>,
    },
    /// Inspect and acknowledge notifications.
    ///
    /// Dismissal is the explicit ack — it latches and replicates
    /// subnet-wide.
    Notif {
        #[command(subcommand)]
        action: NotifCmd,
    },
    /// Self-update operations.
    ///
    /// `apply` is the explicit ack named by the update-consent
    /// notification; it re-verifies the staged release before touching
    /// the live daemon.
    Update {
        #[command(subcommand)]
        action: UpdateCmd,
    },
    /// Fork an endpoint into another subnet as a NEW identity.
    ///
    /// Home subnets are immutable — fork is the cross-subnet move, never
    /// a re-home. Seeds the fork with a one-time copy of the source's
    /// mind (live + project tiers); the two diverge immediately (no
    /// ongoing sync). The source is untouched unless --delete-source.
    /// Same-node only today (one node holds one perch per name, so a
    /// local fork needs a new id).
    Fork {
        /// The source endpoint (must exist on this node).
        src: String,
        /// The fork's id (must differ from the source on the same node).
        new_id: String,
        /// The fork's home subnet — the target (must be a member).
        #[arg(long)]
        subnet: String,
        /// Delete the source endpoint (perch + tracked mind) after the copy.
        #[arg(long)]
        delete_source: bool,
    },
    /// The subnet resource registry — an agent yellow-pages.
    ///
    /// Per-endpoint free-text service blurbs. `set` authors your own,
    /// `show` reads a local endpoint's, `list` projects the registry view
    /// over visible rows.
    Resources {
        #[command(subcommand)]
        action: ResourcesCmd,
    },
    /// Rest an endpoint cold (the suspend edge).
    ///
    /// The resting state machine's suspend edge. From dormant — or
    /// straight from active, in which case the final
    /// context save still fires first. Accepts a qualified `id@node` to
    /// suspend an instance on a paired peer.
    Suspend {
        /// The endpoint id (qualified `id@node` reaches a paired peer).
        id: String,
    },
    /// Wake a resting endpoint in place.
    ///
    /// Re-activates the existing seat (state's already there — no fresh
    /// spawn), resurfaces undismissed notifications, and requests an
    /// immediate context freshness pull from trusted peers. Accepts a
    /// qualified `id@node` for an instance on a paired peer.
    Wake {
        /// The endpoint id (qualified `id@node` reaches a paired peer).
        id: String,
    },
    /// Gracefully shut down an agent's own endpoint.
    ///
    /// Soft-stops the listener, then the suspend edge — the final context
    /// save fires and persistent shells cascade offline with it.
    Shutdown {
        /// The endpoint id (defaults to the session's own perch).
        id: Option<String>,
    },
    /// Harness-contract inbound surface (hook entry points).
    ///
    /// The entry points a harness's hooks fire to keep spt-core's on-disk
    /// state in sync.
    Api(crate::api::ApiArgs),
    /// Run the per-machine daemon. Internal: any `spt` invocation that needs
    /// it auto-starts it; operators rarely run it directly.
    #[command(hide = true)]
    Daemon,
}

/// `spt access <action>` subcommands (REQ-SEC-1).
#[derive(Subcommand)]
enum AccessCmd {
    /// Whitelist a node for an endpoint (creates the restriction if absent).
    Allow {
        /// The endpoint to restrict.
        endpoint: String,
        /// The origin node's pubkey hex (as shown by `spt list` / pairing).
        node: String,
    },
    /// Remove a node from an endpoint's whitelist. Never widens: revoking the
    /// last node leaves the endpoint locked down (all unsolicited refused).
    Revoke {
        endpoint: String,
        node: String,
    },
    /// Delete an endpoint's restriction entirely — back to default-open.
    Open {
        endpoint: String,
    },
    /// List restrictions (all endpoints, or one).
    List {
        endpoint: Option<String>,
    },
}

/// `spt adapter <action>` subcommands (REQ-INSTALL-4).
#[derive(Subcommand)]
enum AdapterCmd {
    /// Register an adapter from a local path (a dir holding `manifest.toml`,
    /// or the manifest file itself) or from GitHub (`--github user/repo`,
    /// cloned under `adapters/_github/`). Manifest-first: an invalid manifest
    /// registers nothing. Install is the first update — the declared
    /// `[update]` avenue is conducted once after recording.
    Add {
        /// Local manifest source (omit when using --github).
        path: Option<String>,
        /// GitHub source `user/repo` — manifest-first, then install via the
        /// declared `[update]` avenue.
        #[arg(long)]
        github: Option<String>,
    },
    /// Soft-deregister: hidden from new-creation/discovery; existing and live
    /// instances keep running. The manifest's optional `uninstall` template
    /// is conducted only with --force until quiesce detection lands.
    Remove {
        name: String,
        /// Conduct the manifest `uninstall` template now, without waiting for
        /// quiesce.
        #[arg(long)]
        force: bool,
    },
    /// List registered adapters (active and soft-deregistered).
    List,
}

/// `spt grant <action>` subcommands (REQ-CONSENT-1).
#[derive(Subcommand)]
enum GrantCmd {
    /// Record a grant: `agent` may exercise `capability` on this node.
    /// Refuses the reserved deferred capability ids (remote-exec,
    /// instantiate-anywhere) — their gate refuses unconditionally, so a row
    /// would only be a footgun-in-waiting.
    Add {
        /// The gated capability id (e.g. spawn-shell, owner-shutdown).
        capability: String,
        /// The subject agent (endpoint id).
        agent: String,
        /// Narrower target within the node (e.g. the shell-adapter name for
        /// spawn-shell). Omitted = the node-wide row; the two never match
        /// each other.
        #[arg(long)]
        qualifier: Option<String>,
    },
    /// Remove the exact grant row. Never widens or narrows neighbours: only
    /// the named (capability, agent, qualifier) tuple goes.
    Revoke {
        capability: String,
        agent: String,
        #[arg(long)]
        qualifier: Option<String>,
    },
    /// List grant rows (all, or one agent's).
    List {
        agent: Option<String>,
    },
}

/// `spt shell <action>` subcommands (M5-D3, REQ-EP-5). Owner-scoped: every
/// action operates on the invoking agent's own instances (`--owner`
/// overrides; auto-detected from the session otherwise).
#[derive(Subcommand)]
enum ShellCmd {
    /// Mint a NEW shell instance of a registered `kind="shell"` adapter:
    /// canonical id `<adapter>-<n>` (smallest free n; teardown frees slots),
    /// starting offline (the launch + bind handshake brings it online).
    Spawn {
        /// The providing shell adapter (must be registered + active).
        adapter: String,
        /// Optional owner-unique friendly label (interchangeable with the
        /// canonical id for addressing; never obscures the adapter).
        #[arg(long)]
        alias: Option<String>,
        /// Owning endpoint id (auto-detected from session if omitted).
        #[arg(long)]
        owner: Option<String>,
    },
    /// List this owner's instances: canonical id, alias, adapter, status.
    List {
        #[arg(long)]
        owner: Option<String>,
    },
    /// Destroy an instance (perch removed; mint slot + alias freed).
    Teardown {
        /// Canonical id or alias.
        shell_ref: String,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Set/replace an instance's alias (owner-unique).
    Rename {
        /// Canonical id or current alias.
        shell_ref: String,
        /// The new alias.
        alias: String,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Drive the shell with a typed capability command (the durable command
    /// channel): the op + positional args are vocabulary-checked against the
    /// manifest's `[shell.capabilities]`, spooled on the shell perch, and
    /// drained by the manifest's `command_receipt` mode (relay / stdin).
    Cmd {
        /// Canonical id or alias.
        shell_ref: String,
        /// The capability op + args (vocabulary-checked against the manifest).
        #[arg(trailing_var_arg = true)]
        op: Vec<String>,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Send a text and/or file payload down the durable 2-way text+file
    /// channel (agent→shell; the shell answers via ordinary `spt send`).
    /// File transfers are progress-queryable by xfer id.
    Send {
        /// Canonical id or alias.
        shell_ref: String,
        /// The text payload.
        text: Option<String>,
        /// A file to transfer to the shell.
        #[arg(long)]
        file: Option<PathBuf>,
        #[arg(long)]
        owner: Option<String>,
    },
    /// Bring an existing offline (persistent) instance back online: re-spawns
    /// the binary with a fresh link token; the perch onlines at its bind.
    Relink {
        /// Canonical id or alias.
        shell_ref: String,
        #[arg(long)]
        owner: Option<String>,
    },
}

/// `spt resources <action>` subcommands (D9-4, REQ-INST-14).
#[derive(Subcommand)]
enum ResourcesCmd {
    /// Author this endpoint's blurb (the agent refines its own at runtime;
    /// an empty string clears it back to the node-config seed).
    Set {
        /// The blurb text ("" clears).
        text: String,
        /// Which local endpoint (auto-detected from the session if omitted).
        #[arg(long)]
        id: Option<String>,
    },
    /// Show a local endpoint's authored blurb.
    Show {
        /// The local endpoint id (auto-detected if omitted).
        id: Option<String>,
    },
    /// The subnet resource-registry projection: `(id, node, status, blurb)`
    /// over VISIBLE rows only (hidden endpoints never appear — discovery
    /// leaks nothing a viewer couldn't reach).
    List {
        /// Limit to one subnet (all member subnets otherwise).
        #[arg(long)]
        subnet: Option<String>,
    },
}

/// `spt notif <action>` subcommands (ADR-0007).
#[derive(Subcommand)]
enum NotifCmd {
    /// List notifications (all member subnets, or one).
    List {
        /// Limit to one subnet.
        #[arg(long)]
        subnet: Option<String>,
    },
    /// Dismiss (ack) a notification by id — latches, replicates subnet-wide.
    Dismiss {
        /// The notif id (as shown by `spt notif list`).
        notif_id: String,
    },
}

/// `spt update <action>` subcommands (REQ-UPD-4).
#[derive(Subcommand)]
enum UpdateCmd {
    /// Apply the staged, verified self-update now.
    Apply,
}

/// `spt pair <action>` subcommands.
#[derive(Subcommand)]
enum PairCmd {
    /// Show a subnet's current 6-digit pairing code + `otpauth://` URI —
    /// gated behind OS elevation (or read the code from your authenticator
    /// app). With no flag the node's sole subnet is used; if it holds
    /// several, `--subnet <name>` is required (never guessed).
    /// `--create-new <name>` mints a fresh subnet first.
    ShowTotp {
        /// Which subnet's code to show. Required only when the node holds several.
        #[arg(long)]
        subnet: Option<String>,
        /// Mint a fresh subnet with this name (this node becomes the sole seed-holder),
        /// then show its code. Mutually exclusive with `--subnet`.
        #[arg(long, value_name = "NAME", conflicts_with = "subnet")]
        create_new: Option<String>,
    },
}

/// Top-level commands an agent is meant to run from its own session; every
/// other visible command lands in the operator-facing "User commands" group.
/// Grouping is render-time only (clap has no native subcommand help
/// headings); `help_groups_cover_every_command` keeps this set honest.
const AGENT_COMMANDS: &[&str] =
    &["api", "notify", "poll", "ready", "resources", "ring", "send", "shell", "shutdown", "whoami"];

/// Build the top-level help template: the default clap layout, with the flat
/// `Commands:` block replaced by two alphabetized groups (user-facing first,
/// agent-facing second). Entries are generated from the derived `Command`
/// itself, so abouts can never drift from the list.
fn grouped_help_template(cmd: &mut clap::Command) -> String {
    cmd.build(); // materialize the implicit `help` subcommand
    let (mut user, mut agent) = (Vec::new(), Vec::new());
    for sc in cmd.get_subcommands() {
        if sc.is_hide_set() {
            continue;
        }
        let name = sc.get_name().to_string();
        let about = sc.get_about().map(ToString::to_string).unwrap_or_default();
        let group = if AGENT_COMMANDS.contains(&name.as_str()) { &mut agent } else { &mut user };
        group.push((name, about));
    }
    user.sort();
    agent.sort();
    // One aligned name column across both groups; abouts are one-liners by
    // contract (see the unit test), so no entry ever wraps.
    let pad = user.iter().chain(&agent).map(|(n, _)| n.len()).max().unwrap_or(0);
    let render = |rows: &[(String, String)]| {
        rows.iter().map(|(n, a)| format!("  {n:<pad$}  {a}\n")).collect::<String>()
    };
    format!(
        "{{before-help}}{{about-with-newline}}\n{{usage-heading}} {{usage}}\n\n\
         User commands:\n{}\n\
         Agent commands:\n{}\n\
         Options:\n{{options}}{{after-help}}",
        render(&user),
        render(&agent),
    )
}

/// Parse args and dispatch. Returns the process exit code.
pub fn run() -> i32 {
    use clap::{CommandFactory, FromArgMatches};
    let mut cmd = Cli::command();
    let template = grouped_help_template(&mut cmd);
    let matches = cmd.help_template(template).get_matches();
    let cli = match Cli::from_arg_matches(&matches) {
        Ok(cli) => cli,
        Err(e) => e.exit(),
    };
    match cli.cmd {
        Cmd::Send { target, from, reply_to, deferred } => cmd_send(target, from, reply_to, deferred),
        Cmd::Ring { target, from, timeout } => cmd_ring(target, from, timeout),
        Cmd::Ready { id, subnet } => run_listen(&id, false, subnet.as_deref()),
        Cmd::Poll { id, once, subnet } => run_listen(&id, once, subnet.as_deref()),
        Cmd::List => cmd_list(),
        Cmd::Stop { id } => cmd_stop(&id),
        Cmd::Whoami => cmd_whoami(),
        Cmd::Pair { action } => match action {
            PairCmd::ShowTotp { subnet, create_new } => cmd_pair_show_totp(subnet, create_new),
        },
        Cmd::Access { action } => cmd_access(action),
        Cmd::Grant { action } => cmd_grant(action),
        Cmd::Adapter { action } => cmd_adapter(action),
        Cmd::Shell { action } => cmd_shell(action),
        Cmd::Notify { body, subnet, from } => cmd_notify(body, subnet, from),
        Cmd::Notif { action } => match action {
            NotifCmd::List { subnet } => cmd_notif_list(subnet),
            NotifCmd::Dismiss { notif_id } => cmd_notif_dismiss(&notif_id),
        },
        Cmd::Update { action } => match action {
            UpdateCmd::Apply => cmd_update_apply(),
        },
        Cmd::Digest { id, follow, json } => cmd_digest(&id, follow, json),
        Cmd::Fork { src, new_id, subnet, delete_source } => {
            cmd_fork(&src, &new_id, &subnet, delete_source)
        }
        Cmd::Resources { action } => cmd_resources(action),
        Cmd::Suspend { id } => cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SUSPENDED"),
        Cmd::Wake { id } => cmd_rest(&id, spt_daemon::RestEvent::Wake, "WOKE"),
        Cmd::Shutdown { id } => cmd_shutdown(id),
        Cmd::Rename { old_id, new_id } => cmd_rename(&old_id, &new_id),
        Cmd::Api(args) => crate::api::run(args),
        Cmd::Daemon => cmd_daemon(),
    }
}

/// `spt digest <id>` — show a daemon-hosted session's live activity buffer
/// (REQ-TERM-4 / ADR-0008). Ensures the daemon is up, then pulls a snapshot or
/// follows the delta-stream over the digest-control channel. Local addressing
/// only: a qualified `[subnet:]id@node` form is refused (cross-node is M4).
// [impl->REQ-TERM-4]
fn cmd_digest(id: &str, follow: bool, json: bool) -> i32 {
    // Local addressing only — the qualified cross-node form is M4 (spt-net).
    if id.contains('@') || id.contains(':') {
        eprintln!("CROSS_NODE_M4:{id} — qualified [subnet:]id@node addressing is M4, not yet supported");
        return 1;
    }
    // The digest lives in the daemon; bring it up if absent (best-effort).
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_WARN: {e}");
    }
    let name = spt_daemon::digest_socket_name();

    if follow {
        let res = spt_daemon::follow(&name, id, 0, |up| {
            if json {
                println!("{}", spt_daemon::update_to_json(up));
            } else {
                print!("{}", spt_daemon::render_update(up));
            }
            let _ = std::io::stdout().flush();
            true // stream until the connection ends / the user interrupts
        });
        return match res {
            Ok(()) => 0,
            Err(e) => {
                eprintln!("DIGEST_FOLLOW_FAIL:{id}: {e}");
                1
            }
        };
    }

    match spt_daemon::pull_snapshot(&name, id) {
        Ok(Some((digest, version))) => {
            if json {
                println!("{}", spt_daemon::digest_to_json(&digest));
            } else {
                print!("{}", spt_daemon::render_digest(&digest));
                let _ = std::io::stdout().flush();
            }
            eprintln!("DIGEST:{id} version={version}");
            0
        }
        Ok(None) => {
            eprintln!("NO_DIGEST:{id} has no live activity buffer (not a daemon-hosted session?)");
            1
        }
        Err(e) => {
            eprintln!("DIGEST_FAIL:{id}: {e}");
            1
        }
    }
}

/// `spt fork <src> <new_id> --subnet <target>` — the cross-subnet move
/// (REQ-INST-15, ADR-0010). Order of operations is refusal-first: every gate
/// runs before anything mutates, so a refused fork leaves no half-fork.
///
/// 1. charset + same-id guard (a node holds one perch per name — the
///    same-name fork is the M5 remote arm);
/// 2. target membership (the fork's home must be a subnet we hold);
/// 3. source exists locally (the remote-source arm is M5);
/// 4. join-time collision in the TARGET subnet (the snapshot's rows — any
///    node, any status: a resting holder still owns its name) and locally;
/// 5. context fork (one-time copy, copied-then-independent) + perch mint
///    (home = target, adapter/blurb carried — the fork starts as the same
///    kind of agent) + sync scope seeded [target];
/// 6. `--delete-source` LAST: the source vanishes only after the fork is
///    whole (perch + tracked mind + address/ready marker).
// [impl->REQ-INST-15]
fn cmd_fork(src: &str, new_id: &str, subnet: &str, delete_source: bool) -> i32 {
    use spt_store::contextstore::ContextStore;

    if let Err(e) = spt_proto::id::validate_endpoint_id(new_id) {
        eprintln!("ERROR: invalid new id: {e}");
        return 2;
    }
    if src == new_id {
        eprintln!(
            "SAME_ID:{src} — a same-node fork needs a new id (one perch per name per node); \
             the same-name fork rides the M5 remote arm"
        );
        return 1;
    }
    let subnets = SubnetStore::load();
    if subnets.find(subnet).is_none() {
        eprintln!("NO_SUBNET:{subnet} — this node is not a member; the fork's home must be held");
        return 1;
    }
    let src_perch = perch::resolve_perch_path(src, ParentHint::Infer);
    let Some(src_rec) = spt_store::info::read_info(&src_perch) else {
        eprintln!("NO_SOURCE:{src} — not on this node (the cross-node fork arm is M5)");
        return 1;
    };
    let new_perch = perch::resolve_perch_path(new_id, ParentHint::Infer);
    if spt_store::info::read_info(&new_perch).is_some() {
        eprintln!("TAKEN:{new_id} — a perch already holds this id on this node");
        return 1;
    }
    // Join-time collision in the target (ADR-0006 §1 via the stale-tolerant
    // snapshot): ANY holder — any node, any status — owns the name.
    let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
    if let Some(reg) = regs.get(subnet) {
        let holders = reg.instances(new_id);
        if !holders.is_empty() {
            let nodes: Vec<&str> = holders.iter().map(|i| i.node.as_str()).collect();
            eprintln!(
                "COLLISION:{new_id} already exists in subnet '{subnet}' on node(s): {} — \
                 pick another id (or rename the holder first)",
                nodes.join(", ")
            );
            return 1;
        }
    }

    // The one-time mind copy (copied-then-independent, ADR-0010).
    let cs = match ContextStore::open_or_init() {
        Ok(cs) => cs,
        Err(e) => {
            eprintln!("CONTEXT_FAIL: {e}");
            return 1;
        }
    };
    if let Err(e) = cs.fork_endpoint(src, new_id) {
        eprintln!("FORK_FAIL:{src} -> {new_id}: {e}");
        return 1;
    }

    // Mint the fork's perch: a NEW identity homed in the target; the adapter
    // and resource blurb carry over (the fork starts as the same kind of
    // agent — the agent refines from there).
    if let Err(e) = std::fs::create_dir_all(&new_perch) {
        eprintln!("FORK_FAIL:{new_id}: {e}");
        return 1;
    }
    let mut rec = spt_store::info::InfoJson::new(
        new_id,
        &now_unix().to_string(),
        std::process::id(),
        "",
        "ready_agent",
    );
    rec.home_subnet = Some(subnet.to_string());
    rec.adapter = src_rec.adapter.clone();
    rec.resources = src_rec.resources.clone();
    if let Err(e) = spt_store::info::write_info(&new_perch, &rec) {
        eprintln!("FORK_FAIL:{new_id}: {e}");
        return 1;
    }
    let mut vis = spt_store::visibility::VisibilityStore::load();
    spt_store::home::seed_endpoint_scopes(&mut vis, new_id, subnet);
    let _ = vis.save();

    // --delete-source last: only after the fork is whole.
    if delete_source {
        if let Err(e) = cs.remove_endpoint(src) {
            eprintln!("DELETE_SOURCE_FAIL:{src}: {e} (the fork is complete; re-run to delete)");
            return 1;
        }
        let _ = std::fs::remove_file(perch::resolve_ready_file(src, ParentHint::Infer));
        let _ = registry::unregister_address(src, &perch::owlery_dir());
        if let Err(e) = std::fs::remove_dir_all(&src_perch) {
            eprintln!("DELETE_SOURCE_FAIL:{src}: {e} (the fork is complete; re-run to delete)");
            return 1;
        }
        eprintln!("SOURCE_DELETED:{src}");
    }
    eprintln!("FORKED:{src} -> {new_id} (home={subnet}, copied-then-independent)");
    0
}

/// `spt resources set|show|list` — the subnet resource registry surface
/// (D9-4, REQ-INST-14). `set`/`show` operate the LOCAL endpoint's authored
/// blurb on its perch record; `list` is the registry **projection** read off
/// the daemon's stale-tolerant snapshots (`identity/registry/<subnet>.json`),
/// visibility-filtered exactly like resolution — a hidden endpoint never
/// appears (ADR-0006 §6, exclusion by construction).
// [impl->REQ-INST-14]
fn cmd_resources(action: ResourcesCmd) -> i32 {
    use spt_net::net::registry::resource_projection;
    use spt_store::visibility::VisibilityStore;

    /// Resolve the operated endpoint: explicit `--id`, else session detect.
    fn target(id: Option<String>) -> Result<String, i32> {
        match id.or_else(roster::detect_self_id) {
            Some(id) => Ok(id),
            None => {
                eprintln!("NO_SELF: name an endpoint (--id <id> or $OWL_SESSION_ID)");
                Err(1)
            }
        }
    }

    match action {
        ResourcesCmd::Set { text, id } => {
            let id = match target(id) {
                Ok(id) => id,
                Err(code) => return code,
            };
            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
            let blurb = (!text.trim().is_empty()).then_some(text.trim());
            match spt_store::info::set_resources(&perch_path, blurb) {
                Ok(()) => {
                    match blurb {
                        Some(b) => eprintln!("RESOURCES_SET:{id}: {b}"),
                        None => eprintln!("RESOURCES_CLEARED:{id} (node seed advertises, if any)"),
                    }
                    0
                }
                Err(e) => {
                    eprintln!("RESOURCES_FAIL:{id}: {e}");
                    1
                }
            }
        }
        ResourcesCmd::Show { id } => {
            let id = match target(id) {
                Ok(id) => id,
                Err(code) => return code,
            };
            let perch_path = perch::resolve_perch_path(&id, ParentHint::Infer);
            match spt_store::info::read_info(&perch_path) {
                Some(rec) => {
                    match rec.resources {
                        Some(b) => println!("{b}"),
                        None => println!("(no authored blurb — the node seed advertises, if any)"),
                    }
                    0
                }
                None => {
                    eprintln!("NO_ENDPOINT:{id}");
                    1
                }
            }
        }
        ResourcesCmd::List { subnet } => {
            let regs = crate::wansend::load_snapshots(&perch::identity_dir().join("registry"));
            let subnets = SubnetStore::load();
            let vis = VisibilityStore::load();
            let names: Vec<String> = match subnet {
                Some(one) => vec![one],
                None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
            };
            let mut shown = 0usize;
            for name in names {
                let Some(reg) = regs.get(&name) else { continue };
                let hide_new = subnets.find(&name).map(|s| s.hide_new_endpoints).unwrap_or(false);
                // The same exclusion the resolver enforces — the projection
                // can never list what resolution would refuse to route to.
                let rows = resource_projection(reg, |id| vis.hidden(id, &name, hide_new));
                for row in rows {
                    println!(
                        "{}\t{}\t{}\t{:?}\t{}",
                        name,
                        row.endpoint_id,
                        row.node,
                        row.status,
                        row.resources.as_deref().unwrap_or("-")
                    );
                    shown += 1;
                }
            }
            eprintln!("RESOURCES:{shown}");
            0
        }
    }
}

/// `spt suspend <id>` / `spt wake <id>` — the manual resting-state edges
/// (REQ-INST-3): apply the event against the local endpoint's durable record
/// through the daemon-side transition host (echo gate-armed for the
/// endpoint's own pulse loop; wake fires the boundary resurface + freshness
/// pull). A qualified `[subnet:]id@node` rides the remote rest arm (M5-D5b,
/// REQ-INST-6): resolve the peer from the registry snapshots, dial, and let
/// the TARGET node's transition host run the edge — exit codes mirror local
/// (edge / NO_EDGE = 0, refusal or failure = 1).
// [impl->REQ-INST-3]
// [impl->REQ-INST-6]
fn cmd_rest(id: &str, event: spt_daemon::RestEvent, verb: &str) -> i32 {
    if id.contains('@') || id.contains(':') {
        let tag = match event {
            spt_daemon::RestEvent::Suspend => spt_net::net::rest::REST_EVENT_SUSPEND,
            spt_daemon::RestEvent::Wake => spt_net::net::rest::REST_EVENT_WAKE,
            // Only the manual verbs route here; the other events are
            // daemon-internal feeds with no CLI surface.
            _ => {
                eprintln!("{verb}_FAIL:{id}: event has no remote arm");
                return 1;
            }
        };
        return match crate::wansend::wan_rest(id, tag) {
            crate::wansend::WanRestOutcome::Edge { node, detail } => {
                eprintln!("{verb}:{id} ({detail}) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoEdge { node } => {
                eprintln!("NO_EDGE:{id} (already in the target state) at {node}");
                0
            }
            crate::wansend::WanRestOutcome::NoReply { node } => {
                eprintln!("{verb}_REFUSED:{id}: {node} closed the stream without a reply (access gate)");
                1
            }
            crate::wansend::WanRestOutcome::RemoteFail { node, detail } => {
                eprintln!("{verb}_FAIL:{id} at {node}: {detail}");
                1
            }
            crate::wansend::WanRestOutcome::Ambiguous(a) => {
                eprintln!("AMBIGUOUS:{id} — qualify the address: {a}");
                1
            }
            crate::wansend::WanRestOutcome::NotFound => {
                eprintln!("NO_ENDPOINT:{id}: no routable remote instance in any visible subnet");
                1
            }
            crate::wansend::WanRestOutcome::Failed(e) => {
                eprintln!("{verb}_FAIL:{id}: {e}");
                1
            }
        };
    }
    let knob = spt_daemon::DaemonConfig::load().auto_suspend_after_ms;
    match spt_daemon::daemon_rest_event(id, event, knob) {
        Ok(Some(report)) => {
            eprintln!("{verb}:{id} ({:?} -> {:?})", report.from, report.to);
            0
        }
        Ok(None) => {
            // Idempotent UX: the machine reports no edge (already there).
            eprintln!("NO_EDGE:{id} (already in the target state)");
            0
        }
        Err(e) => {
            eprintln!("{verb}_FAIL:{id}: {e}");
            1
        }
    }
}

/// `spt shutdown [id]` — the agent's graceful self-suspend verb (M5-D4d,
/// CONTEXT §Shell model: "an agent gracefully shuts down *its own* endpoint
/// → suspended; fires the transition echo-commune; cascades its persistent
/// shells offline"). Two legs through existing machinery: (1) soft-stop the
/// listener (drop the ready marker — the session-over half `spt suspend`
/// does not do); (2) the suspend edge via the daemon-side transition host —
/// which arms the echo gate and runs the D4a shell cascade. Idempotent like
/// every rest verb.
// [impl->REQ-SHELL-2]
fn cmd_shutdown(id: Option<String>) -> i32 {
    let Some(id) = id.or_else(roster::detect_self_id) else {
        eprintln!("NO_SELF: shutdown needs an endpoint (spt shutdown <id> or $OWL_SESSION_ID)");
        return 1;
    };
    if id.contains('@') || id.contains(':') {
        // Deliberately NOT lifted with the D5b remote rest arm: an agent
        // shuts down *its own* endpoint — remote shutdown is not a surface;
        // `spt suspend <id@node>` covers the remote need.
        eprintln!("LOCAL_ONLY:{id} — shutdown is an endpoint's own verb; use `spt suspend {id}` remotely");
        return 1;
    }
    // Leg 1: soft-stop — the listener marker goes first so nothing new is
    // accepted while the edge runs.
    let _ = std::fs::remove_file(perch::resolve_ready_file(&id, ParentHint::Infer));
    // Leg 2: the suspend edge (echo gate + shell cascade live in the host).
    cmd_rest(&id, spt_daemon::RestEvent::Suspend, "SHUTDOWN")
}

/// Run the per-machine daemon (the hidden `spt daemon` entry point). Blocks
/// serving the broker + seed-control channels until killed; a bind contention
/// (another daemon already owns this home) surfaces as a non-zero exit.
// [impl->REQ-DAEMON-3]
fn cmd_daemon() -> i32 {
    match spt_daemon::Daemon::run() {
        Ok(()) => 0,
        Err(e) => {
            eprintln!("DAEMON_EXIT: {e}");
            1
        }
    }
}

/// Read stdin to a string (message body source for send/ring).
fn read_stdin() -> String {
    let mut body = String::new();
    let _ = std::io::stdin().read_to_string(&mut body);
    body
}

/// Resolve which subnet a notif targets: an explicit `--subnet` must be a
/// member; with no flag the node's sole subnet is used; several ⇒ refuse and
/// force qualification (no guessing — the D3c/`show-totp` stance).
// [impl->REQ-NOTIF-2]
fn resolve_notif_subnet(store: &SubnetStore, flag: Option<String>) -> Result<String, i32> {
    match flag {
        Some(name) => {
            if store.find(&name).is_some() {
                Ok(name)
            } else {
                eprintln!("NO_SUBNET:{name} — this node is not a member");
                Err(1)
            }
        }
        None => match store.subnets.len() {
            0 => {
                eprintln!("NO_SUBNET: this node holds no subnet — pair first");
                Err(1)
            }
            1 => Ok(store.subnets[0].name.clone()),
            _ => {
                eprintln!(
                    "AMBIGUOUS_SUBNET: this node holds several — pass --subnet <name> (no guessing)"
                );
                Err(1)
            }
        },
    }
}

/// `spt notify` — agent-issued subnet notif (ADR-0007, REQ-NOTIF-2): produce
/// into the replicated spool (id minted from this node's EpochSource), then
/// first-fire at the user's most-recently-active visible endpoint. The
/// envelope `from` carries issuer endpoint **and node** (`<id>@<node-hex>`);
/// the subnet rides the envelope's own attribute.
// [impl->REQ-NOTIF-2]
fn cmd_notify(body: Option<String>, subnet: Option<String>, from: Option<String>) -> i32 {
    let issuer = from.or_else(roster::detect_self_id).unwrap_or_default();
    if issuer.is_empty() {
        eprintln!("NO_SELF: notify needs an issuer (--from <id> or $OWL_SESSION_ID)");
        return 1;
    }
    let subnets = SubnetStore::load();
    let subnet = match resolve_notif_subnet(&subnets, subnet) {
        Ok(s) => s,
        Err(code) => return code,
    };
    let body = body.unwrap_or_else(read_stdin);
    let body = body.trim();
    if body.is_empty() {
        eprintln!("EMPTY_BODY: nothing to notify");
        return 1;
    }
    let node_hex = match spt_store::nodeid::load_or_create() {
        Ok(identity) => identity.public_key().to_hex(),
        Err(e) => {
            eprintln!("NODE_ID_FAIL: {e}");
            return 1;
        }
    };
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let mut epochs = spt_store::epoch::EpochSource::load();
    let policy = spt_daemon::NotifSurfacePolicy::load();
    let from_id = format!("{issuer}@{node_hex}");
    match spt_daemon::produce_and_first_fire(
        &store,
        &node_hex,
        &mut epochs,
        &subnet,
        spt_daemon::NOTIF_KIND_AGENT,
        &from_id,
        body,
        &policy,
        &perch::owlery_dir(),
        now_ms(),
    ) {
        Ok((row, spt_daemon::FirstFireOutcome::Fired { to, .. })) => {
            println!("NOTIFIED:{} -> {to}", row.notif_id);
            0
        }
        Ok((row, spt_daemon::FirstFireOutcome::NoTarget)) => {
            println!(
                "NOTIF_PENDING:{} (no live endpoint in {subnet}; surfaces at the next boundary)",
                row.notif_id
            );
            0
        }
        // REQ-PRES-1: the user is most recently active on another node — the
        // row replicates there and that node surfaces it.
        Ok((row, spt_daemon::FirstFireOutcome::RemoteTarget { to, node })) => {
            println!("NOTIF_ROUTED:{} -> {to}@{node} (surfaces there via replication)", row.notif_id);
            0
        }
        Err(e) => {
            eprintln!("NOTIFY_FAIL: {e}");
            1
        }
    }
}

/// `spt notif list` — the ack surface's read side.
// [impl->REQ-NOTIF-2]
fn cmd_notif_list(subnet: Option<String>) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    let subnets = SubnetStore::load();
    let names: Vec<String> = match &subnet {
        Some(one) => vec![one.clone()],
        None => subnets.subnets.iter().map(|s| s.name.clone()).collect(),
    };
    let mut shown = 0usize;
    for name in names {
        for row in store.list(&name).unwrap_or_default() {
            let state = if row.dismissed {
                "dismissed".to_string()
            } else if row.seen.is_empty() {
                "fresh".to_string()
            } else {
                format!("seen:{}", row.seen.len())
            };
            // First body line only — the glanceable view.
            let head = row.body.lines().next().unwrap_or_default();
            println!("{}\t{}\t{}\t{}\t{}", row.notif_id, row.subnet, state, row.from_id, head);
            shown += 1;
        }
    }
    eprintln!("NOTIFS:{shown}");
    0
}

/// `spt notif dismiss <id>` — the explicit ack: latches locally, replicates
/// subnet-wide via the D8-2 feed.
// [impl->REQ-NOTIF-2]
fn cmd_notif_dismiss(notif_id: &str) -> i32 {
    let store = match spt_store::notif::NotifStore::open() {
        Ok(s) => s,
        Err(e) => {
            eprintln!("NOTIF_STORE_FAIL: {e}");
            return 1;
        }
    };
    match store.dismiss(notif_id) {
        Ok(true) => {
            println!("DISMISSED:{notif_id}");
            0
        }
        Ok(false) => {
            eprintln!("UNKNOWN_OR_DISMISSED:{notif_id}");
            1
        }
        Err(e) => {
            eprintln!("DISMISS_FAIL:{notif_id}: {e}");
            1
        }
    }
}

/// `spt update apply` — the explicit ack named by the update-consent notif.
// [impl->REQ-UPD-4]
// [impl->REQ-UPD-3]
fn cmd_update_apply() -> i32 {
    if let Err(e) = spt_daemon::ensure_running() {
        eprintln!("DAEMON_START_FAIL: {e}");
        return 1;
    }
    let exe = match std::env::current_exe() {
        Ok(p) => p,
        Err(e) => {
            eprintln!("CURRENT_EXE_FAIL: {e}");
            return 1;
        }
    };
    let cache = spt_daemon::ReleaseCache::open(&perch::spt_home().join("releases"));
    let keys = perch::identity_dir().join("release-keys.json");
    match spt_daemon::apply_staged(&cache, &keys, &exe, &spt_daemon::broker_socket_name()) {
        Ok(spt_daemon::ApplyStagedOutcome::NoUpdate) => {
            println!("NO_UPDATE");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::Applied { version }) => {
            println!("APPLIED:{version}");
            0
        }
        Ok(spt_daemon::ApplyStagedOutcome::RefusedClass(class)) => {
            eprintln!("UPDATE_APPLY_REFUSED:{class:?} (full-swap orchestration is not M5 scope)");
            1
        }
        Ok(spt_daemon::ApplyStagedOutcome::Rejected(reason)) => {
            eprintln!("UPDATE_APPLY_REJECTED:{reason}");
            1
        }
        Err(e) => {
            eprintln!("UPDATE_APPLY_FAIL:{e}");
            1
        }
    }
}

/// Epoch milliseconds (the notif surfacing clock).
fn now_ms() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_millis() as u64)
        .unwrap_or(0)
}

/// Resolve the sender id: explicit `--from`, else session auto-detect, else "".
fn resolve_from(from: Option<String>) -> String {
    from.or_else(roster::detect_self_id).unwrap_or_default()
}

fn cmd_send(target: Option<String>, from: Option<String>, reply_to: Option<String>, deferred: bool) -> i32 {
    let is_reply = reply_to.is_some();
    // --reply-to provides the target when no positional target is given.
    let target = match target.or_else(|| reply_to.clone()) {
        Some(t) => t,
        None => {
            eprintln!("USAGE: a target (or --reply-to <sender>) is required");
            return 2;
        }
    };
    let from = resolve_from(from);
    let body = read_stdin();
    let owlery = perch::owlery_dir();

    let outcome = if deferred {
        deliver::send_deferred(&target, &from, &body)
    } else {
        deliver::send(&target, &from, &body, &owlery)
    };

    match outcome {
        SendOutcome::Sent => {
            eprintln!("{}:{}", if is_reply { "REPLIED" } else { "SENT" }, target);
            0
        }
        SendOutcome::Queued => {
            let label = if deferred {
                "DEFERRED"
            } else if is_reply {
                "REPLIED(QUEUED)"
            } else {
                "QUEUED"
            };
            eprintln!("{}:{}", label, target);
            0
        }
        SendOutcome::NoPerch => {
            if deferred {
                // The wire record carries no deferred class (v1) — a remote
                // deferred send is a documented seam, not a silent reclass.
                eprintln!("NO_PERCH:{} is not listening (deferred stays local-only)", target);