@paneui/cli 0.0.9 → 0.0.10

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.
package/dist/config.js CHANGED
@@ -1,16 +1,65 @@
1
1
  // Relay connection config: PANE_URL / PANE_API_KEY from the environment,
2
- // overridable per-invocation with --url / --api-key.
2
+ // overridable per-invocation with --url / --api-key. Profile selection via
3
+ // --profile / PANE_PROFILE picks WHICH (url, api_key) pair to load from
4
+ // the saved store. See store.ts for the on-disk layout.
3
5
  import { PaneClient } from "@paneui/core";
4
6
  import { fail } from "./output.js";
5
- import { readStore, storePath } from "./store.js";
7
+ import { readStore, resolveProfile, storePath } from "./store.js";
6
8
  import { VERSION } from "./version.js";
9
+ /**
10
+ * The hosted Pane relay. Used as the relay-URL fallback so a fresh user only
11
+ * needs an API key — `pane agent register` against the hosted relay, then go. A
12
+ * self-hoster overrides it with `--url` / `PANE_URL` / `pane agent register --url`.
13
+ */
14
+ export const DEFAULT_RELAY_URL = "https://relay.paneui.com";
15
+ /**
16
+ * Pick the profile-selector source — explicit flag wins over env, env wins
17
+ * over the store's `current_profile`. Returns both the selector value and
18
+ * where it came from so `describeConfig` can report it.
19
+ */
20
+ function pickProfileSelector(args) {
21
+ const flag = args.flags.get("profile");
22
+ if (flag !== undefined && flag !== "") {
23
+ return { selector: flag, source: "flag" };
24
+ }
25
+ const env = process.env.PANE_PROFILE;
26
+ if (env !== undefined && env !== "") {
27
+ return { selector: env, source: "env" };
28
+ }
29
+ return { selector: undefined, source: "none" };
30
+ }
7
31
  /**
8
32
  * Resolve url + apiKey and report the SOURCE of each, WITHOUT making a network
9
33
  * call and WITHOUT failing on a missing value (unlike `resolveConfig`). The
10
34
  * full API key is never returned — only a short, masked prefix.
35
+ *
36
+ * Resolution model:
37
+ * - `--url` / `PANE_URL` and `--api-key` / `PANE_API_KEY` are DIRECT values:
38
+ * they override everything, including any active profile. CI scripts that
39
+ * set those env vars never need to think about profiles.
40
+ * - Otherwise the profile selector (`--profile` flag → `PANE_PROFILE` env →
41
+ * store's `current_profile`) picks one profile out of the store; the
42
+ * selected profile's `url` and `api_key` are used.
43
+ * - Final fallback for URL is `DEFAULT_RELAY_URL`.
11
44
  */
