@oh-hai/cli 0.2.3 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +59 -29
  3. package/dist/commands/ask.js +71 -10
  4. package/dist/commands/ask.js.map +1 -1
  5. package/dist/commands/bridge.d.ts +2 -0
  6. package/dist/commands/bridge.js +502 -0
  7. package/dist/commands/bridge.js.map +1 -0
  8. package/dist/commands/context.d.ts +22 -0
  9. package/dist/commands/doctor.js +12 -1
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/fleet.d.ts +2 -0
  12. package/dist/commands/fleet.js +164 -0
  13. package/dist/commands/fleet.js.map +1 -0
  14. package/dist/commands/handlers.js +8 -0
  15. package/dist/commands/handlers.js.map +1 -1
  16. package/dist/commands/messages.d.ts +2 -0
  17. package/dist/commands/messages.js +131 -0
  18. package/dist/commands/messages.js.map +1 -0
  19. package/dist/commands/messaging/build.d.ts +10 -1
  20. package/dist/commands/messaging/build.js +22 -4
  21. package/dist/commands/messaging/build.js.map +1 -1
  22. package/dist/commands/messaging/capability.d.ts +42 -0
  23. package/dist/commands/messaging/capability.js +164 -0
  24. package/dist/commands/messaging/capability.js.map +1 -0
  25. package/dist/commands/messaging/http.d.ts +28 -1
  26. package/dist/commands/messaging/http.js +234 -13
  27. package/dist/commands/messaging/http.js.map +1 -1
  28. package/dist/commands/messaging/inbox-entries.d.ts +83 -0
  29. package/dist/commands/messaging/inbox-entries.js +371 -0
  30. package/dist/commands/messaging/inbox-entries.js.map +1 -0
  31. package/dist/commands/messaging/inbox-stream.d.ts +37 -0
  32. package/dist/commands/messaging/inbox-stream.js +224 -0
  33. package/dist/commands/messaging/inbox-stream.js.map +1 -0
  34. package/dist/commands/messaging/session-drain.d.ts +50 -0
  35. package/dist/commands/messaging/session-drain.js +163 -0
  36. package/dist/commands/messaging/session-drain.js.map +1 -0
  37. package/dist/commands/messaging/session-scope.d.ts +93 -0
  38. package/dist/commands/messaging/session-scope.js +0 -0
  39. package/dist/commands/messaging/session-scope.js.map +1 -0
  40. package/dist/commands/messaging/sessions-http.d.ts +20 -0
  41. package/dist/commands/messaging/sessions-http.js +153 -0
  42. package/dist/commands/messaging/sessions-http.js.map +1 -0
  43. package/dist/commands/messaging/shared.d.ts +19 -1
  44. package/dist/commands/messaging/shared.js +69 -2
  45. package/dist/commands/messaging/shared.js.map +1 -1
  46. package/dist/commands/messaging/validate.d.ts +33 -1
  47. package/dist/commands/messaging/validate.js +78 -0
  48. package/dist/commands/messaging/validate.js.map +1 -1
  49. package/dist/commands/messaging/wire.d.ts +180 -3
  50. package/dist/commands/notify.js +47 -5
  51. package/dist/commands/notify.js.map +1 -1
  52. package/dist/commands/registry.js +128 -3
  53. package/dist/commands/registry.js.map +1 -1
  54. package/dist/commands/self-test.d.ts +17 -0
  55. package/dist/commands/self-test.js +472 -0
  56. package/dist/commands/self-test.js.map +1 -0
  57. package/dist/commands/session.d.ts +2 -0
  58. package/dist/commands/session.js +158 -0
  59. package/dist/commands/session.js.map +1 -0
  60. package/dist/commands/setup.js +44 -0
  61. package/dist/commands/setup.js.map +1 -1
  62. package/dist/commands/task.js +61 -11
  63. package/dist/commands/task.js.map +1 -1
  64. package/dist/commands/teach.js +84 -4
  65. package/dist/commands/teach.js.map +1 -1
  66. package/dist/commands/whoami.js +14 -1
  67. package/dist/commands/whoami.js.map +1 -1
  68. package/dist/exit-codes.d.ts +37 -1
  69. package/dist/exit-codes.js +42 -1
  70. package/dist/exit-codes.js.map +1 -1
  71. package/dist/help.js +5 -1
  72. package/dist/help.js.map +1 -1
  73. package/dist/watch-lock.js +7 -0
  74. package/dist/watch-lock.js.map +1 -1
  75. package/package.json +5 -4
