@paneui/cli 0.0.3 → 0.0.5

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.
@@ -1,7 +1,8 @@
1
1
  // `pane artifact` — manage reusable, versioned artifacts.
2
2
  //
3
3
  // Flat command namespace: `artifact` is one top-level command that branches on
4
- // a positional subcommand (create / version / update / search / list / show).
4
+ // a positional subcommand (create / version / update / search / list / show /
5
+ // delete).
5
6
  // An artifact is a reusable UI template (HTML + event schema + optional input
6
7
  // schema); a session is one *use* of one version of it. Authoring an artifact
7
8
  // once and instancing it via `pane create --artifact-id` removes the per-use
@@ -27,6 +28,9 @@ Subcommands:
27
28
  search Search the agent's named artifacts (lean — no HTML).
28
29
  list List the agent's named artifacts (search with no query).
29
30
  show Show a full artifact: head metadata + its version list.
31
+ delete Permanently delete an artifact and ALL its versions. Requires
32
+ --yes. Refused with 409 conflict if any session (open or
33
+ closed) still references the artifact — delete those first.
30
34
 
31
35
  pane artifact create --name <n> --artifact <path|inline>
32
36
  [--event-schema <path|json>] [--slug <s>]
@@ -54,6 +58,13 @@ Subcommands:
54
58
  pane artifact show <id|slug>
55
59
  Prints the full artifact: head metadata + every version's content.
56
60
 
61
+ pane artifact delete <id|slug> --yes
62
+ Permanently deletes the artifact and all its versions. Refused
63
+ (409 conflict) if any session in any state still references one
64
+ of the artifact's versions — run 'pane delete <session-id>' on
65
+ each first, or wait for the relay's TTL sweeper to reclaim them.
66
+ Prints { artifact, deleted: true } on success.
67
+
57
68
  Options:
58
69
  --name <n> Artifact display name (required for 'create').
59
70
  --slug <s> Stable, agent-chosen handle (unique per agent). The
@@ -301,6 +312,28 @@ async function runArtifactShow(args) {
301
312
  failFromError(e);
302
313
  }
303
314
  }