12
45
  export function describeConfig(args) {
13
46
  const store = readStore();
47
+ const { selector, source: selectorSource } = pickProfileSelector(args);
48
+ // The store gets visited only if --profile flag is set (explicit
49
+ // selector) and the store has a matching profile, OR the store has a
50
+ // current_profile and no explicit selector overrides it. `resolveProfile`
51
+ // throws on a typo'd selector; we swallow that here so describeConfig
52
+ // can't crash a `pane config show` — resolveConfig() is the one that
53
+ // surfaces the error when the caller actually needs a key.
54
+ let active;
55
+ try {
56
+ active = resolveProfile(store, selector);
57
+ }
58
+ catch {
59
+ active = null;
60
+ }
61
+ // URL precedence: --url flag > PANE_URL env > active profile's url.
62
+ // The default URL is shown only when nothing else is set.
14
63
  let url = null;
15
64
  let urlSource = "none";
16
65
  if (args.flags.get("url")) {
@@ -21,10 +70,11 @@ export function describeConfig(args) {
21
70
  url = process.env.PANE_URL;
22
71
  urlSource = "env";
23
72
  }
24
- else if (store.url) {
25
- url = store.url;
26
- urlSource = "store";
73
+ else if (active && active.profile.url) {
74
+ url = active.profile.url;
75
+ urlSource = "profile";
27
76
  }
77
+ // API key precedence: --api-key flag > PANE_API_KEY env > active profile's api_key.
28
78
  let apiKey = null;
29
79
  let keySource = "none";
30
80
  if (args.flags.get("api-key")) {
@@ -35,37 +85,46 @@ export function describeConfig(args) {
35
85
  apiKey = process.env.PANE_API_KEY;
36
86
  keySource = "env";
37
87
  }
38
- else if (store.apiKey) {
39
- apiKey = store.apiKey;
40
- keySource = "store";
88
+ else if (active && active.profile.apiKey) {
89
+ apiKey = active.profile.apiKey;
90
+ keySource = "profile";
41
91
  }
42
92
  return {
43
93
  url: url ? url.replace(/\/$/, "") : null,
44
94
  url_source: urlSource,
45
95
  key_prefix: apiKey ? apiKey.slice(0, 10) + "…" : null,
46
96
  key_source: keySource,
97
+ profile: active ? active.name : null,
98
+ profile_source: selectorSource,
47
99
  config_path: storePath(),
48
100
  };
49
101
  }
50
- /**
51
- * The hosted Pane relay. Used as the relay-URL fallback so a fresh user only
52
- * needs an API key — `pane agent register` against the hosted relay, then go. A
53
- * self-hoster overrides it with `--url` / `PANE_URL` / `pane agent register --url`.
54
- */
55
- export const DEFAULT_RELAY_URL = "https://relay.paneui.com";
56
102
  /**
57
103
  * Resolve relay URL + API key. Precedence (highest first):
58
- * url: --url flag → PANE_URL env store.url → DEFAULT_RELAY_URL
59
- * apiKey: --api-key → PANE_API_KEY envstore.apiKey
60
- * The store is written by `pane agent register`, so later commands need no env vars.
104
+ * url: --url flag → PANE_URL env active profile's url → DEFAULT_RELAY_URL
105
+ * apiKey: --api-key → PANE_API_KEY → active profile's api_key
106
+ * "Active profile" is chosen by `--profile` / `PANE_PROFILE` / the store's
107
+ * `current_profile`. A typo'd `--profile dev` fails fast with `config_error`
108
+ * — we never silently fall back to a different relay.
61
109
  */
62
110
  export function resolveConfig(args) {
63
111
  const store = readStore();
112
+ const { selector } = pickProfileSelector(args);
113
+ let active;
114
+ try {
115
+ active = resolveProfile(store, selector);
116
+ }
117
+ catch (e) {
118
+ fail(e instanceof Error ? e.message : String(e), "config_error");
119
+ }
64
120
  const url = args.flags.get("url") ??
65
121
  process.env.PANE_URL ??
66
- store.url ??
122
+ active?.profile.url ??
67
123
  DEFAULT_RELAY_URL;
68
- const apiKey = args.flags.get("api-key") ?? process.env.PANE_API_KEY ?? store.apiKey ?? "";
124
+ const apiKey = args.flags.get("api-key") ??
125
+ process.env.PANE_API_KEY ??
126
+ active?.profile.apiKey ??
127
+ "";
69
128
  if (!apiKey) {
70
129
  fail("missing API key: set PANE_API_KEY, pass --api-key <key>, or run 'pane agent register'", "config_error");
71
130
  }
@@ -90,9 +149,17 @@ export function makeClient(args) {
90
149
  */
91
150
  export function resolveRelayUrl(args) {
92
151
  const store = readStore();
152
+ const { selector } = pickProfileSelector(args);
153
+ let active;
154
+ try {
155
+ active = resolveProfile(store, selector);
156
+ }
157
+ catch (e) {
158
+ fail(e instanceof Error ? e.message : String(e), "config_error");
159
+ }
93
160
  const url = args.flags.get("url") ??
94
161
  process.env.PANE_URL ??
95
- store.url ??
162
+ active?.profile.url ??
96
163
  DEFAULT_RELAY_URL;
97
164
  return url.replace(/\/$/, "");
98
165
  }
package/dist/format.js ADDED
@@ -0,0 +1,133 @@
1
+ // Human-readable formatter for `pane create` output.
2
+ //
3
+ // The CLI is JSON-first — that's how agents call it. But humans run it too:
4
+ // the agent dev iterating on a template, the operator smoke-testing a relay,
5
+ // the developer who fires `pane create` once a day to grab a URL and hand
6
+ // it to themselves on their phone. Dumping `{ pane_id, urls, tokens, ... }`
7
+ // at them is a downgrade in every case where the next step is "open the
8
+ // URL in a browser".
9
+ //
10
+ // In a TTY (and without `--json` on the CLI), this module renders:
11
+ // - the title prominently
12
+ // - each human URL on its own line, copy-friendly
13
+ // - a QR code for the first human URL, scannable from a phone
14
+ // - the expiry as a countdown ("in 1h 0m") + ISO timestamp
15
+ // - the agent stream URL on a dim line (less important for humans)
16
+ //
17
+ // Trust boundary: every interpolated value is a server response or a string
18
+ // the caller asked us to render. No HTML escaping needed — terminal output.
19
+ // We DO neutralise stray ANSI escape characters (a malicious title could
20
+ // otherwise inject colour codes); see stripAnsi.
21
+ import qrcode from "qrcode-terminal";
22
+ /** ANSI helpers. Only applied when writing to a TTY; harmless characters
23
+ * otherwise. We deliberately don't pull in a colour library — the CLI has
24
+ * one runtime dep today (qrcode-terminal) and we'd like to keep the
25
+ * pane area tight. */
26
+ const ANSI = {
27
+ reset: "\x1b[0m",
28
+ bold: "\x1b[1m",
29
+ dim: "\x1b[2m",
30
+ cyan: "\x1b[36m",
31
+ green: "\x1b[32m",
32
+ yellow: "\x1b[33m",
33
+ };
34
+ // Strip control chars from interpolated strings. Defends against a relay
35
+ // response (or, more realistically, an echoed title in some future field)
36
+ // carrying ANSI escapes that would otherwise change the user's terminal
37
+ // colour after our output ends.
38
+ // eslint-disable-next-line no-control-regex
39
+ const CTRL_RX = /[\x00-\x08\x0b-\x1f\x7f]/g;
40
+ function safe(s) {
41
+ return s.replace(CTRL_RX, "");
42
+ }
43
+ /** Generate the QR-code string for `text` using qrcode-terminal's `small`
44
+ * rendering (one terminal char per QR module, ~half the height of the
45
+ * default). Returns the multi-line string, ready to write to stdout. */
46
+ function qrToString(text) {
47
+ let out = "";
48
+ qrcode.generate(text, { small: true }, (s) => {
49
+ out = s;
50
+ });
51
+ return out;
52
+ }
53
+ /** Human-friendly countdown from now to `iso`. Returns "in 1h 0m" /
54
+ * "in 45m" / "in 30s" / "expired". Stable enough to test as a string. */
55
+ export function humanCountdown(iso, nowMs = Date.now()) {
56
+ const delta = new Date(iso).getTime() - nowMs;
57
+ if (!Number.isFinite(delta) || delta <= 0)
58
+ return "expired";
59
+ const totalSec = Math.floor(delta / 1000);
60
+ const days = Math.floor(totalSec / 86400);
61
+ const hours = Math.floor((totalSec % 86400) / 3600);
62
+ const mins = Math.floor((totalSec % 3600) / 60);
63
+ const secs = totalSec % 60;
64
+ if (days > 0)
65
+ return `in ${days}d ${hours}h`;
66
+ if (hours > 0)
67
+ return `in ${hours}h ${mins}m`;
68
+ if (mins > 0)
69
+ return `in ${mins}m`;
70
+ return `in ${secs}s`;
71
+ }
72
+ /** Render the pane-created response for a human reader. Returns the
73
+ * full multi-line string; the caller writes it to stdout. */
74
+ export function formatPaneCreated(res, opts = {}) {
75
+ const c = opts.color ?? false;
76
+ const b = c ? ANSI.bold : "";
77
+ const d = c ? ANSI.dim : "";
78
+ const cy = c ? ANSI.cyan : "";
79
+ const g = c ? ANSI.green : "";
80
+ const r = c ? ANSI.reset : "";
81
+ const title = safe(res.title);
82
+ const paneId = safe(res.pane_id);
83
+ const expiresIn = humanCountdown(res.expires_at);
84
+ const expiresAt = safe(res.expires_at);
85
+ const humanUrls = res.urls.humans.map(safe);
86
+ const agentStream = safe(res.urls.agent_stream);
87
+ const lines = [];
88
+ // Header — "Pane created" vs. "Existing pane reused" if `created`
89
+ // is explicitly false. Dedup hits from #262 carry created=false and the
90
+ // human shouldn't think they made a fresh row.
91
+ const headline = res.created === false
92
+ ? `${b}${cy}Existing pane reused${r}`
93
+ : `${b}${g}Pane pane created${r}`;
94
+ lines.push(headline);
95
+ lines.push("");
96
+ lines.push(` ${d}Title:${r} ${title}`);
97
+ lines.push(` ${d}Pane:${r} ${paneId}`);
98
+ lines.push(` ${d}Expires:${r} ${expiresIn} ${d}(${expiresAt})${r}`);
99
+ if (res.context_key) {
100
+ lines.push(` ${d}Key:${r} ${safe(res.context_key)}`);
101
+ }
102
+ lines.push("");
103
+ if (humanUrls.length === 0) {
104
+ // Dedup-on-existing-pane path doesn't re-mint human URLs. Pane
105
+ // the situation explicitly rather than rendering a blank section.
106
+ lines.push(`${d}No human URLs minted on this response — fetch them with ` +
107
+ `\`pane participants ${paneId}\`.${r}`);
108
+ }
109
+ else {
110
+ const label = humanUrls.length === 1 ? "Open this link" : "Open these links";
111
+ lines.push(`${label} in a browser:`);
112
+ lines.push("");
113
+ for (const u of humanUrls) {
114
+ lines.push(` ${b}${u}${r}`);
115
+ }
116
+ lines.push("");
117
+ // Show a QR for the first URL — scannable from a phone. The other
118
+ // URLs (if any) are visible above; one QR keeps the output compact.
119
+ lines.push(`Or scan this QR code with your phone:`);
120
+ lines.push("");
121
+ const qr = qrToString(humanUrls[0]);
122
+ // Indent each QR line by two spaces so it sits inside the same gutter
123
+ // as the rest of the body.
124
+ for (const ln of qr.split("\n")) {
125
+ if (ln.length === 0)
126
+ continue;
127
+ lines.push(` ${ln}`);
128
+ }
129
+ lines.push("");
130
+ }
131
+ lines.push(`${d}Agent stream:${r} ${agentStream}`);
132
+ return lines.join("\n") + "\n";
133
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // behind the shape and the rename from the older flat layout.
7
7
  //
8
8
  // Config: PANE_URL and PANE_API_KEY (env), overridable with --url / --api-key.
9
+ // Multiple environments live as named profiles in
10
+ // $XDG_CONFIG_HOME/pane/config.json; pick one with --profile or PANE_PROFILE.
9
11
  // Output is JSON by default. Every noun self-documents via --help.
10
12
  import { parseArgs, ArgvError } from "./argv.js";
11
13
  /**
@@ -23,8 +25,14 @@ function failArgvError(e) {
23
25
  process.stderr.write(JSON.stringify({ error }) + "\n");
24
26
  process.exit(1);
25
27
  }
26
- import { runSession, sessionHelp } from "./commands/surface.js";
27
- import { runArtifact, artifactHelp } from "./commands/template.js";
28
+ import { runCreate, createHelp } from "./commands/create.js";
29
+ import { runList, listHelp } from "./commands/list.js";
30
+ import { runState, stateHelp } from "./commands/state.js";
31
+ import { runSend, sendHelp } from "./commands/send.js";
32
+ import { runWatch, watchHelp } from "./commands/watch.js";
33
+ import { runDelete, deleteHelp } from "./commands/delete.js";
34
+ import { runParticipant, participantHelp } from "./commands/participant.js";
35
+ import { runTemplate, artifactHelp } from "./commands/template.js";
28
36
  import { runAgent, agentHelp } from "./commands/agent.js";
29
37
  import { runKey, keyHelp } from "./commands/key.js";
30
38
  import { runTaste, tasteHelp } from "./commands/taste.js";
@@ -32,20 +40,35 @@ import { runFeedback, feedbackHelp } from "./commands/feedback.js";
32
40
  import { runConfig, configHelp } from "./commands/config.js";
33
41
  import { runBlob, blobHelp } from "./commands/attachment.js";
34
42
  import { runSkill, skillHelp } from "./commands/skill.js";
43
+ import { runRecords, recordsHelp } from "./commands/records.js";
44
+ import { runTemplateRecords, templateRecordsHelp, } from "./commands/template-records.js";
45
+ import { runQuery, queryHelp } from "./commands/query.js";
46
+ import { runTrash, trashHelp } from "./commands/trash.js";
35
47
  import { VERSION } from "./version.js";
36
48
  import { PaneApiError } from "@paneui/core";
37
49
  import { failUpgradeRequired } from "./output.js";
38
50
  const ROOT_HELP = `pane — a round-trip UI channel between agents and humans
39
51
 
40
52
  Usage:
41
- pane <noun> <verb> [options]
53
+ pane <command> [options]
42
54
 
43
- Nouns:
44
- surface Open / observe / send to / close surfaces
45
- (create | list | show | send | watch | delete |
46
- participant <list|new|revoke>).
55
+ Pane commands (operate on the core noun — a live UI channel):
56
+ create Create a pane (POST /v1/panes). Prints pane_id, urls,
57
+ tokens, expires_at.
58
+ list Enumerate YOUR agent's panes.
59
+ show <id> Non-blocking snapshot: pane metadata + event log.
60
+ send <id> Emit an agent event into a pane.
61
+ watch <id> Stream a pane's events as JSON-lines on stdout.
62
+ delete <id> Close/delete a pane (DELETE /v1/panes/:id).
63
+ participant Manage participant URLs on an existing pane
64
+ <list|new|revoke> (list | mint a fresh URL | revoke one URL).
65
+
66
+ Other noun groups:
47
67
  template Reusable, versioned UI templates
48
68
  (create | version | update | search | list | show | delete).
69
+ template-records Owner-curated content scoped to a Template head
70
+ (list | get | upsert | update | delete), visible to
71
+ every pane derived from any version of the template.
49
72
  key YOUR agent's API key (list | revoke).
50
73
  taste YOUR agent's freeform UI taste notes
51
74
  (get | set | clear) — presentation preferences the agent
@@ -53,28 +76,38 @@ Nouns:
53
76
  generating a pane template.
54
77
  feedback One-shot feedback to the relay operator
55
78
  (create | list) — bug reports, feature requests, notes.
56
- attachment Binary attachments (upload | download | show | list |
57
- delete | token <mint|revoke|list>). Blobs are scoped to
58
- an agent, a surface, or an template, and can be referenced
59
- from event payloads + input_data via
79
+ attachment Binary attachments (upload | download | show | list |
80
+ delete | token <mint|revoke|list>). Attachments are
81
+ scoped to an agent, a pane, or a template, and can be
82
+ referenced from event payloads + input_data via
60
83
  \`format: pane-attachment-id\`.
61
84
  agent Agent identity on this machine (register | logout).
62
85
  config CLI config inspection (show).
63
86
  skill The relay's SKILL.md (show | version) — auto-updating;
64
87
  no API key required.
88
+ trash Manage soft-deleted panes / templates
89
+ (list | restore | restore-template | purge | purge-template).
90
+ query Run read-only SQL over your scoped panes / records /
91
+ events. JSON / CSV / TSV / table output.
65
92
 
66
- Run \`pane <noun> --help\` for that noun's verbs.
93
+ Run \`pane <command> --help\` for command-specific options.
67
94
 
68
95
  Config:
69
96
  PANE_URL Relay base URL. Override: --url <url>
70
97
  PANE_API_KEY Agent API key. Override: --api-key <key>
98
+ PANE_PROFILE Active profile name. Override: --profile <name>
71
99
  'pane agent register' provisions the API key and saves it (with the URL) to
72
- \${XDG_CONFIG_HOME:-~/.config}/pane/config.json afterwards commands need
73
- only PANE_URL (or nothing) set.
100
+ \${XDG_CONFIG_HOME:-~/.config}/pane/config.json under a named profile —
101
+ afterwards commands need no env vars. Manage multiple environments
102
+ (dev/staging/prod) with 'pane config list / use / add / rm'.
74
103
 
75
104
  Global flags:
76
105
  -h, --help Show help.
77
106
  -v, --version Print version.
107
+ --profile <name> Pick a saved profile for this invocation (overrides
108
+ PANE_PROFILE and the saved 'current_profile').
109
+ --url <url> Relay base URL — bypasses profile selection entirely.
110
+ --api-key <key> Agent API key — bypasses profile selection entirely.
78
111
 
79
112
  Output: stdout is machine-readable JSON; errors go to stderr as
80
113
  {"error":{"code","message"}} with a non-zero exit.`;
@@ -85,7 +118,7 @@ Output: stdout is machine-readable JSON; errors go to stderr as
85
118
  //
86
119
  // `version` is deliberately NOT here: the top-level `-v` / `--version` is
87
120
  // handled from rawArgv[0] before parseArgs runs, so it never needs to be a
88
- // boolean flag — and keeping it out lets `pane surface create --version <n>` /
121
+ // boolean flag — and keeping it out lets `pane create --version <n>` /
89
122
  // `pane template version` consume a value as a normal value-flag.
90
123
  const BOOLEAN_FLAGS = new Set([
91
124
  "json",
@@ -122,7 +155,15 @@ async function main() {
122
155
  throw e;
123
156
  }
124
157
  const helps = {
125
- surface: sessionHelp,
158
+ // Top-level pane verbs (formerly `pane surface <verb>` — see #163 follow-up).
159
+ create: createHelp,
160
+ list: listHelp,
161
+ show: stateHelp,
162
+ send: sendHelp,
163
+ watch: watchHelp,
164
+ delete: deleteHelp,
165
+ participant: participantHelp,
166
+ // Other noun groups.
126
167
  template: artifactHelp,
127
168
  key: keyHelp,
128
169
  taste: tasteHelp,
@@ -131,6 +172,10 @@ async function main() {
131
172
  agent: agentHelp,
132
173
  config: configHelp,
133
174
  skill: skillHelp,
175
+ records: recordsHelp,
176
+ "template-records": templateRecordsHelp,
177
+ query: queryHelp,
178
+ trash: trashHelp,
134
179
  };
135
180
  if (!(noun in helps)) {
136
181
  process.stderr.write(JSON.stringify({
@@ -142,7 +187,7 @@ async function main() {
142
187
  process.exit(1);
143
188
  }
144
189
  // `pane <noun> --help` with no verb prints the noun-level help. A verb-level
145
- // --help is the responsibility of each runner (e.g. runSession dispatches to
190
+ // --help is the responsibility of each runner (e.g. runPane dispatches to
146
191
  // the verb runner which reads its own xxxHelp). This pre-empt only fires
147
192
  // when --help is the FIRST positional-equivalent — i.e. no verb given.
148
193
  if (args.bools.has("help") && args.positionals.length === 0) {
@@ -150,11 +195,31 @@ async function main() {
150
195
  return;
151
196
  }
152
197
  switch (noun) {
153
- case "surface":
154
- await runSession(args);
198
+ // Top-level pane verbs.
199
+ case "create":
200
+ await runCreate(args);
201
+ break;
202
+ case "list":
203
+ await runList(args);
204
+ break;
205
+ case "show":
206
+ await runState(args);
207
+ break;
208
+ case "send":
209
+ await runSend(args);
210
+ break;
211
+ case "watch":
212
+ await runWatch(args);
213
+ break;
214
+ case "delete":
215
+ await runDelete(args);
155
216
  break;
217
+ case "participant":
218
+ await runParticipant(args);
219
+ break;
220
+ // Other noun groups.
156
221
  case "template":
157
- await runArtifact(args);
222
+ await runTemplate(args);
158
223
  break;
159
224
  case "key":
160
225
  await runKey(args);
@@ -177,6 +242,18 @@ async function main() {
177
242
  case "skill":
178
243
  await runSkill(args);
179
244
  break;
245
+ case "records":
246
+ await runRecords(args);
247
+ break;
248
+ case "template-records":
249
+ await runTemplateRecords(args);
250
+ break;
251
+ case "query":
252
+ await runQuery(args);
253
+ break;
254
+ case "trash":
255
+ await runTrash(args);
256
+ break;
180
257
  }
181
258
  }
182
259
  main().catch((err) => {
package/dist/output.js CHANGED
@@ -8,7 +8,7 @@ export function printJson(value) {
8
8
  process.stdout.write(JSON.stringify(value, null, 2) + "\n");
9
9
  }
10
10
  /**
11
- * Print a single compact JSON line to stdout and flush. Used by `pane surface watch`
11
+ * Print a single compact JSON line to stdout and flush. Used by `pane watch`
12
12
  * so a pipe-reader (e.g. Claude Code's Monitor tool) sees each event
13
13
  * immediately, one event per line.
14
14
  */