@paneui/cli 0.0.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.
@@ -0,0 +1,196 @@
1
+ // `pane create` — create a session via POST /v1/sessions.
2
+ import { createSessionSchema } from "@paneui/core";
3
+ import { makeClient } from "../config.js";
4
+ import { resolveJson, resolveText } from "../input.js";
5
+ import { printJson, fail, failFromError } from "../output.js";
6
+ export const createHelp = `pane create — create a Pane session
7
+
8
+ A session is one use of an artifact. Supply the artifact in ONE of two ways:
9
+
10
+ Reference form — instance an existing reusable artifact (the cheap path,
11
+ no HTML re-sent):
12
+ pane create --artifact-id <id|slug> [--version <n>] [--input-data <v>]
13
+
14
+ Inline form — a one-off artifact, defined on this call:
15
+ pane create --artifact <path|inline> [--event-schema <path|json>] [options]
16
+
17
+ Exactly one of --artifact-id / --artifact must be given.
18
+
19
+ Artifact (choose one):
20
+ --artifact-id <v> Reference an existing named artifact by id or slug.
21
+ Tip: run 'pane artifact search <keywords>' first — a
22
+ suitable artifact may already exist; reuse it instead of
23
+ regenerating HTML.
24
+ --version <n> With --artifact-id: pin a specific version. Defaults to
25
+ the artifact's latest version.
26
+ --artifact <v> Inline HTML artifact. Either a file path / URL, or inline
27
+ HTML. Combine with --artifact-type to control reading.
28
+ --event-schema <v> Inline-form event schema. A .json file, or inline JSON.
29
+ Optional with --artifact. Omit for a view-only artifact
30
+ (a report/dashboard the human only views — no page/agent
31
+ events). Ignored with --artifact-id.
32
+
33
+ Shape — an object with an "events" map, keyed by event
34
+ type. Each entry declares who may emit it and the JSON
35
+ Schema for its payload:
36
+ {
37
+ "events": {
38
+ "form.submitted": {
39
+ "emittedBy": ["page"],
40
+ "payload": {
41
+ "type": "object",
42
+ "properties": { "answer": { "type": "string" } },
43
+ "required": ["answer"]
44
+ }
45
+ }
46
+ }
47
+ }
48
+ emittedBy is any non-empty subset of ["page", "agent"].
49
+ payload is a JSON Schema; the relay validates every
50
+ emit against it. See docs/SPEC.md for the full grammar.
51
+
52
+ Options:
53
+ --input-data <v> This instance's seed data — a JSON object (file path or
54
+ inline JSON), validated by the relay against the artifact
55
+ version's input_schema. The page reads it as
56
+ window.pane.inputData.
57
+ --artifact-type <t> "html-inline" (default) or "html-ref". With "html-ref"
58
+ the --artifact value is treated as a URL. Note: the relay
59
+ does not serve "html-ref" artifacts in this release and
60
+ will reject the session — use "html-inline".
61
+ --ttl <seconds> Session time-to-live in seconds.
62
+ --participants <n> Number of human participants (default 1).
63
+ --metadata <path|json> Arbitrary metadata object (file path or inline JSON).
64
+ --callback <path|json> Webhook callback config: { url, events[], secret }.
65
+ --url <url> Relay base URL (overrides PANE_URL).
66
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
67
+ -h, --help Show this help.
68
+
69
+ Output (stdout, JSON):
70
+ { session_id, urls, tokens, expires_at }
71
+
72
+ Deliver urls.humans to the human(s); keep tokens.agent for the WS stream.`;
73
+ export async function runCreate(args) {
74
+ const artifactIdVal = args.flags.get("artifact-id");
75
+ const artifactVal = args.flags.get("artifact");
76
+ // Exactly one of the two artifact forms must be present.
77
+ if (artifactIdVal !== undefined && artifactVal !== undefined) {
78
+ fail("pass only one of --artifact-id (reference an existing artifact) or --artifact (inline a one-off)", "invalid_args");
79
+ }
80
+ if (artifactIdVal === undefined && artifactVal === undefined) {
81
+ fail("missing artifact — pass --artifact-id <id|slug> to reference an existing artifact, or --artifact <path|inline> to inline one", "invalid_args");
82
+ }
83
+ // Assemble a candidate request object, then validate the whole thing with
84
+ // the shared Zod schema (single source of truth, matches what the relay
85
+ // expects). Per-field number parsing still happens here so we can give a
86
+ // flag-specific message; the schema then enforces shape and bounds.
87
+ const candidate = {};
88
+ if (artifactIdVal !== undefined) {
89
+ // Reference form — instance an existing named artifact. --artifact /
90
+ // --event-schema are not needed here.
91
+ const ref = { id: artifactIdVal };
92
+ const versionRaw = args.flags.get("version");
93
+ if (versionRaw !== undefined) {
94
+ const version = Number(versionRaw);
95
+ if (!Number.isInteger(version) || version < 1) {
96
+ fail("--version must be a positive integer", "invalid_args");
97
+ }
98
+ ref["version"] = version;
99
+ }
100
+ candidate["artifact"] = ref;
101
+ }
102
+ else {
103
+ // Inline form — the event schema rides inside the `artifact` object; the
104
+ // relay transparently creates an anonymous artifact behind it.
105
+ // --event-schema is optional: omitting it makes a view-only one-off (a
106
+ // report/dashboard the human only views), and the relay then rejects every
107
+ // page/agent emit.
108
+ const schemaVal = args.flags.get("event-schema");
109
+ const artifactType = (args.flags.get("artifact-type") ?? "html-inline");
110
+ if (artifactType !== "html-inline" && artifactType !== "html-ref") {
111
+ fail("--artifact-type must be 'html-inline' or 'html-ref'", "invalid_args");
112
+ }
113
+ // html-ref: the value is a URL, used verbatim. html-inline: file or literal.
114
+ let source;
115
+ try {
116
+ source =
117
+ artifactType === "html-ref" ? artifactVal : resolveText(artifactVal);
118
+ }
119
+ catch (e) {
120
+ fail(e instanceof Error ? e.message : String(e), "invalid_args");
121
+ }
122
+ // Build the inline artifact object. event_schema is OMITTED entirely (not
123
+ // set to undefined) when --event-schema is absent — a view-only artifact.
124
+ const inlineArtifact = {
125
+ type: artifactType,
126
+ source,
127
+ };
128
+ if (schemaVal !== undefined) {
129
+ try {
130
+ inlineArtifact["event_schema"] = resolveJson(schemaVal, "--event-schema");
131
+ }
132
+ catch (e) {
133
+ fail(e instanceof Error ? e.message : String(e), "invalid_args");
134
+ }
135
+ }
136
+ candidate["artifact"] = inlineArtifact;
137
+ }
138
+ // --input-data — per-instance seed data, applies to either form (the relay
139
+ // validates it against the pinned version's input_schema).
140
+ const inputDataRaw = args.flags.get("input-data");
141
+ if (inputDataRaw !== undefined) {
142
+ try {
143
+ candidate["input_data"] = resolveJson(inputDataRaw, "--input-data");
144
+ }
145
+ catch (e) {
146
+ fail(e instanceof Error ? e.message : String(e), "invalid_args");
147
+ }
148
+ }
149
+ const ttlRaw = args.flags.get("ttl");
150
+ if (ttlRaw !== undefined) {
151
+ const ttl = Number(ttlRaw);
152
+ if (!Number.isFinite(ttl))
153
+ fail("--ttl must be a number", "invalid_args");
154
+ candidate["ttl"] = ttl;
155
+ }
156
+ const partRaw = args.flags.get("participants");
157
+ if (partRaw !== undefined) {
158
+ const humans = Number(partRaw);
159
+ if (!Number.isFinite(humans))
160
+ fail("--participants must be a number", "invalid_args");
161
+ candidate["participants"] = { humans };
162
+ }
163
+ const metaRaw = args.flags.get("metadata");
164
+ if (metaRaw !== undefined) {
165
+ try {
166
+ candidate["metadata"] = resolveJson(metaRaw, "--metadata");
167
+ }
168
+ catch (e) {
169
+ fail(e instanceof Error ? e.message : String(e), "invalid_args");
170
+ }
171
+ }
172
+ const cbRaw = args.flags.get("callback");
173
+ if (cbRaw !== undefined) {
174
+ try {
175
+ candidate["callback"] = resolveJson(cbRaw, "--callback");
176
+ }
177
+ catch (e) {
178
+ fail(e instanceof Error ? e.message : String(e), "invalid_args");
179
+ }
180
+ }
181
+ const parsed = createSessionSchema.safeParse(candidate);
182
+ if (!parsed.success) {
183
+ const issue = parsed.error.issues[0];
184
+ const where = issue && issue.path.length > 0 ? issue.path.join(".") : "request";
185
+ fail(`invalid create request: ${where}: ${issue ? issue.message : "validation failed"}`, "invalid_args", parsed.error.flatten());
186
+ }
187
+ const req = parsed.data;
188
+ const client = makeClient(args);
189
+ try {
190
+ const res = await client.createSession(req);
191
+ printJson(res);
192
+ }
193
+ catch (e) {
194
+ failFromError(e);
195
+ }
196
+ }
@@ -0,0 +1,31 @@
1
+ // `pane delete <id>` — close/delete a session.
2
+ import { makeClient } from "../config.js";
3
+ import { printJson, fail, failFromError } from "../output.js";
4
+ export const deleteHelp = `pane delete — close/delete a session
5
+
6
+ Usage:
7
+ pane delete <session-id> [options]
8
+
9
+ Closes and deletes the session (DELETE /v1/sessions/:id). Idempotent on the
10
+ relay side — deleting an already-closed session still succeeds.
11
+
12
+ Options:
13
+ --url <url> Relay base URL (overrides PANE_URL).
14
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
15
+ -h, --help Show this help.
16
+
17
+ Output (stdout, JSON):
18
+ { session_id, deleted: true }`;
19
+ export async function runDelete(args) {
20
+ const sessionId = args.positionals[0];
21
+ if (!sessionId)
22
+ fail("missing <session-id>", "invalid_args");
23
+ const client = makeClient(args);
24
+ try {
25
+ await client.deleteSession(sessionId);
26
+ printJson({ session_id: sessionId, deleted: true });
27
+ }
28
+ catch (e) {
29
+ failFromError(e);
30
+ }
31
+ }
@@ -0,0 +1,76 @@
1
+ // `pane keys` — inspect or revoke the calling agent's API key.
2
+ //
3
+ // Flat command namespace: `keys` is one top-level command that branches on a
4
+ // positional subcommand (list / revoke). The relay scopes /v1/keys to the
5
+ // authenticated agent, so there is exactly one key — the caller's own. Both
6
+ // subcommands therefore act ONLY on the caller's own key.
7
+ import { makeClient } from "../config.js";
8
+ import { printJson, fail, failFromError } from "../output.js";
9
+ export const keysHelp = `pane keys — inspect or revoke YOUR agent's API key
10
+
11
+ Usage:
12
+ pane keys <subcommand> [options]
13
+
14
+ Subcommands:
15
+ list Show YOUR agent's key info. The relay scopes keys to the
16
+ authenticated agent — there is exactly one key per agent, your
17
+ own. Prints { agent_id, name, key_prefix, created_at,
18
+ last_used_at, revoked_at }.
19
+
20
+ revoke Revoke YOUR OWN API key — a self-destruct. The key stops working
21
+ IMMEDIATELY; every subsequent command fails until you run
22
+ 'pane register' again to provision a new key. The relay only
23
+ allows revoking your own key. Requires --yes to confirm.
24
+ Prints { revoked: true, agent_id }.
25
+
26
+ Options:
27
+ --yes Confirm 'keys revoke' (required — it is irreversible).
28
+ --url <url> Relay base URL (overrides PANE_URL).
29
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
30
+ -h, --help Show this help.
31
+
32
+ Output: stdout is machine-readable JSON.`;
33
+ async function runKeysList(args) {
34
+ const client = makeClient(args);
35
+ try {
36
+ const info = await client.listKeys();
37
+ printJson(info);
38
+ }
39
+ catch (e) {
40
+ failFromError(e);
41
+ }
42
+ }
43
+ async function runKeysRevoke(args) {
44
+ if (!args.bools.has("yes")) {
45
+ fail("'pane keys revoke' revokes YOUR OWN API key — it stops working " +
46
+ "immediately and is irreversible. Pass --yes to confirm.", "confirmation_required");
47
+ }
48
+ const client = makeClient(args);
49
+ try {
50
+ // The relay only permits revoking the caller's own key. If a positional id
51
+ // is given, pass it through and let the relay 403 a wrong one; otherwise
52
+ // resolve the caller's own id from GET /v1/keys.
53
+ const id = args.positionals[1] ?? (await client.listKeys()).agent_id;
54
+ await client.revokeKey(id);
55
+ printJson({ revoked: true, agent_id: id });
56
+ }
57
+ catch (e) {
58
+ failFromError(e);
59
+ }
60
+ }
61
+ export async function runKeys(args) {
62
+ const sub = args.positionals[0];
63
+ switch (sub) {
64
+ case "list":
65
+ await runKeysList(args);
66
+ break;
67
+ case "revoke":
68
+ await runKeysRevoke(args);
69
+ break;
70
+ case undefined:
71
+ fail("missing subcommand — usage: pane keys <list|revoke> (run 'pane keys --help')", "invalid_args");
72
+ break;
73
+ default:
74
+ fail(`unknown keys subcommand '${sub}' — expected list|revoke (run 'pane keys --help')`, "invalid_args");
75
+ }
76
+ }
@@ -0,0 +1,25 @@
1
+ // `pane logout` — clear the locally-saved relay URL + API key.
2
+ import { clearStore } from "../store.js";
3
+ import { printJson } from "../output.js";
4
+ export const logoutHelp = `pane logout — clear the saved relay URL + API key
5
+
6
+ Usage:
7
+ pane logout [options]
8
+
9
+ Deletes the CLI config file (\${XDG_CONFIG_HOME:-~/.config}/pane/config.json),
10
+ which holds the relay URL and the agent API key saved by 'pane register'.
11
+ Idempotent — no error if there is nothing to clear.
12
+
13
+ This only clears the LOCAL config. It does NOT revoke the key on the relay —
14
+ the key keeps working until it is revoked. To revoke it on the relay, use
15
+ 'pane keys revoke'.
16
+
17
+ Options:
18
+ -h, --help Show this help.
19
+
20
+ Output (stdout, JSON):
21
+ { cleared: true, path }`;
22
+ export async function runLogout() {
23
+ const path = clearStore();
24
+ printJson({ cleared: true, path });
25
+ }
@@ -0,0 +1,79 @@
1
+ // `pane register` — self-provision an agent API key from the relay.
2
+ //
3
+ // This is the one command that needs no API key: it is the call that obtains
4
+ // one. If the relay runs REGISTRATION_MODE=secret, pass the shared
5
+ // registration secret via --secret or PANE_REGISTER_SECRET. On success the key
6
+ // (and relay URL) are persisted to the CLI config file, so every later command
7
+ // works with only PANE_URL (or nothing) set.
8
+ import { registerAgent, PaneApiError } from "@paneui/core";
9
+ import { DEFAULT_RELAY_URL } from "../config.js";
10
+ import { printJson, fail } from "../output.js";
11
+ import { readStore, writeStore } from "../store.js";
12
+ export const registerHelp = `pane register — register this agent with the relay and save the key locally
13
+
14
+ Usage:
15
+ pane register [options]
16
+
17
+ Calls POST /v1/register, then saves the returned API key (and relay URL) to the
18
+ CLI config file — so afterwards every other command works with only PANE_URL
19
+ set (no PANE_API_KEY needed).
20
+
21
+ Options:
22
+ --name <n> Agent display name. The relay defaults it if omitted.
23
+ --url <url> Relay base URL. Falls back to PANE_URL, then the config
24
+ file, then the hosted Pane relay. Self-hosters set this.
25
+ --secret <s> Registration secret, sent as a Bearer token. Only needed
26
+ when the relay uses REGISTRATION_MODE=secret. Falls back
27
+ to the PANE_REGISTER_SECRET env var.
28
+ --print-key Also echo the full api_key in the output. By default the
29
+ key is only persisted to the config file, never printed.
30
+ -h, --help Show this help.
31
+
32
+ Output (stdout, JSON):
33
+ { agent_id, key_prefix, saved_to } (+ api_key when --print-key is given)
34
+
35
+ The API key is saved to the CLI config file (mode 0600); it is not printed
36
+ unless --print-key is passed.`;
37
+ export async function runRegister(args) {
38
+ const store = readStore();
39
+ const url = args.flags.get("url") ??
40
+ process.env.PANE_URL ??
41
+ store.url ??
42
+ DEFAULT_RELAY_URL;
43
+ const name = args.flags.get("name");
44
+ const secret = args.flags.get("secret") ?? process.env.PANE_REGISTER_SECRET ?? undefined;
45
+ let result;
46
+ try {
47
+ result = await registerAgent({
48
+ url: url.replace(/\/$/, ""),
49
+ ...(name !== undefined ? { name } : {}),
50
+ ...(secret !== undefined && secret !== "" ? { secret } : {}),
51
+ });
52
+ }
53
+ catch (e) {
54
+ if (e instanceof PaneApiError) {
55
+ if (e.status === 429) {
56
+ fail("registration rate limit exceeded — try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
57
+ }
58
+ fail(e.message, e.code, e.details, {
59
+ hint: e.hint,
60
+ retryable: e.retryable,
61
+ docs_url: e.docsUrl,
62
+ });
63
+ }
64
+ fail(e instanceof Error ? e.message : String(e), "internal");
65
+ }
66
+ const savedTo = writeStore({
67
+ url: url.replace(/\/$/, ""),
68
+ apiKey: result.api_key,
69
+ });
70
+ const out = {
71
+ agent_id: result.agent_id,
72
+ key_prefix: result.key_prefix,
73
+ saved_to: savedTo,
74
+ };
75
+ if (args.bools.has("print-key")) {
76
+ out["api_key"] = result.api_key;
77
+ }
78
+ printJson(out);
79
+ }
@@ -0,0 +1,58 @@
1
+ // `pane send <id>` — append an agent event to a session.
2
+ import { makeClient } from "../config.js";
3
+ import { resolveJson } from "../input.js";
4
+ import { printJson, fail, failFromError } from "../output.js";
5
+ export const sendHelp = `pane send — emit an agent event into a session
6
+
7
+ Usage:
8
+ pane send <session-id> --type <event-type> --data <path|json> [options]
9
+
10
+ POSTs an event to /v1/sessions/:id/events. The event is stamped as authored by
11
+ the agent (the relay derives identity from the API key — it cannot be spoofed).
12
+
13
+ Required:
14
+ --type <t> Event type. Must exist in the session's event schema
15
+ with the agent in its emittedBy list.
16
+ --data <v> Event payload: a file path to a .json file, or inline
17
+ JSON. Use --data 'null' or --data '{}' for no payload.
18
+
19
+ Options:
20
+ --causation-id <id> Opaque causation id stored verbatim on the event.
21
+ --idempotency-key <k> Dedup key — a repeat send with the same key is a no-op.
22
+ --url <url> Relay base URL (overrides PANE_URL).
23
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
24
+ -h, --help Show this help.
25
+
26
+ Output (stdout, JSON):
27
+ { event, deduped }`;
28
+ export async function runSend(args) {
29
+ const sessionId = args.positionals[0];
30
+ if (!sessionId)
31
+ fail("missing <session-id>", "invalid_args");
32
+ const type = args.flags.get("type");
33
+ if (!type)
34
+ fail("missing --type", "invalid_args");
35
+ const dataRaw = args.flags.get("data");
36
+ if (dataRaw === undefined)
37
+ fail("missing --data", "invalid_args");
38
+ let data;
39
+ try {
40
+ data = resolveJson(dataRaw, "--data");
41
+ }
42
+ catch (e) {
43
+ fail(e instanceof Error ? e.message : String(e), "invalid_args");
44
+ }
45
+ const client = makeClient(args);
46
+ try {
47
+ const res = await client.sendEvent(sessionId, {
48
+ type: type,
49
+ data,
50
+ causationId: args.flags.get("causation-id"),
51
+ idempotencyKey: args.flags.get("idempotency-key"),
52
+ });
53
+ printJson(res);
54
+ }
55
+ catch (e) {
56
+ failFromError(e);
57
+ }
58
+ }
@@ -0,0 +1,34 @@
1
+ // `pane state <id>` — non-blocking snapshot of a session.
2
+ import { makeClient } from "../config.js";
3
+ import { printJson, fail, failFromError } from "../output.js";
4
+ export const stateHelp = `pane state — show a session's metadata and event log
5
+
6
+ Usage:
7
+ pane state <session-id> [options]
8
+
9
+ Non-blocking. Fetches session metadata (GET /v1/sessions/:id) plus the event
10
+ log (GET /v1/sessions/:id/events) and prints them together.
11
+
12
+ Options:
13
+ --since <cursor> Only return events after this opaque cursor.
14
+ --url <url> Relay base URL (overrides PANE_URL).
15
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
16
+ -h, --help Show this help.
17
+
18
+ Output (stdout, JSON):
19
+ { meta, events, next_cursor }`;
20
+ export async function runState(args) {
21
+ const sessionId = args.positionals[0];
22
+ if (!sessionId)
23
+ fail("missing <session-id>", "invalid_args");
24
+ const since = args.flags.get("since") ?? null;
25
+ const client = makeClient(args);
26
+ try {
27
+ const meta = await client.getSession(sessionId);
28
+ const page = await client.getEvents(sessionId, { since });
29
+ printJson({ meta, events: page.events, next_cursor: page.next_cursor });
30
+ }
31
+ catch (e) {
32
+ failFromError(e);
33
+ }
34
+ }
@@ -0,0 +1,138 @@
1
+ // `pane watch <id>` — long-lived: hold a WebSocket and stream events as
2
+ // JSON-lines on stdout. This harness-agnostic stdout is the core contract:
3
+ // one compact JSON object per line, flushed after every event, so any
4
+ // pipe-reader (Claude Code's Monitor tool, `while read line`, jq -c, ...)
5
+ // sees each event the instant it lands.
6
+ import { openStream } from "@paneui/core";
7
+ import { resolveConfig } from "../config.js";
8
+ import { PaneClient } from "@paneui/core";
9
+ import { printJsonLine, fail } from "../output.js";
10
+ export const watchHelp = `pane watch — stream a session's events as JSON-lines
11
+
12
+ Usage:
13
+ pane watch <session-id> [options]
14
+
15
+ Holds a WebSocket to WS /v1/sessions/:id/stream. Prints ONE compact JSON
16
+ object per line to stdout, flushing after each — designed to be piped into a
17
+ line-reader. On session close, prints a final {"type":"_closed"} line and
18
+ exits 0.
19
+
20
+ Modes:
21
+ (bare) Run until SIGINT (Ctrl-C). Exit 0.
22
+ --once Exit 0 after the first event.
23
+ --type <t> Exit 0 after the first event whose type equals <t>.
24
+
25
+ Options:
26
+ --since <cursor> Replay only events after this opaque cursor.
27
+ --timeout <secs> Fail with code ws_timeout if no frame (not even the
28
+ replay-complete marker) arrives within this window.
29
+ --url <url> Relay base URL (overrides PANE_URL).
30
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
31
+ -h, --help Show this help.
32
+
33
+ Each line is one event envelope: { id, session_id, author, ts, type, data,
34
+ causation_id, idempotency_key }. The terminal line is {"type":"_closed"}.
35
+
36
+ Pattern — Claude Code Monitor tool: run \`pane watch <id> --type form.submitted\`
37
+ as a monitored process; the harness re-invokes the model when the line lands.`;
38
+ export async function runWatch(args) {
39
+ const sessionId = args.positionals[0];
40
+ if (!sessionId)
41
+ fail("missing <session-id>", "invalid_args");
42
+ const cfg = resolveConfig(args);
43
+ const since = args.flags.get("since") ?? null;
44
+ const waitType = args.flags.get("type") ?? null;
45
+ const once = args.bools.has("once");
46
+ let timeoutSec = null;
47
+ const timeoutRaw = args.flags.get("timeout");
48
+ if (timeoutRaw !== undefined) {
49
+ const t = Number(timeoutRaw);
50
+ if (!Number.isFinite(t) || t <= 0)
51
+ fail("--timeout must be a positive number", "invalid_args");
52
+ timeoutSec = t;
53
+ }
54
+ const client = new PaneClient({ url: cfg.url, apiKey: cfg.apiKey });
55
+ let exited = false;
56
+ const finish = (code) => {
57
+ if (exited)
58
+ return;
59
+ exited = true;
60
+ if (timer)
61
+ clearTimeout(timer);
62
+ process.exit(code);
63
+ };
64
+ // Emit the terminal marker exactly once, then exit 0.
65
+ let closedEmitted = false;
66
+ const emitClosed = () => {
67
+ if (!closedEmitted) {
68
+ closedEmitted = true;
69
+ printJsonLine({ type: "_closed" });
70
+ }
71
+ finish(0);
72
+ };
73
+ // Track whether the relay told us the session expired before the socket
74
+ // closed — a 1006/1008/1011 close after that is still a clean shutdown.
75
+ let sawSessionExpired = false;
76
+ // Connection timeout: fail if no frame at all arrives within the window.
77
+ let timer;
78
+ if (timeoutSec !== null) {
79
+ timer = setTimeout(() => {
80
+ fail(`no stream frame within ${timeoutSec}s`, "ws_timeout");
81
+ }, timeoutSec * 1000);
82
+ }
83
+ const sawFrame = () => {
84
+ if (timer) {
85
+ clearTimeout(timer);
86
+ timer = undefined;
87
+ }
88
+ };
89
+ const handle = openStream({
90
+ wsBaseUrl: client.wsBaseUrl,
91
+ sessionId: sessionId,
92
+ token: cfg.apiKey,
93
+ since,
94
+ }, {
95
+ onReplayComplete: () => {
96
+ sawFrame();
97
+ },
98
+ onEvent: (event) => {
99
+ sawFrame();
100
+ printJsonLine(event);
101
+ // A system.session.expired event means the session is closing.
102
+ if (event.type === "system.session.expired") {
103
+ sawSessionExpired = true;
104
+ emitClosed();
105
+ return;
106
+ }
107
+ if (once) {
108
+ finish(0);
109
+ return;
110
+ }
111
+ if (waitType !== null && event.type === waitType) {
112
+ finish(0);
113
+ }
114
+ },
115
+ onClose: ({ code, reason }) => {
116
+ // A clean close is 1000 (normal) or 1001 (going away). Any other code
117
+ // — 1006 abnormal, 1008 policy/auth, 1011 server error — is a failure
118
+ // UNLESS we already saw system.session.expired, which means the relay
119
+ // closed us on purpose after a clean session end.
120
+ if (code === 1000 || code === 1001 || sawSessionExpired) {
121
+ emitClosed();
122
+ return;
123
+ }
124
+ fail(`stream closed abnormally (code ${code})${reason ? ": " + reason : ""}`, "ws_closed_abnormally", { code, reason });
125
+ },
126
+ onRelayError: (err) => {
127
+ fail(err.message ?? "relay error", err.code ?? "relay_error", err.details);
128
+ },
129
+ onError: (err) => {
130
+ fail(err.message, "ws_error");
131
+ },
132
+ });
133
+ // SIGINT: clean shutdown, exit 0.
134
+ process.on("SIGINT", () => {
135
+ handle.close();
136
+ finish(0);
137
+ });
138
+ }