@@ -0,0 +1,164 @@
1
+ // The Hub capability document, and the §8.0 FEATURE-DETECTION GATE an addressed send must pass
2
+ // before anything is posted (issue #657).
3
+ //
4
+ // WHY THIS IS A PRE-FLIGHT AND NOT A POST-HOC CHECK. §10 robustness says a Hub ignores fields it does
5
+ // not understand — so a pre-v0.5 Hub handed an envelope carrying `to` does not refuse it. It drops
6
+ // the address and files the message in the HUMAN inbox. The §8.1 destination-snapshot check catches
7
+ // that, but only from the ACK: by then the content an agent addressed to another agent has already
8
+ // been disclosed to a person, and no error code un-sends it. A round-trip is a cheap price for not
9
+ // leaking; "the Hub's refusals are precise" was only ever true of Hubs that understand the field.
10
+ //
11
+ // WHAT IS REQUIRED, AND WHAT DELIBERATELY IS NOT. The gate demands a declared minor >= 0.5 — that is
12
+ // exactly the property that makes the Hub *refuse* rather than silently misroute, since v0.5 is where
13
+ // `to` is defined and constrained. It does NOT demand an advertised `inter_agent`: this Hub routes
14
+ // addressed messages today while deliberately withholding that object until the response (#652) and
15
+ // receipt (#653) producers land, so requiring it would make `--to` unusable against the very Hub that
16
+ // implements it. An explicitly DISABLED `inter_agent` is a different matter and is refused here,
17
+ // because that is the Hub stating the leg is off.
18
+ //
19
+ // FAIL CLOSED. An unreachable or unreadable capability document refuses the send. We cannot prove the
20
+ // Hub understands `to`, and the cost of being wrong is disclosure, not inconvenience.
21
+ import { CliError } from "../../envelope.js";
22
+ /** The minor at which `to` / `agent.session` exist and are constrained (spec §4/§4.1). */
23
+ const ADDRESSING_MINOR = 5;
24
+ /** Per-process capability cache, keyed by Hub base URL. The document is effectively static for a
25
+ * process lifetime, and an addressed send would otherwise pay a round-trip every call. `null` marks
26
+ * a fetch that FAILED, cached so a down/unreachable discovery endpoint is not re-probed per send —
27
+ * it still fails closed either way. */
28
+ const CACHE = new Map();
29
+ /** Test seam — a real process wants the cache to live exactly as long as it does. */
30
+ export function resetCapabilityCache() {
31
+ CACHE.clear();
32
+ }
33
+ /** The declared minor of an `0.<minor>` version string, or null when it is absent/malformed. */
34
+ function minorOf(version) {
35
+ if (typeof version !== "string")
36
+ return null;
37
+ const m = /^0\.(\d+)$/.exec(version.trim());
38
+ return m ? Number(m[1]) : null;
39
+ }
40
+ /**
41
+ * GET /v1/capability — the public capability document (no auth), memoized per process.
42
+ *
43
+ * Best-effort by return type: an unreachable / non-2xx / unparseable document resolves to
44
+ * `undefined`. Callers decide what that means — `fleet ls` degrades its caption to "unknown", while
45
+ * `assertHubSpeaksAddressing` below refuses the send.
46
+ */
47
+ export async function fetchCapability(ctx) {
48
+ const key = ctx.config.baseUrl;
49
+ const cached = CACHE.get(key);
50
+ if (cached !== undefined)
51
+ return cached ?? undefined;
52
+ const timeout = ctx.config.timeoutMs !== undefined && ctx.config.timeoutMs > 0 ? Math.min(ctx.config.timeoutMs, 2_147_483_647) : 10_000;
53
+ let res;
54
+ try {
55
+ res = await ctx.runtime.fetchImpl(`${key}/v1/capability`, { method: "GET", headers: {}, signal: AbortSignal.timeout(timeout) });
56
+ }
57
+ catch {
58
+ CACHE.set(key, null);
59
+ return undefined;
60
+ }
61
+ if (res.status < 200 || res.status >= 300 || typeof res.json !== "function") {
62
+ CACHE.set(key, null);
63
+ return undefined;
64
+ }
65
+ let body;
66
+ try {
67
+ body = await res.json();
68
+ }
69
+ catch {
70
+ CACHE.set(key, null);
71
+ return undefined;
72
+ }
73
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
74
+ CACHE.set(key, null);
75
+ return undefined;
76
+ }
77
+ const doc = body;
78
+ // `agent_list_visibility` is a GRANT and the only field a caller ACTS on — `fleet ls` turns it into
79
+ // the scope caption and into `data.scope` for scripts. A blind cast would read the STRING "false"
80
+ // as truthy and report the whole fleet for a list the Hub never said was account-wide. Anything
81
+ // that is not a real boolean is dropped, so it reads `unknown`: the honest answer for a document
82
+ // that did not say.
83
+ const sessions = doc.sessions;
84
+ if (sessions !== null && typeof sessions === "object" && !Array.isArray(sessions)) {
85
+ const visibility = sessions.agent_list_visibility;
86
+ if (visibility !== undefined && typeof visibility !== "boolean") {
87
+ delete sessions.agent_list_visibility;
88
+ }
89
+ }
90
+ else if (sessions !== undefined) {
91
+ delete doc.sessions;
92
+ }
93
+ const capability = doc;
94
+ CACHE.set(key, capability);
95
+ return capability;
96
+ }
97
+ /**
98
+ * The §8.0 gate: refuse an addressed send unless this Hub demonstrably understands `to`.
99
+ *
100
+ * Throws a `usage` CliError (exit 2 — the caller's invocation cannot work against this Hub, and no
101
+ * retry will change that) BEFORE any envelope is posted. See the file header for why the check is
102
+ * a pre-flight, why `inter_agent` is not required, and why an unreadable document fails closed.
103
+ */
104
+ export async function assertHubSpeaksAddressing(ctx, to) {
105
+ const capability = await fetchCapability(ctx);
106
+ if (capability === undefined) {
107
+ throw new CliError("usage", `refusing to send to ${to}: this Hub's capability document (${ctx.config.baseUrl}/v1/capability) could not be read, ` +
108
+ "so it cannot be confirmed to understand v0.5 addressing. A Hub that does not would IGNORE the address and file " +
109
+ "this message in a human inbox (§10), which no error code can undo — so the send fails closed (§8.0).");
110
+ }
111
+ const minor = minorOf(capability.ma2h_version);
112
+ if (minor === null || minor < ADDRESSING_MINOR) {
113
+ throw new CliError("usage", `refusing to send to ${to}: this Hub declares MA2H ${capability.ma2h_version ?? "(no version)"}, which predates ` +
114
+ "the v0.5 addressing vocabulary. It would ignore `to` and file this message in a human inbox rather than refusing it (§8.0/§10).");
115
+ }
116
+ // Absent `inter_agent` is NOT read as disabled (see the file header) — but an explicit `false` is
117
+ // the Hub stating the leg is off, which is worth catching before a round-trip that can only 403.
118
+ if (capability.inter_agent?.enabled === false) {
119
+ throw new CliError("usage", `refusing to send to ${to}: this Hub advertises the inter-agent leg as disabled (§8.0 inter_agent.enabled=false).`);
120
+ }
121
+ }
122
+ /**
123
+ * The §8.0 gate for the RECEIVE half — `oh-hai bridge`'s session-presenting drain (#658).
124
+ *
125
+ * The same disclosure logic as the send gate, pointed the other way, and the failure it prevents is
126
+ * worse than a wasted round-trip. A pre-v0.5 Hub does not refuse `?session=`; per §10 it IGNORES the
127
+ * unknown query parameter and answers with the v0.4 body — which means the bridge would issue a
128
+ * **destructive PRINCIPAL-scoped drain**, claiming (and, on ack, consuming) every directive queued
129
+ * for the whole agent, while believing it had drained only its own session's mail. Concurrent runs
130
+ * of the same principal would then eat each other's directives: exactly the race the watch-lock
131
+ * exists to prevent, reintroduced by a silently-dropped parameter.
132
+ *
133
+ * So the gate runs BEFORE the first drain or stream connect, and it fails CLOSED on an unreadable
134
+ * capability document for the same reason the send gate does — we cannot prove the Hub understands
135
+ * `session`, and the cost of being wrong is consuming someone else's mail.
136
+ *
137
+ * What is deliberately NOT required: a present `inbound.session_param`. An ABSENT capability field
138
+ * must never be read as "disabled" (the lesson `assertHubSpeaksAddressing` records in this file's
139
+ * header) — a declared minor >= 0.5 is what makes the Hub *refuse* rather than silently misroute,
140
+ * and that is the property being tested. An EXPLICIT `false` is a different matter: that is the Hub
141
+ * stating the parameter is not honoured, and it is refused.
142
+ */
143
+ export async function assertHubSpeaksSessionDrain(ctx) {
144
+ const capability = await fetchCapability(ctx);
145
+ if (capability === undefined) {
146
+ throw new CliError("usage", `refusing to bridge: this Hub's capability document (${ctx.config.baseUrl}/v1/capability) could not be read, so it ` +
147
+ "cannot be confirmed to understand the v0.5 session-scoped drain. A Hub that does not would IGNORE `?session=` and " +
148
+ "answer with the v0.4 principal-wide mailbox (§10) — consuming directives meant for every other run of this agent. " +
149
+ "The bridge fails closed (§8.0).");
150
+ }
151
+ const minor = minorOf(capability.ma2h_version);
152
+ if (minor === null || minor < ADDRESSING_MINOR) {
153
+ throw new CliError("usage", `refusing to bridge: this Hub declares MA2H ${capability.ma2h_version ?? "(no version)"}, which predates the v0.5 ` +
154
+ "session-scoped drain (§8.7.1). It would ignore `?session=` and drain this agent's WHOLE mailbox destructively " +
155
+ "instead of refusing. Use `oh-hai inbox watch` against a pre-0.5 Hub.");
156
+ }
157
+ if (capability.sessions?.enabled === false) {
158
+ throw new CliError("usage", "refusing to bridge: this Hub advertises the v0.5 session primitive as disabled (§8.0 sessions.enabled=false).");
159
+ }
160
+ if (capability.inbound?.session_param === false) {
161
+ throw new CliError("usage", "refusing to bridge: this Hub advertises `inbound.session_param: false` — it does not honour the session-scoped drain (§8.7.1).");
162
+ }
163
+ }
164
+ //# sourceMappingURL=capability.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"capability.js","sourceRoot":"","sources":["../../../src/commands/messaging/capability.ts"],"names":[],"mappings":"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"}
@@ -1,12 +1,19 @@
1
+ import { CliError } from "../../envelope.js";
1
2
  import type { CommandContext } from "../context.js";
