@pinagent/react-native 0.2.4 → 0.2.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.
@@ -0,0 +1,40 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+
3
+ /**
4
+ * Keyboard-shortcut helpers for the React Native widget.
5
+ *
6
+ * The browser widget wires its hotkeys to `document`-level `keydown`
7
+ * listeners (see packages/widget/src/keyboard.ts). React Native has no such
8
+ * global key stream in JS: the core `Keyboard` module only reports the soft
9
+ * keyboard showing/hiding, never key presses, and there's no document to
10
+ * listen on. A faithful port of the *global* web hotkeys (toggle picker,
11
+ * hop-to-next-agent, minimize-all) would need a native module — iOS
12
+ * `UIKeyCommand` / an Android key bridge — which we deliberately avoid: the RN
13
+ * widget ships as plain JS source for Metro with zero native setup.
14
+ *
15
+ * What IS reachable, and all we need in practice, are the key events a focused
16
+ * `TextInput` surfaces — so the shortcuts live where a hardware keyboard is
17
+ * actually in play (the composer and the stream sheet, both modal):
18
+ * - Return/Enter, via `onSubmitEditing` on the single-line inputs — submits
19
+ * the agent answer and the follow-up, mirroring the web composer's
20
+ * Enter-to-send. Wired directly on the input (no key inspection needed).
21
+ * - Escape, via `onKeyPress` — backs out of a sheet, mirroring the web
22
+ * widget's Escape. Decided here so the (RN-runtime-only, untestable)
23
+ * components stay thin and the rule is unit-tested.
24
+ *
25
+ * `onKeyPress`'s `nativeEvent` only carries `key` on native platforms (no
26
+ * modifier flags), so these predicates take the bare key name — there's no
27
+ * Shift/Cmd to branch on, which is also why plain Enter stays a newline in the
28
+ * multiline composer rather than hijacking submit. Hardware Back (Android) is
29
+ * handled separately by each Modal's `onRequestClose`.
30
+ */
31
+
32
+ /**
33
+ * Should this key event back out of the current sheet? Escape on a hardware
34
+ * keyboard — the RN analog of the web widget's Escape. Only the Escape key
35
+ * qualifies; every printable key (i.e. normal typing) is ignored, so an
36
+ * `onKeyPress` handler built on this never interferes with text entry.
37
+ */
38
+ export function isDismissKey(key: string): boolean {
39
+ return key === 'Escape';
40
+ }
@@ -0,0 +1,194 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Tiny, dependency-free Markdown parser for the RN widget.
4
+ *
5
+ * The agent streams its replies as Markdown — Claude writes **bold**, `code`,
6
+ * fenced blocks, bullet/numbered lists, headings and links. The StreamSheet
7
+ * used to drop that straight into a <Text>, so the markers showed up as
8
+ * literal characters. This module folds the raw text into a small block/inline
9
+ * tree that `Markdown.tsx` renders with React Native primitives — no
10
+ * markdown-it, no extra runtime dependency, in keeping with the rest of the
11
+ * native source (see transcript.ts for the same "mirror, don't import" stance).
12
+ *
13
+ * It is intentionally a *subset* of CommonMark covering what shows up in chat:
14
+ * ATX headings, fenced code, blockquotes, unordered/ordered lists, thematic
15
+ * breaks, paragraphs, and inline code / bold / italic / links. Anything it
16
+ * doesn't recognise falls through as plain text, so the worst case degrades to
17
+ * the old behaviour rather than dropping content. Pure and deterministic, so
18
+ * it's unit-tested like the transcript reducer.
19
+ */
20
+
21
+ /** An inline run carrying at most the marks we render. */
22
+ export interface InlineSpan {
23
+ text: string;
24
+ bold?: boolean;
25
+ italic?: boolean;
26
+ code?: boolean;
27
+ /** Present on link spans; the destination URL. */
28
+ href?: string;
29
+ }
30
+
31
+ export type MdBlock =
32
+ | { type: 'heading'; level: number; spans: InlineSpan[] }
33
+ | { type: 'paragraph'; spans: InlineSpan[] }
34
+ | { type: 'code'; text: string; lang?: string }
35
+ | { type: 'list'; ordered: boolean; items: InlineSpan[][] }
36
+ | { type: 'quote'; spans: InlineSpan[] }
37
+ | { type: 'hr' };
38
+
39
+ /**
40
+ * Inline scanner: walk the string and, at each step, take the earliest of the
41
+ * recognised markers — ties broken by priority (code > link > bold > italic),
42
+ * so `**x**` reads as one bold run rather than two italics, and a backtick span
43
+ * is never re-parsed for emphasis. Text between markers is emitted verbatim;
44
+ * an unbalanced marker (a lone `*`) never matches and stays literal, so we
45
+ * never swallow content.
46
+ */
47
+ export function parseInline(input: string): InlineSpan[] {
48
+ const matchers: Array<{ re: RegExp; make: (m: RegExpExecArray) => InlineSpan }> = [
49
+ { re: /`([^`]+)`/, make: (m) => ({ text: m[1] ?? '', code: true }) },
50
+ { re: /\[([^\]]+)\]\(([^)\s]+)\)/, make: (m) => ({ text: m[1] ?? '', href: m[2] ?? '' }) },
51
+ { re: /\*\*([^*]+)\*\*/, make: (m) => ({ text: m[1] ?? '', bold: true }) },
52
+ { re: /__([^_]+)__/, make: (m) => ({ text: m[1] ?? '', bold: true }) },
53
+ { re: /\*([^*]+)\*/, make: (m) => ({ text: m[1] ?? '', italic: true }) },
54
+ { re: /_([^_]+)_/, make: (m) => ({ text: m[1] ?? '', italic: true }) },
55
+ ];
56
+
57
+ const spans: InlineSpan[] = [];
58
+ let rest = input;
59
+ while (rest.length > 0) {
60
+ let best: { index: number; length: number; span: InlineSpan } | null = null;
61
+ for (const { re, make } of matchers) {
62
+ const m = re.exec(rest);
63
+ // Strictly-less keeps the higher-priority matcher on an index tie.
64
+ if (m && (best === null || m.index < best.index)) {
65
+ best = { index: m.index, length: (m[0] ?? '').length, span: make(m) };
66
+ }
67
+ }
68
+ if (best === null) {
69
+ pushText(spans, rest);
70
+ break;
71
+ }
72
+ pushText(spans, rest.slice(0, best.index));
73
+ spans.push(best.span);
74
+ rest = rest.slice(best.index + best.length);
75
+ }
76
+ return spans;
77
+ }
78
+
79
+ function pushText(spans: InlineSpan[], text: string): void {
80
+ if (text) spans.push({ text });
81
+ }
82
+
83
+ /**
84
+ * Fold raw Markdown into render-ready blocks. Line-oriented and greedy:
85
+ * consecutive list items fold into one list, consecutive `>` lines into one
86
+ * quote, and consecutive plain lines into one paragraph (soft-wrapped lines are
87
+ * joined with a space). Blank lines separate paragraphs. Pure and
88
+ * deterministic.
89
+ */
90
+ export function parseMarkdown(source: string): MdBlock[] {
91
+ const blocks: MdBlock[] = [];
92
+ const lines = source.replace(/\r\n?/g, '\n').split('\n');
93
+
94
+ let paragraph: string[] = [];
95
+ function flushParagraph(): void {
96
+ const text = paragraph.join(' ').trim();
97
+ paragraph = [];
98
+ if (text) blocks.push({ type: 'paragraph', spans: parseInline(text) });
99
+ }
100
+
101
+ let i = 0;
102
+ while (i < lines.length) {
103
+ const line = lines[i] ?? '';
104
+
105
+ // Fenced code block: ``` or ```lang … until a closing ``` (or EOF).
106
+ const fence = /^\s*```(.*)$/.exec(line);
107
+ if (fence) {
108
+ flushParagraph();
109
+ const lang = (fence[1] ?? '').trim();
110
+ const body: string[] = [];
111
+ i++;
112
+ while (i < lines.length) {
113
+ const l = lines[i] ?? '';
114
+ if (/^\s*```\s*$/.test(l)) break;
115
+ body.push(l);
116
+ i++;
117
+ }
118
+ i++; // consume the closing fence (no-op at EOF)
119
+ blocks.push({ type: 'code', text: body.join('\n'), ...(lang ? { lang } : {}) });
120
+ continue;
121
+ }
122
+
123
+ // Blank line: end the current paragraph.
124
+ if (/^\s*$/.test(line)) {
125
+ flushParagraph();
126
+ i++;
127
+ continue;
128
+ }
129
+
130
+ // Thematic break: a line of 3+ identical -, * or _.
131
+ if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
132
+ flushParagraph();
133
+ blocks.push({ type: 'hr' });
134
+ i++;
135
+ continue;
136
+ }
137
+
138
+ // ATX heading (#–######), trailing #'s stripped.
139
+ const heading = /^\s*(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line);
140
+ if (heading) {
141
+ flushParagraph();
142
+ blocks.push({
143
+ type: 'heading',
144
+ level: (heading[1] ?? '').length,
145
+ spans: parseInline(heading[2] ?? ''),
146
+ });
147
+ i++;
148
+ continue;
149
+ }
150
+
151
+ // Blockquote: fold consecutive `>` lines together.
152
+ if (/^\s*>/.test(line)) {
153
+ flushParagraph();
154
+ const quoted: string[] = [];
155
+ while (i < lines.length) {
156
+ const l = lines[i] ?? '';
157
+ if (!/^\s*>/.test(l)) break;
158
+ quoted.push(l.replace(/^\s*>\s?/, ''));
159
+ i++;
160
+ }
161
+ blocks.push({ type: 'quote', spans: parseInline(quoted.join(' ').trim()) });
162
+ continue;
163
+ }
164
+
165
+ // List: fold consecutive items of the same kind (ordered vs unordered).
166
+ const first = matchListItem(line);
167
+ if (first) {
168
+ flushParagraph();
169
+ const items: InlineSpan[][] = [];
170
+ while (i < lines.length) {
171
+ const it = matchListItem(lines[i] ?? '');
172
+ if (!it || it.ordered !== first.ordered) break;
173
+ items.push(parseInline(it.text));
174
+ i++;
175
+ }
176
+ blocks.push({ type: 'list', ordered: first.ordered, items });
177
+ continue;
178
+ }
179
+
180
+ // Otherwise accumulate into the running paragraph.
181
+ paragraph.push(line.trim());
182
+ i++;
183
+ }
184
+ flushParagraph();
185
+ return blocks;
186
+ }
187
+
188
+ function matchListItem(line: string): { ordered: boolean; text: string } | null {
189
+ const ul = /^\s*[-*+]\s+(.*)$/.exec(line);
190
+ if (ul) return { ordered: false, text: ul[1] ?? '' };
191
+ const ol = /^\s*\d+[.)]\s+(.*)$/.exec(line);
192
+ if (ol) return { ordered: true, text: ol[1] ?? '' };
193
+ return null;
194
+ }
@@ -0,0 +1,204 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Pure run-state model for the RN widget's live agent runs.
4
+ *
5
+ * The minimized agent UI used to derive its status ad-hoc inside `StreamSheet`
6
+ * from a tangle of booleans (`running`/`done`/`transportError`/`askOpen`),
7
+ * encoded purely by dot color. This module replaces that with one explicit,
8
+ * unit-testable state machine: a {@link RunState} per run, a presentation map
9
+ * (glyph + tone + label, not color-alone), and the aggregation
10
+ * ({@link dockModel}) the compact bottom-left dock renders.
11
+ *
12
+ * It's deliberately dependency-free (only the sibling `transcript` reducer) so
13
+ * it's testable without React Native — the device UI (`AgentDock`, the expanded
14
+ * `StreamSheet`) stays a thin renderer over these pure functions, the same way
15
+ * `transcript.ts` / `restore.ts` keep their logic out of the un-testable
16
+ * RN-runtime layer.
17
+ */
18
+
19
+ import { type AgentEvent, pendingAsk } from './transcript';
20
+
21
+ /**
22
+ * The lifecycle state of one streamed agent run.
23
+ *
24
+ * - `connecting` — socket up, transcript not replayed yet (no events). The
25
+ * brief window after spawn/restore and again right after a reconnect.
26
+ * - `working` — events are flowing and the run hasn't ended.
27
+ * - `awaiting` — blocked on an `ask_user` the developer hasn't answered. The
28
+ * one state that needs the developer's attention; it pulses.
29
+ * - `done` — ended cleanly (success result, or the bus simply closed).
30
+ * - `failed` — ended on an error (server error frame, transport error, or a
31
+ * non-success result subtype).
32
+ */
33
+ export type RunState = 'connecting' | 'working' | 'awaiting' | 'done' | 'failed';
34
+
35
+ export interface RunStateInput {
36
+ /** The folded agent event stream (same array `StreamSheet` accumulates). */
37
+ events: AgentEvent[];
38
+ /** Set once the run reached a terminal `result`/`done`. */
39
+ done: boolean;
40
+ /** Non-null when a server `error` frame (or "no dev server") arrived. */
41
+ transportError: string | null;
42
+ /** `askId` → answer, so an answered question no longer reads as `awaiting`. */
43
+ answered: Record<string, string>;
44
+ }
45
+
46
+ /** Did the run's last terminal event signal failure rather than success? */
47
+ function endedInFailure(events: AgentEvent[]): boolean {
48
+ for (let i = events.length - 1; i >= 0; i--) {
49
+ const e = events[i];
50
+ if (!e) continue;
51
+ if (e.type === 'error') return true;
52
+ if (e.type === 'result') return e.subtype !== 'success';
53
+ }
54
+ return false;
55
+ }
56
+
57
+ /**
58
+ * Collapse the raw run booleans into a single {@link RunState}. Pure and
59
+ * order-sensitive: a transport error wins over everything (you can't answer or
60
+ * keep working over a dead socket), then an unanswered question, then terminal
61
+ * done/failure, else working-vs-connecting by whether any event has arrived.
62
+ */
63
+ export function deriveRunState({
64
+ events,
65
+ done,
66
+ transportError,
67
+ answered,
68
+ }: RunStateInput): RunState {
69
+ if (transportError) return 'failed';
70
+ const ask = pendingAsk(events);
71
+ if (ask && !answered[ask.askId]) return 'awaiting';
72
+ if (done) return endedInFailure(events) ? 'failed' : 'done';
73
+ return events.length > 0 ? 'working' : 'connecting';
74
+ }
75
+
76
+ /** Semantic color bucket; mapped to concrete colors in the component layer. */
77
+ export type RunTone = 'neutral' | 'active' | 'attention' | 'success' | 'danger';
78
+
79
+ export interface RunPresentation {
80
+ /** Short status word for labels / accessibility. */
81
+ label: string;
82
+ /** Monochrome glyph so state never rides on color alone. */
83
+ glyph: string;
84
+ /** Semantic tone the renderer maps to a palette entry. */
85
+ tone: RunTone;
86
+ /** Whether the chip should pulse to pull the developer back. */
87
+ pulse: boolean;
88
+ /** Non-terminal (connecting/working/awaiting) — i.e. still an active run. */
89
+ active: boolean;
90
+ }
91
+
92
+ const PRESENTATION: Record<RunState, RunPresentation> = {
93
+ connecting: { label: 'Connecting', glyph: '◌', tone: 'neutral', pulse: false, active: true },
94
+ working: { label: 'Working', glyph: '◐', tone: 'active', pulse: false, active: true },
95
+ awaiting: { label: 'Needs you', glyph: '!', tone: 'attention', pulse: true, active: true },
96
+ done: { label: 'Done', glyph: '✓', tone: 'success', pulse: false, active: false },
97
+ failed: { label: 'Failed', glyph: '✕', tone: 'danger', pulse: false, active: false },
98
+ };
99
+
100
+ /**
101
+ * Does an "interrupting" overlay actually apply right now?
102
+ *
103
+ * Stop is purely a client-side affordance over the existing `interrupt` frame
104
+ * (ticket 015): tapping it sets a local flag, and we keep showing "Stopping…"
105
+ * until the server lands a terminal event. The overlay therefore applies only
106
+ * while the run is still {@link RunPresentation.active active} — once it reaches
107
+ * a terminal `done`/`failed` state the interrupt resolved, so the overlay drops
108
+ * even if the caller's flag hasn't been cleared yet. Modeling it as an overlay
109
+ * (rather than a sixth {@link RunState}) keeps the state machine — and the dock
110
+ * aggregation — unchanged and unit-testable.
111
+ */
112
+ export function interruptOverlayActive(state: RunState, interrupting: boolean): boolean {
113
+ return interrupting && PRESENTATION[state].active;
114
+ }
115
+
116
+ /**
117
+ * Presentation for a run, optionally overlaid with an interrupting affordance.
118
+ *
119
+ * With `interrupting` true on a still-active run we relabel to "Stopping…" and
120
+ * stop any pulse (the developer asked it to halt — pulsing for attention is the
121
+ * wrong signal), keeping the underlying state's glyph/tone/active so the dock
122
+ * partitioning is undisturbed. Terminal states ignore the flag (see
123
+ * {@link interruptOverlayActive}).
124
+ */
125
+ export function runPresentation(state: RunState, interrupting = false): RunPresentation {
126
+ const base = PRESENTATION[state];
127
+ if (!interruptOverlayActive(state, interrupting)) return base;
128
+ return { ...base, label: 'Stopping…', pulse: false };
129
+ }
130
+
131
+ /** A run as the dock sees it: identity, header label, and derived state. */
132
+ export interface DockRun {
133
+ id: string;
134
+ /** Header label — `file:line` if anchored, else the component name. */
135
+ target: string;
136
+ state: RunState;
137
+ /**
138
+ * The developer tapped Stop and we're awaiting the run's terminal event
139
+ * (ticket 015). An overlay over `state`, not a state — the chip relabels to
140
+ * "Stopping…" while it's still active. Ignored once terminal.
141
+ */
142
+ interrupting?: boolean;
143
+ }
144
+
145
+ export interface DockModel {
146
+ /** connecting/working/awaiting, sorted attention-first (stable within tone). */
147
+ active: DockRun[];
148
+ /** done/failed, in input order. */
149
+ finished: DockRun[];
150
+ /** Collapse the active runs into a single count bar (true at ≥2 active). */
151
+ collapseActive: boolean;
152
+ /** How many active runs are blocked on input. */
153
+ awaitingCount: number;
154
+ /** State driving the collapsed bar's glyph/tone (most attention-worthy). */
155
+ summaryState: RunState;
156
+ /** Bar text, e.g. `3 agents · 1 needs you`. */
157
+ activeHeadline: string;
158
+ /** True when any finished run failed (lets the summary flag failures). */
159
+ finishedHasFailure: boolean;
160
+ }
161
+
162
+ /** Attention priority for ordering/summarizing active runs (higher = first). */
163
+ const ACTIVE_PRIORITY: Record<RunState, number> = {
164
+ awaiting: 3,
165
+ working: 2,
166
+ connecting: 1,
167
+ done: 0,
168
+ failed: 0,
169
+ };
170
+
171
+ /**
172
+ * Aggregate runs into what the dock draws. Active runs surface attention-first
173
+ * (a blocked run jumps above a busy one); ≥2 of them collapse into one count
174
+ * bar. Finished runs always roll into a `▸ N finished` summary. Pure so the
175
+ * hybrid + done-summary behavior is covered without rendering anything.
176
+ */
177
+ export function dockModel(runs: readonly DockRun[]): DockModel {
178
+ // Stable attention-first sort for active runs: decorate with the input index
179
+ // so equal-tone runs keep their arrival order.
180
+ const active = runs
181
+ .filter((r) => runPresentation(r.state).active)
182
+ .map((run, i) => ({ run, i }))
183
+ .sort((a, b) => ACTIVE_PRIORITY[b.run.state] - ACTIVE_PRIORITY[a.run.state] || a.i - b.i)
184
+ .map(({ run }) => run);
185
+ const finished = runs.filter((r) => !runPresentation(r.state).active);
186
+
187
+ const awaitingCount = active.filter((r) => r.state === 'awaiting').length;
188
+ const summaryState = active[0]?.state ?? 'working';
189
+ const noun = active.length === 1 ? 'agent' : 'agents';
190
+ const activeHeadline =
191
+ awaitingCount > 0
192
+ ? `${active.length} ${noun} · ${awaitingCount} needs you`
193
+ : `${active.length} ${noun}`;
194
+
195
+ return {
196
+ active,
197
+ finished,
198
+ collapseActive: active.length >= 2,
199
+ awaitingCount,
200
+ summaryState,
201
+ activeHeadline,
202
+ finishedHasFailure: finished.some((r) => r.state === 'failed'),
203
+ };
204
+ }
@@ -0,0 +1,47 @@
1
+ // SPDX-License-Identifier: Apache-2.0
2
+ /**
3
+ * Pure scroll-follow predicate for the RN widget's live transcript.
4
+ *
5
+ * The expanded `StreamSheet` transcript used to call `scrollToEnd` on every
6
+ * content change, so scrolling up to re-read earlier output during an active run
7
+ * got yanked back to the bottom by the next event. The fix is the standard
8
+ * chat-log "stick to bottom only while already near the bottom" behavior — and
9
+ * the near-bottom test is the one piece of math worth pulling out of the
10
+ * un-testable RN-runtime layer (mirrors how `run-state.ts` / `transcript.ts`
11
+ * keep their logic pure, see packages/react-native/src/native).
12
+ */
13
+
14
+ /** Default slack (in px) for treating "almost at the bottom" as "at bottom". */
15
+ export const NEAR_BOTTOM_THRESHOLD = 24;
16
+
17
+ export interface NearBottomInput {
18
+ /** `contentOffset.y` — how far the content is scrolled up from the top. */
19
+ offsetY: number;
20
+ /** `layoutMeasurement.height` — the visible viewport height. */
21
+ viewportH: number;
22
+ /** `contentSize.height` — the full scrollable content height. */
23
+ contentH: number;
24
+ /** Slack in px; the gap to the bottom that still counts as "at bottom". */
25
+ threshold?: number;
26
+ }
27
+
28
+ /**
29
+ * True when the scroll position is within `threshold` px of the bottom (so
30
+ * auto-follow should keep pinning new content into view). Pure and tolerant of
31
+ * the bouncy/over-scroll values RN can report: a negative remaining distance
32
+ * (rubber-band past the end) still reads as at-bottom, and content shorter than
33
+ * the viewport is always at-bottom. A non-finite or non-positive threshold
34
+ * falls back to {@link NEAR_BOTTOM_THRESHOLD}.
35
+ */
36
+ export function isNearBottom({
37
+ offsetY,
38
+ viewportH,
39
+ contentH,
40
+ threshold = NEAR_BOTTOM_THRESHOLD,
41
+ }: NearBottomInput): boolean {
42
+ const slack = Number.isFinite(threshold) && threshold > 0 ? threshold : NEAR_BOTTOM_THRESHOLD;
43
+ // Content that doesn't fill the viewport can't be scrolled — always pinned.
44
+ if (contentH <= viewportH) return true;
45
+ const distanceFromBottom = contentH - (offsetY + viewportH);
46
+ return distanceFromBottom <= slack;
47
+ }
@@ -84,9 +84,14 @@ export class StreamClient {
84
84
  this.send({ type: 'ask_response', askId, answer });
85
85
  }