315
+ // `pane artifact delete <id|slug> --yes` — remove an artifact (and, server-
316
+ // side, all its versions). The relay refuses with 409 conflict if any
317
+ // session still references it; the CLI surfaces that as the relay-supplied
318
+ // envelope. `--yes` is required because there's no Undo button on a delete
319
+ // and the same `pane artifact create` slug isn't reservable once gone.
320
+ async function runArtifactDelete(args) {
321
+ const idOrSlug = args.positionals[1];
322
+ if (!idOrSlug) {
323
+ fail("missing artifact <id|slug> — usage: pane artifact delete <id|slug> --yes", "invalid_args");
324
+ }
325
+ if (!args.bools.has("yes")) {
326
+ fail("'pane artifact delete' permanently removes the artifact and all its versions — it is destructive. Pass --yes to confirm.", "invalid_args");
327
+ }
328
+ const client = makeClient(args);
329
+ try {
330
+ await client.deleteArtifact(idOrSlug);
331
+ printJson({ artifact: idOrSlug, deleted: true });
332
+ }
333
+ catch (e) {
334
+ failFromError(e);
335
+ }
336
+ }
304
337
  export async function runArtifact(args) {
305
338
  const sub = args.positionals[0];
306
339
  switch (sub) {
@@ -322,10 +355,13 @@ export async function runArtifact(args) {
322
355
  case "show":
323
356
  await runArtifactShow(args);
324
357
  break;
358
+ case "delete":
359
+ await runArtifactDelete(args);
360
+ break;
325
361
  case undefined:
326
- fail("missing subcommand — usage: pane artifact <create|version|update|search|list|show> (run 'pane artifact --help')", "invalid_args");
362
+ fail("missing subcommand — usage: pane artifact <create|version|update|search|list|show|delete> (run 'pane artifact --help')", "invalid_args");
327
363
  break;
328
364
  default:
329
- fail(`unknown artifact subcommand '${sub}' — expected create|version|update|search|list|show (run 'pane artifact --help')`, "invalid_args");
365
+ fail(`unknown artifact subcommand '${sub}' — expected create|version|update|search|list|show|delete (run 'pane artifact --help')`, "invalid_args");
330
366
  }
331
367
  }
@@ -3,6 +3,42 @@ import { createSessionSchema } from "@paneui/core";
3
3
  import { makeClient } from "../config.js";
4
4
  import { resolveJson, resolveText } from "../input.js";
5
5
  import { printJson, fail, failFromError } from "../output.js";
6
+ // Translate a Zod schema path (e.g. ["participants","humans"]) back to the
7
+ // public CLI flag the user actually typed. Without this, a `--participants 0`
8
+ // rejection surfaces as `participants.humans: ...` — which leaks the wire
9
+ // shape and refers to no flag the user could fix.
10
+ //
11
+ // Match strategy: longest prefix wins. Schema paths whose top segment isn't
12
+ // in the table fall back to dotted notation so we degrade gracefully on
13
+ // fields that don't have a single corresponding flag (e.g. `artifact.source`
14
+ // — there's no single --artifact-source flag for the inline form, just
15
+ // --artifact pointing at the whole blob).
16
+ const SCHEMA_PATH_TO_FLAG = {
17
+ participants: "--participants",
18
+ "participants.humans": "--participants",
19
+ ttl: "--ttl",
20
+ metadata: "--metadata",
21
+ callback: "--callback",
22
+ input_data: "--input-data",
23
+ "artifact.id": "--artifact-id",
24
+ "artifact.version": "--version",
25
+ "artifact.type": "--artifact-type",
26
+ "artifact.source": "--artifact",
27
+ "artifact.event_schema": "--event-schema",
28
+ };
29
+ function schemaPathToFlag(path) {
30
+ const dotted = path.map(String).join(".");
31
+ // Longest prefix that has a mapping. Try the full path first, then strip
32
+ // one trailing segment at a time. Falls back to dotted notation as the
33
+ // honest default.
34
+ for (let i = path.length; i > 0; i--) {
35
+ const prefix = path.slice(0, i).map(String).join(".");
36
+ const flag = SCHEMA_PATH_TO_FLAG[prefix];
37
+ if (flag !== undefined)
38
+ return flag;
39
+ }
40
+ return dotted;
41
+ }
6
42
  export const createHelp = `pane create — create a Pane session
7
43
 
8
44
  A session is one use of an artifact. Supply the artifact in ONE of two ways:
@@ -184,7 +220,7 @@ export async function runCreate(args) {
184
220
  const parsed = createSessionSchema.safeParse(candidate);
185
221
  if (!parsed.success) {
186
222
  const issue = parsed.error.issues[0];
187
- const where = issue && issue.path.length > 0 ? issue.path.join(".") : "request";
223
+ const where = issue && issue.path.length > 0 ? schemaPathToFlag(issue.path) : "request";
188
224
  fail(`invalid create request: ${where}: ${issue ? issue.message : "validation failed"}`, "invalid_args", parsed.error.flatten());
189
225
  }
190
226
  const req = parsed.data;
@@ -0,0 +1,127 @@
1
+ import { makeClient } from "../config.js";
2
+ import { printJson, fail, failFromError } from "../output.js";
3
+ export const feedbackHelp = `pane feedback — submit / list feedback to the relay operator
4
+
5
+ Feedback is a one-shot bug report, feature request, or note from YOUR agent
6
+ to whoever runs the relay. Submissions are stored in the relay DB; the
7
+ operator triages out of band.
8
+
9
+ Usage:
10
+ pane feedback <subcommand> [options]
11
+
12
+ Subcommands:
13
+ create Submit one feedback row. Requires --type and --message.
14
+ Prints { id, type, created_at } — the message is not echoed back.
15
+
16
+ list List YOUR agent's own submissions, newest first. Prints
17
+ { items: [...], next_before?: <cursor> }. Pass --before <cursor>
18
+ from a previous page to fetch the next page.
19
+
20
+ Options for 'create':
21
+ --type <bug|feature|note> Feedback category. Required.
22
+ --message <text|-> Message body. Pass '-' to read from stdin.
23
+ 1..4000 chars after trim.
24
+ --session-id <id> Optional session this feedback relates to;
25
+ must belong to YOUR agent.
26
+
27
+ Options for 'list':
28
+ --limit <N> Page size (default 50, max 100).
29
+ --before <cursor> Opaque cursor from a previous page's next_before.
30
+
31
+ Global:
32
+ --url <url> Relay base URL (overrides PANE_URL).
33
+ --api-key <key> Agent API key (overrides PANE_API_KEY).
34
+ -h, --help Show this help.
35
+
36
+ Examples:
37
+ pane feedback create --type bug --message "watch hangs on empty session"
38
+ echo "long-form note..." | pane feedback create --type note --message -
39
+ pane feedback list --limit 20
40
+
41
+ Output: stdout is machine-readable JSON.`;
42
+ const FEEDBACK_TYPES = ["bug", "feature", "note"];
43
+ async function readStdin() {
44
+ const chunks = [];
45
+ for await (const chunk of process.stdin) {
46
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
47
+ }
48
+ return Buffer.concat(chunks).toString("utf8");
49
+ }
50
+ async function runFeedbackCreate(args) {
51
+ const type = args.flags.get("type");
52
+ const rawMessage = args.flags.get("message");
53
+ const sessionId = args.flags.get("session-id");
54
+ if (type === undefined) {
55
+ fail("'pane feedback create' requires --type <bug|feature|note>", "invalid_args");
56
+ }
57
+ if (!FEEDBACK_TYPES.includes(type)) {
58
+ fail(`unknown --type '${type}' — expected one of: ${FEEDBACK_TYPES.join(", ")}`, "invalid_args");
59
+ }
60
+ if (rawMessage === undefined) {
61
+ fail("'pane feedback create' requires --message <text|-> (use '-' to read from stdin)", "invalid_args");
62
+ }
63
+ let message;
64
+ if (rawMessage === "-") {
65
+ if (process.stdin.isTTY) {
66
+ fail("'pane feedback create --message -' expects feedback on stdin, but stdin is a TTY", "invalid_args");
67
+ }
68
+ message = await readStdin();
69
+ }
70
+ else {
71
+ message = rawMessage;
72
+ }
73
+ if (message.trim().length === 0) {
74
+ fail("feedback message must not be empty or whitespace-only", "invalid_args");
75
+ }
76
+ const client = makeClient(args);
77
+ try {
78
+ const res = await client.submitFeedback({
79
+ type: type,
80
+ message,
81
+ ...(sessionId !== undefined ? { sessionId } : {}),
82
+ });
83
+ printJson(res);
84
+ }
85
+ catch (e) {
86
+ failFromError(e);
87
+ }
88
+ }
89
+ async function runFeedbackList(args) {
90
+ const limitRaw = args.flags.get("limit");
91
+ const before = args.flags.get("before");
92
+ let limit;
93
+ if (limitRaw !== undefined) {
94
+ const n = Number(limitRaw);
95
+ if (!Number.isInteger(n) || n <= 0) {
96
+ fail(`--limit must be a positive integer, got '${limitRaw}'`, "invalid_args");
97
+ }
98
+ limit = n;
99
+ }
100
+ const client = makeClient(args);
101
+ try {
102
+ const page = await client.listFeedback({
103
+ ...(limit !== undefined ? { limit } : {}),
104
+ ...(before !== undefined ? { before } : {}),
105
+ });
106
+ printJson(page);
107
+ }
108
+ catch (e) {
109
+ failFromError(e);
110
+ }
111
+ }
112
+ export async function runFeedback(args) {
113
+ const sub = args.positionals[0];
114
+ switch (sub) {
115
+ case "create":
116
+ await runFeedbackCreate(args);
117
+ break;
118
+ case "list":
119
+ await runFeedbackList(args);
120
+ break;
121
+ case undefined:
122
+ fail("missing subcommand — usage: pane feedback <create|list> (run 'pane feedback --help')", "invalid_args");
123
+ break;
124
+ default:
125
+ fail(`unknown feedback subcommand '${sub}' — expected create|list (run 'pane feedback --help')`, "invalid_args");
126
+ }
127
+ }
@@ -7,8 +7,9 @@
7
7
  // works with only PANE_URL (or nothing) set.
8
8
  import { registerAgent, PaneApiError } from "@paneui/core";
9
9
  import { DEFAULT_RELAY_URL } from "../config.js";
10
- import { printJson, fail } from "../output.js";
10
+ import { printJson, fail, failUpgradeRequired } from "../output.js";
11
11
  import { readStore, writeStore } from "../store.js";
12
+ import { VERSION } from "../version.js";
12
13
  export const registerHelp = `pane register — register this agent with the relay and save the key locally
13
14
 
14
15
  Usage:
@@ -48,10 +49,17 @@ export async function runRegister(args) {
48
49
  url: url.replace(/\/$/, ""),
49
50
  ...(name !== undefined ? { name } : {}),
50
51
  ...(secret !== undefined && secret !== "" ? { secret } : {}),
52
+ cliVersion: VERSION,
51
53
  });
52
54
  }