2
3
  import type { GetMessageBody, InboundDelivery, InboxAckResult, SubmitAck } from "./wire.js";
4
+ /** The per-request bound for a single Hub call: `config.timeoutMs` (from `--timeout` /
5
+ * `MA2H_TIMEOUT_MS`, §6), defaulted and clamped to the timer ceiling so an oversized/negative value
6
+ * can't overflow `AbortSignal.timeout` and fail the request spuriously. */
7
+ export declare function requestTimeoutMs(ctx: CommandContext): number;
3
8
  /** Redact any occurrence of the bearer from a Hub-supplied string before it can reach
4
9
  * stdout/stderr/logs/the `--json` envelope (§5.4) — a hostile/buggy Hub could echo the
5
10
  * Authorization value back in an error message, an ack field, or (inbox watch) a directive. */
6
11
  export declare function redactToken(message: string | undefined, token: string): string | undefined;
7
12
  /** POST an envelope to the Hub's ingest endpoint (agent bearer). `202` → the SubmitAck; every
8
13
  * other status maps to the right §7 exit code (via the Hub's error envelope when present). */
9
- export declare function submitEnvelope(ctx: CommandContext, token: string, envelope: unknown): Promise<SubmitAck>;
14
+ export declare function submitEnvelope(ctx: CommandContext, token: string, envelope: unknown, opts?: {
15
+ addressedTo?: string;
16
+ }): Promise<SubmitAck>;
10
17
  /** GET one message (the submitter-bound pull, agent bearer). Embeds the terminal Response.
11
18
  * A `2xx` returns the parsed body; every other status maps to a §7 exit code. */