86
86
 
87
- /** Interrupt the in-flight run. */
88
- interrupt(): void {
89
- this.send({ type: 'interrupt', feedbackId: this.feedbackId });
87
+ /**
88
+ * Interrupt the in-flight run. Returns whether the frame was actually written
89
+ * to an OPEN socket — `false` means it was dropped (socket closed/reconnecting)
90
+ * so the UI can say "couldn't stop" instead of silently no-opping. The server
91
+ * tears the run down on its own terminal event; this is just the request.
92
+ */
93
+ interrupt(): boolean {
94
+ return this.send({ type: 'interrupt', feedbackId: this.feedbackId });
90
95
  }
91
96
 
92
97
  private connect(): void {
@@ -151,13 +156,16 @@ export class StreamClient {
151
156
  }
152
157
  }
153
158
 
154
- private send(msg: Record<string, unknown>): void {
159
+ /** Write a frame if the socket is OPEN. Returns whether it was sent. */
160
+ private send(msg: Record<string, unknown>): boolean {
155
161
  const s = this.socket;
156
- if (!s || s.readyState !== 1 /* OPEN */) return;
162
+ if (!s || s.readyState !== 1 /* OPEN */) return false;
157
163
  try {
158
164
  s.send(JSON.stringify(msg));
165
+ return true;
159
166
  } catch {
160
167
  // socket closing mid-write
168
+ return false;
161
169
  }
162
170
  }
163
171