53
55
  catch (e) {
54
56
  if (e instanceof PaneApiError) {
57
+ // 426 cli_upgrade_required goes through the shared upgrade-message
58
+ // path (stderr block + exit 75) so the SKILL.md's instructions to the
59
+ // agent's harness fire on `pane register` too.
60
+ if (e.status === 426 && e.code === "cli_upgrade_required") {
61
+ failUpgradeRequired(e);
62
+ }
55
63
  if (e.status === 429) {
56
64
  fail("registration rate limit exceeded — try again later", "rate_limited", undefined, { hint: e.hint, retryable: e.retryable, docs_url: e.docsUrl });
57
65
  }
@@ -0,0 +1,136 @@
1
+ // `pane skill` — fetch the relay's SKILL.md, or just its version.
2
+ //
3
+ // The relay serves its skill at GET /skills/pane/SKILL.md and its version
4
+ // at GET /skills/pane/SKILL.md/version (see
5
+ // packages/relay/src/http/routes/skill.ts). The skill is auto-updating:
6
+ // the relay's deployed image owns both the body and the version, so the
7
+ // agent always reads what the relay it's actually talking to wants it
8
+ // to read.
9
+ //
10
+ // Two subcommands:
11
+ // `pane skill` — print the full markdown to stdout (the
12
+ // install / refresh path; pipe to a file).
13
+ // `pane skill version` — print just the relay's skill version (the
14
+ // "is my local copy stale?" probe). The agent
15
+ // compares this to the `<!-- pane skill v… -->`
16
+ // comment in its local skill file and re-runs
17
+ // `pane skill > <path>` when they differ.
18
+ //
19
+ // Both are unauthenticated — the skill route is public on the relay and
20
+ // an agent on a too-old CLI must be able to read the upgrade instructions
21
+ // even before it has registered (or before its key was minted).
22
+ import { resolveRelayUrl } from "../config.js";
23
+ import { fail } from "../output.js";
24
+ import { VERSION } from "../version.js";
25
+ export const skillHelp = `pane skill — fetch the relay's SKILL.md (or its version)
26
+
27
+ Usage:
28
+ pane skill Print the full skill to stdout.
29
+ pane skill version [--plain] Print just the relay's skill version.
30
+
31
+ The skill is auto-updating: the relay's deployed image owns the version,
32
+ so this is always the skill that matches the relay you are talking to.
33
+
34
+ Unauthenticated — no API key needed. An agent can call either form
35
+ before 'pane register' to bootstrap or refresh its local skill copy.
36
+
37
+ Subcommands:
38
+ (bare) Fetch GET /skills/pane/SKILL.md and write the raw
39
+ markdown to stdout. Pipe to your local skill path:
40
+ pane skill > ~/.claude/skills/pane/SKILL.md
41
+ version Fetch GET /skills/pane/SKILL.md/version and print
42
+ the relay's skill version. Default output is the
43
+ JSON envelope; --plain prints just the version
44
+ string so an agent can compare it inline in shell.
45
+
46
+ Options:
47
+ --plain (with 'version' only) print the bare version
48
+ string on stdout, no JSON envelope. Useful inside
49
+ a shell pipeline: \`if [ "$(pane skill version
50
+ --plain)" != "$LOCAL" ]; then ...\`.
51
+ --url <url> Relay base URL (overrides PANE_URL).
52
+ -h, --help Show this help.
53
+
54
+ Output (stdout):
55
+ (bare) Raw markdown, as served by the relay.
56
+ version { "version": "1.0.0" } — or '1.0.0\\n' with --plain.
57
+
58
+ Errors (stderr): { "error": { "code", "message" } } and non-zero exit.`;
59
+ // Shared fetch with the consistent x-pane-cli-version header (the skill
60
+ // routes are exempt from the version-skew middleware, but sending it lets
61
+ // access logs see which CLI versions are reading the skill).
62
+ async function fetchOrFail(url) {
63
+ try {
64
+ return await fetch(url, {
65
+ headers: { "x-pane-cli-version": VERSION },
66
+ });
67
+ }
68
+ catch (e) {
69
+ const msg = e instanceof Error ? e.message : String(e);
70
+ fail(`could not reach ${url}: ${msg}`, "fetch_error");
71
+ }
72
+ }
73
+ async function failOnNon2xx(res, target) {
74
+ if (res.ok)
75
+ return;
76
+ // 404 if the operator stripped the route, 5xx on a static-read failure.
77
+ // Surface the body inline — it may carry a useful message.
78
+ const body = await res.text().catch(() => "");
79
+ fail(`relay returned ${res.status} for ${target}${body ? ": " + body.slice(0, 200) : ""}`, "relay_error");
80
+ }
81
+ // `pane skill` (no positional) — print the full skill.
82
+ async function runSkillFetch(args) {
83
+ const url = resolveRelayUrl(args);
84
+ const target = url + "/skills/pane/SKILL.md";
85
+ const res = await fetchOrFail(target);
86
+ await failOnNon2xx(res, target);
87
+ const text = await res.text();
88
+ process.stdout.write(text);
89
+ // Ensure the markdown ends with a newline so a pipe-reader (cat | xargs |
90
+ // claude) sees a clean line-terminated boundary even if the relay served
91
+ // a file without a trailing newline.
92
+ if (!text.endsWith("\n"))
93
+ process.stdout.write("\n");
94
+ }
95
+ // `pane skill version [--plain]` — print just the version.
96
+ async function runSkillVersion(args) {
97
+ const url = resolveRelayUrl(args);
98
+ const target = url + "/skills/pane/SKILL.md/version";
99
+ const res = await fetchOrFail(target);
100
+ await failOnNon2xx(res, target);
101
+ // The relay returns { version: "x.y.z" }. We tolerate a missing/
102
+ // malformed body so a misbehaving relay can't crash this probe — fall
103
+ // through to "0.0.0" the same way the relay does when its own SKILL.md
104
+ // lacks a version comment.
105
+ let body;
106
+ try {
107
+ body = await res.json();
108
+ }
109
+ catch {
110
+ body = null;
111
+ }
112
+ const version = body !== null &&
113
+ typeof body === "object" &&
114
+ typeof body.version === "string"
115
+ ? body.version
116
+ : "0.0.0";
117
+ if (args.bools.has("plain")) {
118
+ process.stdout.write(version + "\n");
119
+ }
120
+ else {
121
+ process.stdout.write(JSON.stringify({ version }) + "\n");
122
+ }
123
+ }
124
+ export async function runSkill(args) {
125
+ const sub = args.positionals[0];
126
+ switch (sub) {
127
+ case undefined:
128
+ await runSkillFetch(args);
129
+ break;
130
+ case "version":
131
+ await runSkillVersion(args);
132
+ break;
133
+ default:
134
+ fail(`unknown skill subcommand '${sub}' — expected 'version' or no subcommand (run 'pane skill --help')`, "invalid_args");
135
+ }
136
+ }
@@ -1,4 +1,4 @@
1
- // `pane state <id>` — non-blocking snapshot of a session.
1
+ // `pane state <id>` — snapshot of a session, optionally long-polled.
2
2
  import { makeClient } from "../config.js";
3
3
  import { printJson, fail, failFromError } from "../output.js";
4
4
  export const stateHelp = `pane state — show a session's metadata and event log
@@ -6,11 +6,24 @@ export const stateHelp = `pane state — show a session's metadata and event log
6
6
  Usage:
7
7
  pane state <session-id> [options]
8
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.
9
+ By default non-blocking: fetches session metadata (GET /v1/sessions/:id) plus
10
+ the event log (GET /v1/sessions/:id/events) and prints them together.
11
+
12
+ With --wait, blocks at the relay for up to <secs> if no new events are
13
+ available since the cursor — returns as soon as something lands. Use this
14
+ for headless polling agents that can't keep a WebSocket open (cron,
15
+ FaaS, slow links): poll, then re-poll using next_cursor as --since on the
16
+ next call. Compared to 'pane watch', it's higher latency per round-trip
17
+ but no long-lived connection.
11
18
 
12
19
  Options:
13
- --since <cursor> Only return events after this opaque cursor.
20
+ --since <cursor> Only return events after this opaque cursor. Pass
21
+ next_cursor from the previous call to chain pages.
22
+ --wait <secs> Long-poll window. The relay holds the request open
23
+ for up to this many seconds, capped server-side at
24
+ 30. Without --since, this still returns immediately
25
+ with whatever events exist — long-poll only blocks
26
+ when there are NO new events to return.
14
27
  --url <url> Relay base URL (overrides PANE_URL).
15
28
  --api-key <key> Agent API key (overrides PANE_API_KEY).
16
29
  -h, --help Show this help.
@@ -22,10 +35,26 @@ export async function runState(args) {
22
35
  if (!sessionId)
23
36
  fail("missing <session-id>", "invalid_args");
24
37
  const since = args.flags.get("since") ?? null;
38
+ // --wait <secs>: hand the server the long-poll window. The relay caps
39
+ // this at 30s; we pass the raw value and let the relay clamp (sending
40
+ // a higher number is not an error, just a clamp). 0 or unset means
41
+ // non-blocking — the default snapshot behaviour.
42
+ let waitSeconds;
43
+ const waitRaw = args.flags.get("wait");
44
+ if (waitRaw !== undefined) {
45
+ const n = Number(waitRaw);
46
+ if (!Number.isFinite(n) || n < 0) {
47
+ fail("--wait must be a non-negative number of seconds", "invalid_args");
48
+ }
49
+ waitSeconds = n;
50
+ }
25
51
  const client = makeClient(args);
26
52
  try {
27
53
  const meta = await client.getSession(sessionId);
28
- const page = await client.getEvents(sessionId, { since });
54
+ const page = await client.getEvents(sessionId, {
55
+ since,
56
+ ...(waitSeconds !== undefined ? { waitSeconds } : {}),
57
+ });
29
58
  printJson({ meta, events: page.events, next_cursor: page.next_cursor });
30
59
  }
31
60
  catch (e) {
@@ -37,16 +37,18 @@ Subcommands:
37
37
  { taste: string|null, updated_at: string|null, bytes: number }.
38
38
  taste is null and bytes is 0 when notes have never been written.
39
39
 
40
- set Whole-blob replace. Read markdown from stdin OR --file <path>
41
- (exactly one). The relay rejects empty/whitespace-only payloads
42
- and caps the blob at MAX_TASTE_BYTES (utf8). To clear the notes,
40
+ set Whole-blob replace. Source the markdown via --file <path>,
41
+ --file - (read stdin), or by piping into 'pane taste set' with
42
+ no flag. The relay rejects empty/whitespace-only payloads and
43
+ caps the blob at MAX_TASTE_BYTES (utf8). To clear the notes,
43
44
  use 'pane taste clear', not 'set' with an empty body.
44
45
 
45
46
  clear Delete the notes. Requires --yes (it is destructive). Prints
46
47
  { cleared: true }.
47
48
 
48
49
  Options:
49
- --file <path> Read 'set' input from <path> (otherwise reads stdin).
50
+ --file <path|-> Source for 'set' a file path, or '-' to read stdin
51
+ explicitly. Omit to fall back to piped stdin.
50
52
  --yes Confirm 'clear'.
51
53
  --url <url> Relay base URL (overrides PANE_URL).
52
54
  --api-key <key> Agent API key (overrides PANE_API_KEY).
@@ -54,17 +56,17 @@ Options:
54
56
 
55
57
  Examples:
56
58
  pane taste get
57
- echo "- denser layout\\n- no rounded corners" | pane taste set
58
59
  pane taste set --file ./taste.md
60
+ pane taste set --file - # explicit stdin
61
+ echo "- denser layout" | pane taste set
59
62
  pane taste clear --yes
60
63
 
61
64
  Output: stdout is machine-readable JSON.`;
62
- // Drain process.stdin to a utf8 string. Resolves to "" when stdin is a TTY
63
- // (i.e. nothing was piped in), so the caller can detect "no input" and emit a
64
- // proper error instead of hanging forever waiting for keystrokes.
65
+ // Drain process.stdin to a utf8 string. The caller is responsible for
66
+ // deciding that stdin should be read (e.g. an explicit `--file -`, or a
67
+ // non-TTY stdin where data is actually piped). In a TTY this would block
68
+ // waiting for ^D, so the caller MUST gate on `process.stdin.isTTY` first.
65
69
  async function readStdin() {
66
- if (process.stdin.isTTY)
67
- return "";
68
70
  const chunks = [];
69
71
  for await (const chunk of process.stdin) {
70
72
  chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
@@ -83,15 +85,19 @@ async function runTasteGet(args) {
83
85
  }
84
86
  async function runTasteSet(args) {
85
87
  const filePath = args.flags.get("file");
86
- const hasStdin = !process.stdin.isTTY;
87
- if (filePath !== undefined && hasStdin) {
88
- fail("'pane taste set' takes input from EITHER stdin OR --file <path>, not both", "invalid_args");
89
- }
90
- if (filePath === undefined && !hasStdin) {
91
- fail("'pane taste set' needs input — pipe markdown on stdin or pass --file <path>", "invalid_args");
92
- }
88
+ // Source the blob deterministically — no isTTY-flag fusing, because
89
+ // `!process.stdin.isTTY` is true under every non-interactive caller
90
+ // (pipes, redirects, closed fd, CI, agent harnesses) and would wrongly
91
+ // reject `--file` for the entire target audience. See issue #148.
92
+ //
93
+ // --file - explicit stdin sentinel
94
+ // --file <path> → read that path (works in TTY and non-TTY alike)
95
+ // (no --file) → fall back to stdin IF non-TTY; error in a TTY
93
96
  let taste;
94
- if (filePath !== undefined) {
97
+ if (filePath === "-") {
98
+ taste = await readStdin();
99
+ }
100
+ else if (filePath !== undefined) {
95
101
  try {
96
102
  taste = readFileSync(filePath, "utf8");
97
103
  }
@@ -99,9 +105,12 @@ async function runTasteSet(args) {
99
105
  fail(`failed to read --file '${filePath}': ${e instanceof Error ? e.message : String(e)}`, "invalid_args");
100
106
  }
101
107
  }
102
- else {
108
+ else if (!process.stdin.isTTY) {
103
109
  taste = await readStdin();
104
110
  }
111
+ else {
112
+ fail("'pane taste set' needs input — pass --file <path>, pipe markdown on stdin, or use --file -", "invalid_args");
113
+ }
105
114
  if (taste.trim().length === 0) {
106
115
  fail("'pane taste set' refuses an empty or whitespace-only blob — use 'pane taste clear --yes' to delete the notes", "invalid_args");
107
116
  }
@@ -7,6 +7,7 @@ import { openStream } from "@paneui/core";
7
7
  import { resolveConfig } from "../config.js";
8
8
  import { PaneClient } from "@paneui/core";
9
9
  import { printJsonLine, fail } from "../output.js";
10
+ import { VERSION } from "../version.js";
10
11
  export const watchHelp = `pane watch — stream a session's events as JSON-lines
11
12
 
12
13
  Usage:
@@ -20,12 +21,30 @@ exits 0.
20
21
  Modes:
21
22
  (bare) Run until SIGINT (Ctrl-C). Exit 0.
22
23
  --once Exit 0 after the first event.
23
- --type <t> Exit 0 after the first event whose type equals <t>.
24
+ --type <t[,t2,…]> Exit 0 after the first event whose type is in this
25
+ comma-separated set. Without --filter-type, stdout
26
+ still prints EVERY event until the match — --type
27
+ controls the EXIT condition, --filter-type controls
28
+ the OUTPUT.
24
29
 
25
30
  Options:
31
+ --filter-type <t[,t2,…]>
32
+ Print only events whose type is in this set.
33
+ system.* events (lifecycle: participant.joined,
34
+ session.expired, …) and the terminal {"type":
35
+ "_closed"} line always pass through, so the
36
+ harness still sees them. Combine with --type X
37
+ --filter-type X for "stream only X events and
38
+ exit on the first one" — the literal-reading of
39
+ --type alone that agents often expect.
26
40
  --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.
41
+ --timeout <secs> Wall-clock max wait. Fail with code ws_timeout if
42
+ the natural exit condition (--once, --type, session
43
+ close) doesn't happen within this many seconds.
44
+ Frames arriving DO NOT reset the timer — this is
45
+ the budget for "give up on the human", not an idle
46
+ detector. Without --once or --type, bare watch
47
+ will simply exit non-zero at the deadline.
29
48
  --url <url> Relay base URL (overrides PANE_URL).
30
49
  --api-key <key> Agent API key (overrides PANE_API_KEY).
31
50
  -h, --help Show this help.
@@ -34,14 +53,52 @@ Each line is one event envelope: { id, session_id, author, ts, type, data,
34
53
  causation_id, idempotency_key }. The terminal line is {"type":"_closed"}.
35
54
 
36
55
  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.`;
56
+ as a monitored process; the harness re-invokes the model when the line lands.
57
+
58
+ Wait for any of several events:
59
+ pane watch <id> --type form.submitted,form.cancelled --timeout 60
60
+
61
+ Stream only matching events to stdout, exit on the first:
62
+ pane watch <id> --type form.submitted --filter-type form.submitted`;
63
+ // Parse a comma-separated event-type list (e.g. "form.submitted,form.cancelled")
64
+ // into a Set. Empty/whitespace entries are dropped. Returns null when the flag
65
+ // wasn't given (so callers can distinguish "no filter" from "empty filter").
66
+ // Exported for unit-test coverage; the wrapper around the actual openStream
67
+ // integration is hard to test in isolation.
68
+ export function parseTypeList(raw) {
69
+ if (raw === undefined)
70
+ return null;
71
+ const types = raw
72
+ .split(",")
73
+ .map((t) => t.trim())
74
+ .filter((t) => t.length > 0);
75
+ return new Set(types);
76
+ }
77
+ /**
78
+ * Decide whether `--filter-type` lets this event through to stdout. Lifecycle
79
+ * `system.*` events always pass — without that an agent waiting on
80
+ * `--filter-type form.submitted` would never see `system.participant.joined`
81
+ * and miss the "the human opened the URL" signal. Exported for testing.
82
+ */
83
+ export function shouldPrintEvent(eventType, filterTypes) {
84
+ if (filterTypes === null)
85
+ return true;
86
+ if (eventType.startsWith("system."))
87
+ return true;
88
+ return filterTypes.has(eventType);
89
+ }
38
90
  export async function runWatch(args) {
39
91
  const sessionId = args.positionals[0];
40
92
  if (!sessionId)
41
93
  fail("missing <session-id>", "invalid_args");
42
94
  const cfg = resolveConfig(args);
43
95
  const since = args.flags.get("since") ?? null;
44
- const waitType = args.flags.get("type") ?? null;
96
+ // --type controls the EXIT condition (set of types that trigger exit 0
97
+ // on first match). --filter-type controls OUTPUT (the only event types
98
+ // printed to stdout; system.* and _closed always pass through). Each
99
+ // flag is independent — combine them only if you really want both.
100
+ const exitTypes = parseTypeList(args.flags.get("type"));
101
+ const filterTypes = parseTypeList(args.flags.get("filter-type"));
45
102
  const once = args.bools.has("once");
46
103
  let timeoutSec = null;
47
104
  const timeoutRaw = args.flags.get("timeout");
@@ -51,7 +108,11 @@ export async function runWatch(args) {
51
108
  fail("--timeout must be a positive number", "invalid_args");
52
109
  timeoutSec = t;
53
110
  }
54
- const client = new PaneClient({ url: cfg.url, apiKey: cfg.apiKey });
111
+ const client = new PaneClient({
112
+ url: cfg.url,
113
+ apiKey: cfg.apiKey,
114
+ cliVersion: VERSION,
115
+ });
55
116
  let exited = false;
56
117
  const finish = (code) => {
57
118
  if (exited)
@@ -73,19 +134,20 @@ export async function runWatch(args) {
73
134
  // Track whether the relay told us the session expired before the socket
74
135
  // closed — a 1006/1008/1011 close after that is still a clean shutdown.
75
136
  let sawSessionExpired = false;
76
- // Connection timeout: fail if no frame at all arrives within the window.
137
+ // Wall-clock timeout. The reporter's mental model (#137) and the skill
138
+ // text both treat this as "max wait until something happens" — i.e. an
139
+ // agent giving up on a human who never acts. The previous behaviour
140
+ // (clear the timer on first frame, never re-arm) made `--timeout`
141
+ // useless once any frame arrived, even a system.participant.joined
142
+ // emitted the moment a human connected. Frames now DO NOT reset the
143
+ // timer; the only ways `--timeout` doesn't fire are the natural exit
144
+ // conditions (--once, --type match, session close) finishing first.
77
145
  let timer;
78
146
  if (timeoutSec !== null) {
79
147
  timer = setTimeout(() => {
80
- fail(`no stream frame within ${timeoutSec}s`, "ws_timeout");
148
+ fail(`no terminal condition met within ${timeoutSec}s`, "ws_timeout");
81
149
  }, timeoutSec * 1000);
82
150
  }
83
- const sawFrame = () => {
84
- if (timer) {
85
- clearTimeout(timer);
86
- timer = undefined;
87
- }
88
- };
89
151
  const handle = openStream({
90
152
  wsBaseUrl: client.wsBaseUrl,
91
153
  sessionId: sessionId,
@@ -93,11 +155,14 @@ export async function runWatch(args) {
93
155
  since,
94
156
  }, {
95
157
  onReplayComplete: () => {
96
- sawFrame();
158
+ // No-op: replay-complete is informational, no timer interaction.
97
159
  },
98
160
  onEvent: (event) => {
99
- sawFrame();
100
- printJsonLine(event);
161
+ // Output filter: print only events the agent asked for. See
162
+ // shouldPrintEvent — system.* lifecycle events always pass.
163
+ if (shouldPrintEvent(event.type, filterTypes)) {
164
+ printJsonLine(event);
165
+ }
101
166
  // A system.session.expired event means the session is closing.
102
167
  if (event.type === "system.session.expired") {
103
168
  sawSessionExpired = true;
@@ -108,7 +173,7 @@ export async function runWatch(args) {
108
173
  finish(0);
109
174
  return;
110
175
  }
111
- if (waitType !== null && event.type === waitType) {
176
+ if (exitTypes !== null && exitTypes.has(event.type)) {
112
177
  finish(0);
113
178
  }
114
179
  },
package/dist/config.js CHANGED
@@ -3,6 +3,7 @@
3
3
  import { PaneClient } from "@paneui/core";
4
4
  import { fail } from "./output.js";
5
5
  import { readStore, storePath } from "./store.js";
6
+ import { VERSION } from "./version.js";
6
7
  /**
7
8
  * Resolve url + apiKey and report the SOURCE of each, WITHOUT making a network
8
9
  * call and WITHOUT failing on a missing value (unlike `resolveConfig`). The
@@ -73,5 +74,25 @@ export function resolveConfig(args) {
73
74
  /** Build a PaneClient from resolved config. */
74
75
  export function makeClient(args) {
75
76
  const cfg = resolveConfig(args);
76
- return new PaneClient({ url: cfg.url, apiKey: cfg.apiKey });
77
+ return new PaneClient({
78
+ url: cfg.url,
79
+ apiKey: cfg.apiKey,
80
+ // Sent as `x-pane-cli-version` on every relay request so the relay can
81
+ // return 426 cli_upgrade_required when this CLI is too old. Single
82
+ // source: ./version.ts.
83
+ cliVersion: VERSION,
84
+ });
85
+ }
86
+ /**
87
+ * Resolve just the relay URL — same precedence as `resolveConfig` but
88
+ * without insisting on an API key. For commands that hit unauthenticated
89
+ * relay routes (e.g. `pane skill` → GET /skills/pane/SKILL.md).
90
+ */
91
+ export function resolveRelayUrl(args) {
92
+ const store = readStore();
93
+ const url = args.flags.get("url") ??
94
+ process.env.PANE_URL ??
95
+ store.url ??
96
+ DEFAULT_RELAY_URL;
97
+ return url.replace(/\/$/, "");
77
98
  }
package/dist/index.js CHANGED
@@ -14,8 +14,12 @@ import { runConfig, configHelp } from "./commands/config.js";
14
14
  import { runLogout, logoutHelp } from "./commands/logout.js";
15
15
  import { runKeys, keysHelp } from "./commands/keys.js";
16
16
  import { runTaste, tasteHelp } from "./commands/taste.js";
17
+ import { runFeedback, feedbackHelp } from "./commands/feedback.js";
17
18
  import { runDelete, deleteHelp } from "./commands/delete.js";
18
- const VERSION = "0.0.3";
19
+ import { runSkill, skillHelp } from "./commands/skill.js";
20
+ import { VERSION } from "./version.js";
21
+ import { PaneApiError } from "@paneui/core";
22
+ import { failUpgradeRequired } from "./output.js";
19
23
  const ROOT_HELP = `pane — a round-trip UI channel between agents and humans
20
24
 
21
25
  Usage:
@@ -27,7 +31,7 @@ Commands:
27
31
  create Create a session (POST /v1/sessions). Prints session_id,
28
32
  urls, tokens, expires_at.
29
33
  artifact Manage reusable, versioned artifacts (create / version /
30
- update / search / list / show).
34
+ update / search / list / show / delete).
31
35
  state <id> Non-blocking snapshot: session metadata + event log.
32
36
  send <id> Emit an agent event into a session.
33
37
  watch <id> Stream a session's events as JSON-lines on stdout
@@ -38,8 +42,13 @@ Commands:
38
42
  (get / set / clear) — presentation preferences the agent
39
43
  has learned from human feedback and reads before
40
44
  generating a pane artifact.
45
+ feedback Submit / list one-shot feedback to the relay operator
46
+ (create / list) — bug reports, feature requests, notes.
41
47
  config Show the resolved relay config (no network call).
42
48
  logout Clear the locally-saved relay URL + API key.
49
+ skill Fetch the relay's SKILL.md to stdout, or just its
50
+ version with 'pane skill version'. Used to install
51
+ and keep the local skill copy in sync; no API key.
43
52
 
44
53
  Run \`pane <command> --help\` for command-specific options.
45
54
 
@@ -65,7 +74,14 @@ Output: stdout is machine-readable JSON; errors go to stderr as
65
74
  // handled from rawArgv[0] before parseArgs runs, so it never needs to be a
66
75
  // boolean flag — and keeping it out lets `pane create --version <n>` /
67
76
  // `pane artifact version` consume a value as a normal value-flag.
68
- const BOOLEAN_FLAGS = new Set(["json", "once", "help", "print-key", "yes"]);
77
+ const BOOLEAN_FLAGS = new Set([
78
+ "json",
79
+ "once",
80
+ "help",
81
+ "print-key",
82
+ "yes",
83
+ "plain",
84
+ ]);
69
85
  async function main() {
70
86
  const rawArgv = process.argv.slice(2);
71
87
  // Version: handle before anything else.
@@ -105,8 +121,10 @@ async function main() {
105
121
  delete: deleteHelp,
106
122
  keys: keysHelp,
107
123
  taste: tasteHelp,
124
+ feedback: feedbackHelp,
108
125
  config: configHelp,
109
126
  logout: logoutHelp,
127
+ skill: skillHelp,
110
128
  };
111
129
  if (!(command in helps)) {
112
130
  process.stderr.write(JSON.stringify({
@@ -149,15 +167,30 @@ async function main() {
149
167
  case "taste":
150
168
  await runTaste(args);
151
169
  break;
170
+ case "feedback":
171
+ await runFeedback(args);
172
+ break;
152
173
  case "config":
153
174
  await runConfig(args);
154
175
  break;
155
176
  case "logout":
156
177
  await runLogout();
157
178
  break;
179
+ case "skill":
180
+ await runSkill(args);
181
+ break;
158
182
  }
159
183
  }
160
184
  main().catch((err) => {
185
+ // Funnel 426 cli_upgrade_required through the dedicated upgrade-message
186
+ // path so a command that throws raw (instead of going through
187
+ // failFromError) still produces the exact stderr block + exit 75 the
188
+ // SKILL.md tells the agent's harness to expect.
189
+ if (err instanceof PaneApiError &&
190
+ err.code === "cli_upgrade_required" &&
191
+ err.status === 426) {
192
+ failUpgradeRequired(err);
193
+ }
161
194
  process.stderr.write(JSON.stringify({
162
195
  error: {
163
196
  code: "internal",
package/dist/output.js CHANGED
@@ -1,6 +1,8 @@
1
1
  // stdout/stderr helpers. The CLI is JSON-by-default: machine-readable on
2
2
  // stdout, human errors on stderr.
3
3
  import { PaneApiError } from "@paneui/core";
4
+ import { fileURLToPath } from "node:url";
5
+ import { detectInstallMethod, upgradeCommandFor, formatUpgradeMessage, EXIT_CLI_UPGRADE_REQUIRED, } from "./upgrade.js";
4
6
  /** Print a value as pretty JSON to stdout. */
5
7
  export function printJson(value) {
6
8
  process.stdout.write(JSON.stringify(value, null, 2) + "\n");
@@ -29,6 +31,16 @@ export function fail(message, code = "error", details, extra) {
29
31
  }
30
32
  /** Translate a thrown error (incl. PaneApiError) into a fail() exit. */
31
33
  export function failFromError(err) {
34
+ // 426 cli_upgrade_required gets its own dedicated exit path: a
35
+ // human-readable upgrade message on stderr and a stable exit code
36
+ // (sysexits EX_TEMPFAIL = 75) that the SKILL.md instructs the agent's
37
+ // harness to branch on. Everything else falls through to the generic
38
+ // JSON envelope below.
39
+ if (err instanceof PaneApiError &&
40
+ err.code === "cli_upgrade_required" &&
41
+ err.status === 426) {
42
+ failUpgradeRequired(err);
43
+ }
32
44
  if (err instanceof PaneApiError) {
33
45
  fail(err.message, err.code, err.details, {
34
46
  hint: err.hint,
@@ -38,3 +50,28 @@ export function failFromError(err) {
38
50
  }
39
51
  fail(err instanceof Error ? err.message : String(err), "internal");
40
52
  }
53
+ /**
54
+ * Print the upgrade message to stderr and exit 75. Pulled out of
55
+ * failFromError so the top-level main().catch can also funnel through it
56
+ * — the two entry points must produce identical output for the SKILL.md's
57
+ * "if you see exit 75…" instructions to be reliable.
58
+ *
59
+ * The install-method detection reads `import.meta.url` of the CLI entry,
60
+ * resolved from the call site that imports this module. Inlining the
61
+ * resolution here keeps each command's own error-handling free of the
62
+ * detail.
63
+ */
64
+ export function failUpgradeRequired(err) {
65
+ // The CLI entry is packages/cli/dist/index.js (after build) or
66
+ // packages/cli/src/index.ts (when running from source via tsx). Either
67
+ // way, the detector only looks at the path's shape, so resolving from
68
+ // *this* file works — output.ts sits alongside index.ts/index.js in
69
+ // both layouts.
70
+ const entryPath = fileURLToPath(import.meta.url);
71
+ const method = detectInstallMethod(entryPath);
72
+ const details = (err.details ?? {});
73
+ const minVersion = typeof details.min_version === "string" ? details.min_version : "0.0.0";
74
+ const command = upgradeCommandFor(method, minVersion);
75
+ process.stderr.write(formatUpgradeMessage(err, method, command) + "\n");
76
+ process.exit(EXIT_CLI_UPGRADE_REQUIRED);
77
+ }
@@ -0,0 +1,115 @@
1
+ // CLI auto-upgrade helpers — install-method detection and message formatting.
2
+ //
3
+ // Called by the top-level error handler when a relay returns 426
4
+ // `cli_upgrade_required`. The goal is to print a single, machine-parseable
5
+ // line the agent can lift verbatim — and tell the human (or the agent's
6
+ // harness) exactly what to run instead of a generic "upgrade @paneui/cli".
7
+ //
8
+ // Detection is best-effort: we inspect `process.execPath` and the CLI's own
9
+ // install path. There's no programmatic "ask npm what installed me" API, so
10
+ // the heuristics below are matched against the well-known install layouts
11
+ // of each package manager. Anything unrecognized lands in `unknown`, which
12
+ // means "tell the agent to ask the human" rather than guess.
13
+ /**
14
+ * Detection rules, ordered most-specific to least. Each rule looks at the
15
+ * directory the CLI is running from — caller passes `import.meta.url`-
16
+ * derived absolute path for the CLI entry. The actual file at that path
17
+ * doesn't need to exist; we're only pattern-matching the path itself, so
18
+ * tests can pass synthetic strings.
19
+ *
20
+ * Patterns are deliberately loose (substring tests, not exact prefixes) so
21
+ * the same rule handles per-user installs (`~/.npm-global/lib/node_modules/`),
22
+ * system installs (`/usr/lib/node_modules/`), and the macOS/Linux
23
+ * variations within each manager — without listing every layout.
24
+ */
25
+ export function detectInstallMethod(entryPath) {
26
+ // Volta wraps every binary in a shim under ~/.volta/tools/image/packages/
27
+ // and re-exports it via ~/.volta/bin/. Either path is a positive match.
28
+ if (entryPath.includes("/.volta/"))
29
+ return "volta";
30
+ // Bun's global registry: ~/.bun/install/global/node_modules/@paneui/cli/...
31
+ if (entryPath.includes("/.bun/install/global/"))
32
+ return "bun-global";
33
+ // npm global, in both common shapes:
34
+ // /usr/(local/)?lib/node_modules/@paneui/cli/... (system)
35
+ // ~/.npm-global/lib/node_modules/@paneui/cli/... (npm prefix)
36
+ // ~/.nvm/versions/node/vXX/lib/node_modules/... (nvm)
37
+ if (/\/lib\/node_modules\/@paneui\/cli\//.test(entryPath) ||
38
+ /\/lib\/node_modules\/\.bin\//.test(entryPath)) {
39
+ return "npm-global";
40
+ }
41
+ // npx caches the package under ~/Library/Caches/_npx (macOS) or
42
+ // ~/.npm/_npx (Linux) and runs it from a node_modules inside that dir.
43
+ // Surface this distinctly from a real vendored install: with an npx
44
+ // execution there is no project package.json owning the version and no
45
+ // global to upgrade — the user runs `npx @paneui/cli@<version>` each
46
+ // time, so the right answer is "ask the human / re-run with a newer
47
+ // explicit version".
48
+ if (entryPath.includes("/_npx/"))
49
+ return "unknown";
50
+ // Vendored: the CLI lives inside the *project's* node_modules — i.e. the
51
+ // user did `npm i @paneui/cli` (no -g) and runs it via a local script.
52
+ // We can't safely upgrade this for them; package.json owns it.
53
+ if (entryPath.includes("/node_modules/@paneui/cli/"))
54
+ return "vendored";
55
+ // pnpm temp, asdf, or anything else.
56
+ return "unknown";
57
+ }
58
+ /**
59
+ * Returns the shell command the human (or the agent, in a sandbox it owns)
60
+ * can run to upgrade @paneui/cli to satisfy `minVersion`. `null` means "no
61
+ * portable command exists — escalate to the human."
62
+ *
63
+ * Always pin the upgrade target to `>=${minVersion}` instead of `@latest`
64
+ * so a self-hosted relay that requires 0.0.7 doesn't drag the client to a
65
+ * future 0.1.0 that may have its own incompatibilities. The trailing
66
+ * `@latest`-equivalent is fine for the operator who deliberately
67
+ * fast-forwards.
68
+ */
69
+ export function upgradeCommandFor(method, minVersion) {
70
+ const spec = `@paneui/cli@>=${minVersion}`;
71
+ switch (method) {
72
+ case "npm-global":
73
+ return `npm install -g ${spec}`;
74
+ case "bun-global":
75
+ return `bun install -g ${spec}`;
76
+ case "volta":
77
+ return `volta install ${spec}`;
78
+ case "vendored":
79
+ case "unknown":
80
+ return null;
81
+ }
82
+ }
83
+ /**
84
+ * The deterministic stderr block the CLI prints on a 426 response. The agent
85
+ * is expected to read this verbatim and (per SKILL.md) run the printed
86
+ * command, then re-run its original `pane` invocation once. Format is held
87
+ * stable across CLI versions so the skill's instructions don't drift — a
88
+ * change here is a contract change.
89
+ */
90
+ export function formatUpgradeMessage(err, method, command) {
91
+ // The relay's 426 payload puts the two version strings under details. We
92
+ // tolerate a missing/malformed details object so a misbehaving relay
93
+ // can't crash the CLI's own error path — show whatever we have.
94
+ const details = (err.details ?? {});
95
+ const minVersion = typeof details.min_version === "string" ? details.min_version : "?";
96
+ const yourVersion = typeof details.your_version === "string" ? details.your_version : "?";
97
+ const lines = [];
98
+ lines.push(`pane: this relay requires @paneui/cli >= ${minVersion} (you have ${yourVersion}).`);
99
+ if (command !== null) {
100
+ lines.push(`To upgrade: ${command}`);
101
+ }
102
+ else if (method === "vendored") {
103
+ lines.push("Install method: vendored (inside a project's node_modules). Bump the @paneui/cli version in that project's package.json and re-install — the CLI isn't safe to upgrade globally for a vendored install.");
104
+ }
105
+ else {
106
+ lines.push("Install method: unknown. Ask the human to upgrade @paneui/cli — the install path didn't match any pattern we recognize (npm-global, bun-global, volta).");
107
+ }
108
+ return lines.join("\n");
109
+ }
110
+ /**
111
+ * Stable exit code used by the CLI on a `cli_upgrade_required` response.
112
+ * Sysexits.h's `EX_TEMPFAIL` — "temporary failure; retry after fixing".
113
+ * Documented in SKILL.md so an agent's harness can branch on it.
114
+ */
115
+ export const EXIT_CLI_UPGRADE_REQUIRED = 75;
@@ -0,0 +1,11 @@
1
+ // Single source of truth for the CLI version string.
2
+ //
3
+ // - `pane --version` prints this verbatim.
4
+ // - Every PaneClient construction passes it as `cliVersion`, which surfaces
5
+ // as the `x-pane-cli-version` header on every relay request — drives the
6
+ // relay's version-skew check (HTTP 426 `cli_upgrade_required`).
7
+ //
8
+ // Keep this in lockstep with packages/cli/package.json's `version` field;
9
+ // they're consulted in different places (here for the runtime header,
10
+ // package.json for npm publish + dependency resolution).
11
+ export const VERSION = "0.0.5";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paneui/cli",
3
- "version": "0.0.3",
3
+ "version": "0.0.5",
4
4
  "description": "Command-line client for the Pane relay: create sessions, inspect state, send and watch events.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -41,7 +41,7 @@
41
41
  "test:unit": "vitest run"
42
42
  },
43
43
  "dependencies": {
44
- "@paneui/core": "^0.0.3"
44
+ "@paneui/core": "^0.0.5"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@types/node": "^22.7.0",