12
19
  export declare function pollMessage(ctx: CommandContext, token: string, id: string, timeoutMs: number): Promise<GetMessageBody>;
@@ -17,6 +24,26 @@ export declare function pollMessage(ctx: CommandContext, token: string, id: stri
17
24
  export declare function drainInbox(ctx: CommandContext, token: string, max?: number): Promise<{
18
25
  directives: InboundDelivery[];
19
26
  }>;
27
+ /** GET the agent's OWN submitted messages — the submitter-bound self-list (spec §9.1, agent bearer,
28
+ * the #400 primitive paginated by #527). The Hub scopes the result to (accountId, agentId) and
29
+ * returns a pure INDEX: each row is the stored envelope MINUS the embedded terminal `response` and
30
+ * derived `delivery` (the agent pulls a specific answer with `pollMessage`). `opts` carries the
31
+ * optional `limit`/`offset` pagination and `status`/`type` filters as query params;
32
+ * `expectedAgentId` is the caller's own agent id — every returned row MUST be its own submission
33
+ * (see the ownership guard). A `2xx` returns the validated message array; every other status maps
34
+ * to a §7 exit code. Every returned string is bearer-redacted before the caller renders it (§5.4). */
35
+ export declare function listMessages(ctx: CommandContext, token: string, opts: {
36
+ limit?: number;
37
+ offset?: number;
38
+ status?: string;
39
+ type?: string;
40
+ }, expectedAgentId: string): Promise<GetMessageBody[]>;
20
41
  /** POST a single batched consume-ack for the drained ids — the §14 directive receipt (agent bearer).
21
42
  * A `2xx` returns the consumed count; every other status maps to a §7 exit code. */
22
43
  export declare function ackInbox(ctx: CommandContext, token: string, ids: string[]): Promise<InboxAckResult>;
44
+ /** Map a Hub error response to a CliError. The exit code comes from the HTTP status; the stable
45
+ * `error.code` string (§8) prefers the Hub's own `error.code` when it maps to the SAME exit tier
46
+ * — so a documented code like `version_not_supported` survives instead of being flattened to
47
+ * `bad_request` (both exit 9), without letting a Hub-supplied code change the exit semantics. The
48
+ * server message is stripped of terminal control chars (the top-level catch prints it). */
49
+ export declare function statusToCliError(status: number, message: string | undefined, hubCode?: string, hint?: string): CliError;
@@ -11,6 +11,7 @@
11
11
  import { CliError } from "../../envelope.js";
12
12
  import { exitCodeForError, isErrorCode } from "../../exit-codes.js";
13
13
  import { throwTransportError } from "../http.js";
14
+ import { assertHubSpeaksAddressing } from "./capability.js";
14
15
  import { MAX_TIMER_MS, sanitizeForTerminal } from "./shared.js";
15
16
  /** Default per-request bound when no `--timeout`/`MA2H_TIMEOUT_MS` is set — mirrors the scripts
16
17
  * layer's DEFAULT_TIMEOUT_MS so an unresponsive Hub can't hang a command indefinitely. */
@@ -18,7 +19,7 @@ const DEFAULT_TIMEOUT_MS = 10_000;
18
19
  /** The per-request bound for a single Hub call: `config.timeoutMs` (from `--timeout` /
19
20
  * `MA2H_TIMEOUT_MS`, §6), defaulted and clamped to the timer ceiling so an oversized/negative value
20
21
  * can't overflow `AbortSignal.timeout` and fail the request spuriously. */
21
- function requestTimeoutMs(ctx) {
22
+ export function requestTimeoutMs(ctx) {
22
23
  const t = ctx.config.timeoutMs;
23
24
  return Math.min(t !== undefined && t > 0 ? t : DEFAULT_TIMEOUT_MS, MAX_TIMER_MS);
24
25
  }
@@ -32,7 +33,15 @@ export function redactToken(message, token) {
32
33
  }
33
34
  /** POST an envelope to the Hub's ingest endpoint (agent bearer). `202` → the SubmitAck; every
34
35
  * other status maps to the right §7 exit code (via the Hub's error envelope when present). */
35
- export async function submitEnvelope(ctx, token, envelope) {
36
+ export async function submitEnvelope(ctx, token, envelope, opts = {}) {
37
+ // §8.0 FEATURE DETECTION, before a single byte is posted. A pre-v0.5 Hub does not refuse an
38
+ // envelope carrying `to` — §10 says it IGNORES the field, files the message in the human inbox,
39
+ // and acks success. The §8.1 snapshot check below catches that only from the ack, by which point
40
+ // agent-addressed content has already been disclosed to a person and cannot be recalled. Gating
41
+ // HERE, at the one choke point every submit path funnels through, is also why the replay paths
42
+ // cannot miss it — a per-caller gate already got forgotten once.
43
+ if (opts.addressedTo !== undefined)
44
+ await assertHubSpeaksAddressing(ctx, opts.addressedTo);
36
45
  const url = `${ctx.config.baseUrl}/v1/messages`;
37
46
  const init = {
38
47
  method: "POST",
@@ -49,7 +58,11 @@ export async function submitEnvelope(ctx, token, envelope) {
49
58
  }
50
59
  if (res.status !== 202) {
51
60
  const { code, message } = await readErrorEnvelope(res);
52
- throw hubStatusToCliError(res.status, redactToken(message, token), code);
61
+ // On an ADDRESSED submit, append the one sentence that turns each documented §8.5 refusal into
62
+ // a next step. Done here — where the HTTP status and the Hub's own `error.code` are still in
63
+ // hand — rather than by re-inspecting a flattened CliError message at the call site.
64
+ const hint = opts.addressedTo !== undefined ? addressedHint(res.status, code, opts.addressedTo) : undefined;
65
+ throw statusToCliError(res.status, redactToken(message, token), code, hint);
53
66
  }
54
67
  const body = await readJson(res);
55
68
  // A conformant Hub returns `{ id, status, poll_url, review_url? }` for every message type.
@@ -67,14 +80,94 @@ export async function submitEnvelope(ctx, token, envelope) {
67
80
  // Redact the bearer from every ack field before returning — a hostile/buggy Hub could echo the
68
81
  // Authorization value into id/status/poll_url/review_url, which the submit handlers print to
69
82
  // stdout / the --json envelope (§5.4). Build a clean ack (drops any extra Hub fields too).
83
+ const destination = destinationOf(body, token, opts.addressedTo !== undefined);
84
+ // §8.1 MUST: every ADDRESSED ack carries the destination snapshot, and its absence is not a
85
+ // cosmetic gap — it is positive proof that the Hub did not route the address. A pre-0.5 Hub cannot
86
+ // emit this object at all, so a 202 without it means `to` was ignored and the message was filed in
87
+ // the HUMAN inbox. Printing a warning while still returning a successful ack (the earlier shape)
88
+ // left every caller exiting 0 and `--json` saying `ok: true` for a message that reached the wrong
89
+ // recipient — a silent misroute is exactly what this snapshot exists to make impossible. The id is
90
+ // included because the submit WAS accepted: the caller needs it to find (and cancel) the message.
91
+ if (opts.addressedTo !== undefined && destination === undefined) {
92
+ throw new CliError("server", `the Hub accepted this message (id ${redactToken(String(body.id), token)}) but returned no destination snapshot, ` +
93
+ `which means it did not route "${opts.addressedTo}" — the message was filed in the HUMAN inbox instead. ` +
94
+ "This Hub predates v0.5 addressing (§8.1).");
95
+ }
70
96
  const ack = {
71
97
  id: redactToken(body.id, token),
72
98
  status: redactToken(body.status, token),
73
99
  poll_url: redactToken(body.poll_url, token),
74
100
  ...(typeof body.review_url === "string" ? { review_url: redactToken(body.review_url, token) } : {}),
101
+ ...(destination !== undefined ? { destination } : {}),
75
102
  };
76
103
  return ack;
77
104
  }
105
+ /** Read the v0.5 §8.1 `destination` snapshot off an ack, defensively. Absent / wrong-shaped ⇒
106
+ * undefined, which the submit handlers render as "no destination snapshot" — a meaningful signal in
107
+ * itself on an ADDRESSED submit, because a pre-0.5 Hub cannot emit this object at all, so its
108
+ * absence is the sender's proof that `to` was IGNORED and the message filed in a human inbox.
109
+ * An unrecognized `state` is passed through verbatim (sanitized at render) rather than coerced to
110
+ * `unknown`: inventing a reachability claim is exactly the dishonesty §8.1 exists to prevent. */
111
+ function destinationOf(body, token, addressed) {
112
+ const raw = body.destination;
113
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
114
+ return undefined;
115
+ const { state, last_seen: lastSeen } = raw;
116
+ if (typeof state !== "string" || state === "")
117
+ return undefined;
118
+ if (addressed) {
119
+ // The v0.5 submit-ack schema (and conformance vector sv-034) fixes BOTH the enum and the
120
+ // relationship: `state` is online|offline|unknown, and `unknown` — the "no visibility" answer —
121
+ // MUST carry no `last_seen`, because a timestamp IS visibility. Passing an out-of-enum state
122
+ // through "verbatim" was the wrong kind of honesty: rendering `reachable` or an `unknown` with a
123
+ // last-seen invents a reachability claim the protocol cannot express, which is exactly the false
124
+ // belief this snapshot exists to prevent. A violation is a malformed ack, not a usable reading.
125
+ if (state !== "online" && state !== "offline" && state !== "unknown") {
126
+ throw new CliError("server", `the Hub returned an unknown destination reachability state ${JSON.stringify(sanitizeForTerminal(state))}.`);
127
+ }
128
+ // The schema pins the relationship in BOTH directions, and each direction is load-bearing:
129
+ // `unknown` must carry no `last_seen` (a timestamp IS visibility, so one here contradicts the
130
+ // claim), and `online`/`offline` MUST carry one (a known state with no timestamp is an
131
+ // unanchored assertion — "online" as of when?). Half-validating leaves the second case rendering
132
+ // a reachability reading the sender cannot date, which is the same unfalsifiable claim the
133
+ // snapshot exists to replace.
134
+ if (state === "unknown" && lastSeen !== undefined) {
135
+ throw new CliError("server", "the Hub returned an `unknown` destination reachability carrying a last_seen, which the v0.5 ack schema forbids.");
136
+ }
137
+ if (state !== "unknown" && !isIsoTimestamp(lastSeen)) {
138
+ throw new CliError("server", `the Hub reported the destination as ${state} without the last_seen timestamp the v0.5 ack schema requires to anchor it.`);
139
+ }
140
+ }
141
+ return {
142
+ state: redactToken(state, token),
143
+ ...(typeof lastSeen === "string" ? { last_seen: redactToken(lastSeen, token) } : {}),
144
+ };
145
+ }
146
+ /** The actionable second sentence for a refused ADDRESSED submit (spec §8.5). Keyed on the HTTP
147
+ * status plus the Hub's `error.code`, both of which are documented and stable:
148
+ * - `403` — the leg is ACCOUNT-OPT-IN and defaults off (§8.0). Nothing the sender can fix in argv.
149
+ * - `422 unknown_destination` — no such agent (or a session that is not the addressee's) in THIS
150
+ * account. Deliberately indistinguishable from "exists but not yours", so the guidance can only
151
+ * point at the discovery surface.
152
+ * - `410 destination_gone` — the addressed session is terminal; the PRINCIPAL may still be live.
153
+ * - `422 invalid_field` — a Hub that predates v0.5 addressing vocabulary.
154
+ * Anything else falls through to the Hub's own message unadorned. */
155
+ function addressedHint(status, code, to) {
156
+ if (status === 403) {
157
+ return "the inter-agent leg is opt-in per account and off by default — an account owner enables it before agents can address each other.";
158
+ }
159
+ if (status === 410) {
160
+ const principal = to.split("#")[0] ?? to;
161
+ return `that session is terminal — re-address the principal (\`--to ${principal}\`) or pick a live session from \`oh-hai fleet ls\`.`;
162
+ }
163
+ if (status === 422 && code === "unknown_destination") {
164
+ return "no live agent (or session) in this account answers to that address — check it with `oh-hai fleet ls`.";
165
+ }
166
+ if (status === 422 && code === "invalid_field") {
167
+ return "this Hub does not speak v0.5 addressing, so `--to` cannot be routed here.";
168
+ }
169
+ return undefined;
170
+ }
78
171
  /** GET one message (the submitter-bound pull, agent bearer). Embeds the terminal Response.
79
172
  * A `2xx` returns the parsed body; every other status maps to a §7 exit code. */
80
173
  export async function pollMessage(ctx, token, id, timeoutMs) {
@@ -93,7 +186,7 @@ export async function pollMessage(ctx, token, id, timeoutMs) {
93
186
  }
94
187
  if (res.status < 200 || res.status >= 300) {
95
188
  const { code, message } = await readErrorEnvelope(res);
96
- throw hubStatusToCliError(res.status, redactToken(message, token), code);
189
+ throw statusToCliError(res.status, redactToken(message, token), code);
97
190
  }
98
191
  const body = await readJson(res);
99
192
  // Validate the body shape before the await loop keys terminality on it: the `id` must be present
@@ -104,9 +197,12 @@ export async function pollMessage(ctx, token, id, timeoutMs) {
104
197
  if (typeof body.id !== "string" || body.id !== id || !isValidTypeStatus(body.type, body.status)) {
105
198
  throw new CliError("server", "the Hub returned a malformed or mismatched message (bad/other id, or a status invalid for its type).");
106
199
  }
107
- // A TERMINAL ask/task body must embed its Response (the pull contract) — else `await` would exit
108
- // "successfully" with no resolution value/actor. `open`/`delivered` are pre-terminal (no Response).
109
- if (body.status !== "open" && body.status !== "delivered" && (typeof body.response !== "object" || body.response === null)) {
200
+ // A body that reached a §7 RESOLUTION must embed its Response (the pull contract) — else `await`
201
+ // would exit "successfully" with no resolution value/actor. Keyed on the resolution set rather
202
+ // than "not open and not delivered": the v0.5 §14.2 delivery-track states carry no §7 Response by
203
+ // design (they describe DELIVERY, not a human's answer), so the old negative form would have
204
+ // rejected a legitimate `queued` / `acknowledged` / `bounced` poll body as malformed.
205
+ if (isResolved(body.type, body.status) && (typeof body.response !== "object" || body.response === null)) {
110
206
  throw new CliError("server", "the Hub returned a resolved message with no embedded Response.");
111
207
  }
112
208
  return body;
@@ -132,7 +228,7 @@ export async function drainInbox(ctx, token, max) {
132
228
  }
133
229
  if (res.status < 200 || res.status >= 300) {
134
230
  const { code, message } = await readErrorEnvelope(res);
135
- throw hubStatusToCliError(res.status, redactToken(message, token), code);
231
+ throw statusToCliError(res.status, redactToken(message, token), code);
136
232
  }
137
233
  const body = await readJson(res);
138
234
  // Validate the drain shape before the loop trusts it: `directives` MUST be an array, and every
@@ -146,6 +242,76 @@ export async function drainInbox(ctx, token, max) {
146
242
  }
147
243
  return { directives: directives };
148
244
  }
245
+ /** GET the agent's OWN submitted messages — the submitter-bound self-list (spec §9.1, agent bearer,
246
+ * the #400 primitive paginated by #527). The Hub scopes the result to (accountId, agentId) and
247
+ * returns a pure INDEX: each row is the stored envelope MINUS the embedded terminal `response` and
248
+ * derived `delivery` (the agent pulls a specific answer with `pollMessage`). `opts` carries the
249
+ * optional `limit`/`offset` pagination and `status`/`type` filters as query params;
250
+ * `expectedAgentId` is the caller's own agent id — every returned row MUST be its own submission
251
+ * (see the ownership guard). A `2xx` returns the validated message array; every other status maps
252
+ * to a §7 exit code. Every returned string is bearer-redacted before the caller renders it (§5.4). */
253
+ export async function listMessages(ctx, token, opts, expectedAgentId) {
254
+ const query = new URLSearchParams();
255
+ if (opts.limit !== undefined)
256
+ query.set("limit", String(opts.limit));
257
+ if (opts.offset !== undefined)
258
+ query.set("offset", String(opts.offset));
259
+ if (opts.status !== undefined)
260
+ query.set("status", opts.status);
261
+ if (opts.type !== undefined)
262
+ query.set("type", opts.type);
263
+ const suffix = query.toString();
264
+ const url = `${ctx.config.baseUrl}/v1/messages${suffix ? `?${suffix}` : ""}`;
265
+ const init = {
266
+ method: "GET",
267
+ headers: { Authorization: `Bearer ${token}` },
268
+ signal: AbortSignal.timeout(requestTimeoutMs(ctx)),
269
+ };
270
+ let res;
271
+ try {
272
+ res = await ctx.runtime.fetchImpl(url, init);
273
+ }
274
+ catch (error) {
275
+ throwTransportError(error, ctx.config.baseUrl);
276
+ }
277
+ if (res.status < 200 || res.status >= 300) {
278
+ const { code, message } = await readErrorEnvelope(res);
279
+ throw statusToCliError(res.status, redactToken(message, token), code);
280
+ }
281
+ const body = await readJson(res);
282
+ // The self-list body is `{ messages: [...] }`; validate each row is an INDEX item with the string
283
+ // id/type/status the renderer keys on (mirrors drainInbox's shape guard) — a malformed body is a
284
+ // server error, not a silently-empty list that would hide a Hub/proxy regression.
285
+ const messages = body.messages;
286
+ if (!Array.isArray(messages) || !messages.every(isListItem)) {
287
+ throw new CliError("server", "the Hub returned a malformed message list (expected { messages: [{ id, type, status }] }).");
288
+ }
289
+ // Defense-in-depth against the dual-audience route: `GET /v1/messages` returns the submitter-bound
290
+ // self-list for an agent bearer but the account-wide inbox for a human/session bearer. Refuse any
291
+ // row not submitted by THIS agent — that catches a misconfigured non-agent bearer whenever the
292
+ // account has other agents' messages. (A heuristic, not a proof of the agent branch: a single-agent
293
+ // or empty account has no foreign row to trip on — but the index projection below then yields only
294
+ // this agent's own rows, so nothing extra leaks. See the polish backlog for the rigorous fix.)
295
+ if (messages.some((m) => m.agent?.id !== expectedAgentId)) {
296
+ throw new CliError("server", "the Hub returned a message this agent did not submit — refusing a non-self-list result.");
297
+ }
298
+ // Project every row to a pure INDEX (drop the answer body + bulky detail payloads); see toIndexRow.
299
+ const index = messages.map(toIndexRow);
300
+ // Redact the bearer from every field before the caller prints it to stdout / the --json envelope —
301
+ // a hostile/buggy Hub could echo the Authorization value into a title/id. Round-trips the array
302
+ // through redactToken so a match in ANY nested string is scrubbed (mirrors the MCP client's
303
+ // #redact). Fail CLOSED on the pathological case where the re-parse fails: never return the
304
+ // un-redacted array (that would leak the bearer) — surface a clean server error instead.
305
+ const redacted = redactToken(JSON.stringify(index), token);
306
+ if (redacted === undefined)
307
+ return index;
308
+ try {
309
+ return JSON.parse(redacted);
310
+ }
311
+ catch {
312
+ throw new CliError("server", "the Hub response could not be safely processed.");
313
+ }
314
+ }
149
315
  /** POST a single batched consume-ack for the drained ids — the §14 directive receipt (agent bearer).
150
316
  * A `2xx` returns the consumed count; every other status maps to a §7 exit code. */
151
317
  export async function ackInbox(ctx, token, ids) {
@@ -165,7 +331,7 @@ export async function ackInbox(ctx, token, ids) {
165
331
  }
166
332
  if (res.status < 200 || res.status >= 300) {
167
333
  const { code, message } = await readErrorEnvelope(res);
168
- throw hubStatusToCliError(res.status, redactToken(message, token), code);
334
+ throw statusToCliError(res.status, redactToken(message, token), code);
169
335
  }
170
336
  const body = await readJson(res);
171
337
  // The Hub returns the ids it actually consumed (spec §14; server `ackDirectives` → string[]), not a
@@ -194,12 +360,43 @@ function isInboundDelivery(value) {
194
360
  return false;
195
361
  return d.priority === undefined || typeof d.priority === "string";
196
362
  }
363
+ /** Structural guard for one self-list row — an INDEX item carrying the string `id` / `type` /
364
+ * `status` the renderer reads. The Hub strips `response`/`delivery`, so those are absent by design;
365
+ * a row missing any of the three keyed strings is a malformed body, not a usable list item. */
366
+ function isListItem(value) {
367
+ if (value === null || typeof value !== "object")
368
+ return false;
369
+ const { id, type, status } = value;
370
+ return typeof id === "string" && typeof type === "string" && typeof status === "string";
371
+ }
372
+ /** Project a self-list row down to an INDEX shape (mirrors the server's `toSearchResultBody`): drop
373
+ * the terminal answer payload (`response`/`delivery` — never render a resolved answer from the bulk
374
+ * list) AND the bulky detail payloads (`context`/`state`/`request`/`action`, which can be large or
375
+ * opaque). A history list must stay lightweight; the agent pulls a specific message's detail via
376
+ * GET /:id. Keeps the index fields (id/type/title/body/status/created_at/priority/agent/…). */
377
+ function toIndexRow(row) {
378
+ const rest = { ...row };
379
+ for (const field of ["response", "delivery", "context", "state", "request", "action"]) {
380
+ delete rest[field];
381
+ }
382
+ return rest;
383
+ }
197
384
  /** Valid lifecycle statuses per message type (spec §7). A status valid for one type but not the
198
385
  * message's actual type is a malformed body. `delivered` is terminal-on-acceptance for `notify`
199
386
  * ONLY — ask/task go from `open` straight to a resolution, so a `{type:"ask", status:"delivered"}`
200
387
  * must be rejected (else the await loop would treat it as non-terminal and hang to timeout). */
388
+ /** The v0.5 §14.2 DELIVERY-track states belong to the NOTIFY set only.
389
+ *
390
+ * An addressed notify's lifecycle IS the delivery track, so `status` legitimately reads
391
+ * `queued`/`acknowledged`/`bounced`/`expired` there (spec §5.1). An addressed ASK/TASK keeps the §7
392
+ * resolution machinery at `status` — an undeliverable track terminal auto-resolves it to
393
+ * `cancelled`/`dismissed` — and surfaces the outbound track under `mailbox` instead
394
+ * (v0.5 get-message.schema.json). Admitting the delivery states into the ask/task sets would let a
395
+ * malformed `{type:"ask", status:"bounced"}` through, and `ask await` would then report it as a
396
+ * successful terminal resolution carrying no Response — the exact "status valid globally but
397
+ * impossible for this type" failure this table exists to catch. */
201
398
  const STATUSES_BY_TYPE = {
202
- notify: new Set(["open", "delivered"]),
399
+ notify: new Set(["open", "delivered", "queued", "acknowledged", "bounced", "expired"]),
203
400
  ask: new Set(["open", "answered", "declined", "cancelled", "expired"]),
204
401
  task: new Set(["open", "completed", "dismissed", "expired"]),
205
402
  };
@@ -209,13 +406,34 @@ function isValidTypeStatus(type, status) {
209
406
  const allowed = STATUSES_BY_TYPE[type];
210
407
  return allowed !== undefined && allowed.has(status);
211
408
  }
409
+ /** A `format: date-time` string, checked as far as is useful here: a parseable ISO timestamp. Used
410
+ * for the reachability anchor, where an unparseable value is as unusable as an absent one. */
411
+ function isIsoTimestamp(value) {
412
+ return typeof value === "string" && value.trim() !== "" && !Number.isNaN(Date.parse(value));
413
+ }
414
+ /** The §7 RESOLUTION states per type — the ones whose body MUST embed a terminal Response. A notify
415
+ * has no resolution track at all (its `expired` belongs to the §14.2 delivery track and carries no
416
+ * human answer), which is why this is per-type rather than one flat set: `expired` means two
417
+ * different things depending on which track owns the message. */
418
+ const RESOLUTIONS_BY_TYPE = {
419
+ ask: new Set(["answered", "declined", "cancelled", "expired"]),
420
+ task: new Set(["completed", "dismissed", "expired"]),
421
+ };
422
+ function isResolved(type, status) {
423
+ if (typeof type !== "string" || typeof status !== "string")
424
+ return false;
425
+ return RESOLUTIONS_BY_TYPE[type]?.has(status) === true;
426
+ }
212
427
  /** Map a Hub error response to a CliError. The exit code comes from the HTTP status; the stable
213
428
  * `error.code` string (§8) prefers the Hub's own `error.code` when it maps to the SAME exit tier
214
429
  * — so a documented code like `version_not_supported` survives instead of being flattened to
215
430
  * `bad_request` (both exit 9), without letting a Hub-supplied code change the exit semantics. The
216
431
  * server message is stripped of terminal control chars (the top-level catch prints it). */
217
- function hubStatusToCliError(status, message, hubCode) {
218
- const detail = message !== undefined ? sanitizeForTerminal(message) : `Hub returned ${status}.`;
432
+ export function statusToCliError(status, message, hubCode, hint) {
433
+ const base_ = message !== undefined ? sanitizeForTerminal(message) : `Hub returned ${status}.`;
434
+ // The hint is CLI-authored (never Hub-supplied), so it needs no sanitizing — but it is appended
435
+ // AFTER the Hub's own message so the verbatim server text stays the headline.
436
+ const detail = hint !== undefined ? `${base_} — ${hint}` : base_;
219
437
  const base = statusToErrorCode(status);
220
438
  // Preserve the Hub's code ONLY when it is a documented §8 code AND maps to the same exit tier —
221
439
  // an unrecognized code (e.g. `rate_limited` on a 429) must not leak into the CLI envelope, and a
@@ -229,7 +447,10 @@ function hubStatusToCliError(status, message, hubCode) {
229
447
  function statusToErrorCode(status) {
230
448
  if (status === 401 || status === 403)
231
449
  return "auth";
232
- if (status === 404)
450
+ // 410 Gone joins 404: the named resource is no longer usable (a terminal destination session,
451
+ // §8.5 `destination_gone`). Distinguishing it from 404 at the EXIT-code level would buy a caller
452
+ // nothing — both mean "re-address"; the Hub's own message carries the distinction.
453
+ if (status === 404 || status === 410)
233
454
  return "not_found";
234
455
  if (status === 409)
235
456
  return "conflict";