gipity 1.0.410 → 1.0.412

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,148 @@
1
+ const DEFAULTS = {
2
+ maxEntries: 2000,
3
+ maxBytes: 8 * 1024 * 1024,
4
+ batchMax: 200,
5
+ backoffMs: [2000, 5000, 15000, 60000],
6
+ };
7
+ function entryBytes(e) {
8
+ try {
9
+ return JSON.stringify(e).length;
10
+ }
11
+ catch {
12
+ return 1024;
13
+ }
14
+ }
15
+ export class IngestQueue {
16
+ post;
17
+ queue = [];
18
+ bytes = 0;
19
+ draining = false;
20
+ closed = false;
21
+ closeDeadline = 0;
22
+ drainDone = Promise.resolve();
23
+ droppedTotal = 0;
24
+ markedDrop = false;
25
+ opts;
26
+ constructor(post, opts = {}) {
27
+ this.post = post;
28
+ this.opts = { ...DEFAULTS, ...opts };
29
+ }
30
+ get dropped() {
31
+ return this.droppedTotal;
32
+ }
33
+ /** Enqueue entries in order and kick the drainer. Sync and non-blocking. */
34
+ push(...entries) {
35
+ if (this.closed) {
36
+ this.opts.onWarn?.('ingest queue push after close - dropped', { count: entries.length });
37
+ this.droppedTotal += entries.length;
38
+ return;
39
+ }
40
+ for (const entry of entries) {
41
+ const bytes = entryBytes(entry);
42
+ this.queue.push({ entry, bytes });
43
+ this.bytes += bytes;
44
+ }
45
+ this.enforceBounds();
46
+ this.kick();
47
+ }
48
+ /** Stop accepting entries and drain what's left, giving up at the
49
+ * deadline. Returns whether everything flushed. */
50
+ async close(deadlineMs = 30_000) {
51
+ this.closed = true;
52
+ this.closeDeadline = Date.now() + deadlineMs;
53
+ this.kick();
54
+ // Await until the drainer has fully settled. The re-kick in `kick`'s
55
+ // finally is gated on `!closed`, so once we're here no NEW drain can
56
+ // start - a single settled await is sufficient, but loop defensively
57
+ // in case a kick was mid-settle when `closed` flipped.
58
+ while (this.draining)
59
+ await this.drainDone;
60
+ await this.drainDone;
61
+ const flushed = this.queue.length === 0;
62
+ if (!flushed) {
63
+ this.droppedTotal += this.queue.length;
64
+ this.opts.onWarn?.('ingest queue close deadline hit - entries lost', { remaining: this.queue.length });
65
+ this.queue = [];
66
+ this.bytes = 0;
67
+ }
68
+ return { flushed, dropped: this.droppedTotal };
69
+ }
70
+ enforceBounds() {
71
+ let droppedNow = 0;
72
+ while (this.queue.length > this.opts.maxEntries || this.bytes > this.opts.maxBytes) {
73
+ const victim = this.queue.shift();
74
+ if (!victim)
75
+ break;
76
+ this.bytes -= victim.bytes;
77
+ droppedNow++;
78
+ }
79
+ if (droppedNow > 0) {
80
+ this.droppedTotal += droppedNow;
81
+ this.opts.onWarn?.('ingest queue overflow - oldest entries dropped', { droppedNow, droppedTotal: this.droppedTotal });
82
+ if (!this.markedDrop) {
83
+ this.markedDrop = true;
84
+ // Visible gap marker, placed at the head so it lands before the
85
+ // surviving (newer) entries.
86
+ const marker = { kind: 'system', content: 'Some session output could not be uploaded (upload backlog overflowed).' };
87
+ this.queue.unshift({ entry: marker, bytes: entryBytes(marker) });
88
+ this.bytes += entryBytes(marker);
89
+ }
90
+ }
91
+ }
92
+ kick() {
93
+ if (this.draining)
94
+ return;
95
+ this.draining = true;
96
+ this.drainDone = this.drain().finally(() => {
97
+ this.draining = false;
98
+ // Entries may have arrived while the final await of drain() settled.
99
+ // Never re-kick once closed: close() owns the last drain, and a
100
+ // re-kick here would POST a batch AFTER close() returned (the
101
+ // zombie-drain bug - post-after-ack + corrupted byte accounting).
102
+ if (this.queue.length && !this.closed)
103
+ this.kick();
104
+ });
105
+ }
106
+ async drain() {
107
+ const sleep = this.opts.sleep ?? ((ms) => new Promise(r => setTimeout(r, ms)));
108
+ let attempt = 0;
109
+ while (this.queue.length) {
110
+ // Remove the batch from the queue BEFORE awaiting the POST. A push
111
+ // that overflows the bounds during the await runs `enforceBounds`,
112
+ // which shifts the queue head - if the in-flight batch were still
113
+ // at the head, a later splice-by-index would remove the wrong
114
+ // entries and drift `bytes`. Holding the batch out of the queue
115
+ // makes overflow touch only not-in-flight entries.
116
+ const batch = this.queue.splice(0, this.opts.batchMax);
117
+ const batchBytes = batch.reduce((n, b) => n + b.bytes, 0);
118
+ this.bytes -= batchBytes;
119
+ const result = await this.post(batch.map(b => b.entry));
120
+ if (result.ok) {
121
+ attempt = 0;
122
+ continue;
123
+ }
124
+ // Definitive rejection (4xx: schema or auth) - a replay can never
125
+ // succeed, and retrying would stall the queue behind a poisoned
126
+ // batch. Drop it (counted + warned) and move on.
127
+ if (result.retryable === false) {
128
+ attempt = 0;
129
+ this.droppedTotal += batch.length;
130
+ this.opts.onWarn?.('ingest batch rejected by server - dropped', { count: batch.length });
131
+ continue;
132
+ }
133
+ // Transient failure: put the batch back at the head and retry after
134
+ // backoff (order preserved; source_uuid dedup makes a partial
135
+ // server-side success safe).
136
+ this.queue.unshift(...batch);
137
+ this.bytes += batchBytes;
138
+ const backoff = this.opts.backoffMs[Math.min(attempt, this.opts.backoffMs.length - 1)];
139
+ attempt++;
140
+ if (this.closed && Date.now() + backoff > this.closeDeadline)
141
+ return;
142
+ await sleep(backoff);
143
+ if (this.closed && Date.now() > this.closeDeadline)
144
+ return;
145
+ }
146
+ }
147
+ }
148
+ //# sourceMappingURL=ingest-queue.js.map
@@ -1,13 +1,20 @@
1
1
  /**
2
- * One-time first-run relay onboarding. Called from `gipity claude` after
3
- * auth + project selection. Asks up to four Y/n questions (all default Y);
4
- * pressing Enter four times leaves the user with a paired + running
5
- * daemon that auto-starts on every subsequent `gipity claude` invocation,
6
- * and - if they said yes to the last question - also starts at OS login.
2
+ * Interactive relay setup the single flow that pairs this computer as a
3
+ * relay, starts the daemon, and (optionally) installs the OS login service.
4
+ * ONE implementation, shared by both entry points:
5
+ *
6
+ * - `gipity claude` → `maybeOfferRelayOn()` (mode 'offer-once': runs on the
7
+ * very first launch, then never nags again).
8
+ * - `gipity setup` → `runRelaySetup({ mode: 'run-now' })` (the user asked
9
+ * for it explicitly, so always run — even to re-pair or
10
+ * fix autostart on a machine that already answered).
11
+ *
12
+ * All the non-interactive primitives live in `setup.ts` (`pairDevice`,
13
+ * `startDaemon`, `installAutostart`); this module is only the prompts + copy.
7
14
  */
8
15
  import { hostname } from 'os';
9
16
  import { prompt, confirm } from '../utils.js';
10
- import { bold, brand, dim, success, error as clrError, muted, info } from '../colors.js';
17
+ import { bold, brand, dim, success, error as clrError, muted } from '../colors.js';
11
18
  import * as state from './state.js';
12
19
  import { UnsupportedPlatformError } from './installers.js';
13
20
  import { pairDevice, startDaemon, installAutostart } from './setup.js';
@@ -16,28 +23,41 @@ import { pairDevice, startDaemon, installAutostart } from './setup.js';
16
23
  * keep their import path. */
17
24
  export const ensureDaemonRunning = startDaemon;
18
25
  /**
19
- * First-run prompt block. Idempotent: if the user has already answered
20
- * (`relay_enabled` is a boolean), this is a no-op. Non-interactive flows
21
- * (e.g. `gipity claude -p`) should skip calling this.
26
+ * Pair this computer as a relay and get it running. Returns `true` if the relay
27
+ * ended up enabled (or was already), `false` if the user declined or a step
28
+ * failed. Non-interactive flows (e.g. `gipity claude -p`) must not call this.
22
29
  */
23
- export async function maybeOfferRelayOn() {
24
- if (state.getRelayEnabled() !== undefined) {
25
- // Already answered - just ensure the daemon is running if they're opted in.
30
+ export async function runRelaySetup(opts) {
31
+ const alreadyAnswered = state.getRelayEnabled() !== undefined;
32
+ // `gipity claude` first-run: honor the user's prior choice and don't re-ask.
33
+ if (opts.mode === 'offer-once' && alreadyAnswered) {
26
34
  if (state.isRelayEnabled() && !state.isPaused())
27
35
  ensureDaemonRunning();
28
- return;
36
+ return state.isRelayEnabled();
37
+ }
38
+ // Header. `gipity setup` frames it as the deliberate action it is; the
39
+ // `gipity claude` first-run frames it as an optional add-on it's offering.
40
+ if (opts.mode === 'run-now') {
41
+ console.log(` ${bold('Set up this computer as a relay')}`);
42
+ console.log(` ${dim('A relay runs Claude Code (or Codex) here so you can drive it from the web (')}${brand('gipity.ai')}${dim(') on any browser.')}`);
43
+ console.log(` ${dim('It uses your Claude or Codex subscription — the cheapest way to pay for tokens.')}`);
44
+ }
45
+ else {
46
+ console.log(` ${bold('Remote control of Claude Code')}`);
47
+ console.log(` ${dim('Drive this Claude Code from the web (')}${brand('gipity.ai')}${dim(') on any browser (desktop or phone).')}`);
48
+ console.log('');
49
+ console.log(` ${dim('Enable now (takes 2 seconds) or turn on later with')} ${brand('gipity setup')}`);
29
50
  }
30
- console.log(` ${bold('Remote control of Claude Code')}`);
31
- console.log(` ${dim('Drive this Claude Code from the web (')}${brand('gipity.ai')}${dim(') on any browser (desktop or phone).')}`);
32
- console.log('');
33
- console.log(` ${dim('Enable now (takes 2 seconds) or turn on later with')} ${brand('gipity relay install')}`);
34
51
  console.log('');
35
- const enable = await confirm(' Enable remote control?', { default: 'yes' });
52
+ const promptText = opts.mode === 'run-now'
53
+ ? ' Set up remote control on this computer?'
54
+ : ' Enable remote control?';
55
+ const enable = await confirm(promptText, { default: 'yes' });
36
56
  if (!enable) {
37
57
  state.setRelayEnabled(false);
38
58
  console.log(` ${muted('Skipped.')}`);
39
59
  console.log('');
40
- return;
60
+ return false;
41
61
  }
42
62
  // Device name - show hostname as the default; Enter accepts.
43
63
  const defaultName = hostname() || 'my-pc';
@@ -46,7 +66,7 @@ export async function maybeOfferRelayOn() {
46
66
  if (!name || name.length > 100) {
47
67
  console.error(` ${clrError('Device name must be 1–100 non-whitespace characters. Skipping.')}`);
48
68
  state.setRelayEnabled(false);
49
- return;
69
+ return false;
50
70
  }
51
71
  // Create the device directly (user-auth, no pair code). `pairDevice` writes
52
72
  // the device + flips relay_enabled on; we only have to render the outcome.
@@ -56,12 +76,12 @@ export async function maybeOfferRelayOn() {
56
76
  }
57
77
  catch (err) {
58
78
  console.error(`\n ${clrError(`Could not create device: ${err?.message || err}`)}`);
59
- console.error(` ${dim('Skipping for now - we\'ll offer again next time. Or turn it on with `gipity relay install`.')}`);
79
+ console.error(` ${dim('Skipping for now - we\'ll offer again next time. Or turn it on with `gipity setup`.')}`);
60
80
  // Deliberately DON'T persist relay_enabled here: the user SAID YES, and
61
81
  // this is a transient failure (network blip, server down). Leaving the
62
82
  // tri-state unset means onboarding re-offers on the next `gipity claude`
63
83
  // run instead of silently opting the user out forever.
64
- return;
84
+ return false;
65
85
  }
66
86
  // Start the daemon for this session.
67
87
  const startNow = await confirm(' Start the relay now (and on future `gipity claude` runs)?', { default: 'yes' });
@@ -93,6 +113,14 @@ export async function maybeOfferRelayOn() {
93
113
  console.log(` ${success(`Registered as ${bold(device.name)} (${device.guid}).`)}`);
94
114
  console.log(` ${dim('In the Gipity web CLI, type `/claude` to dispatch messages to this PC.')}`);
95
115
  console.log('');
96
- void info;
116
+ return true;
117
+ }
118
+ /**
119
+ * First-run prompt block for `gipity claude`. Idempotent: if the user has
120
+ * already answered, this is a no-op (beyond keeping the daemon alive).
121
+ * Non-interactive flows (e.g. `gipity claude -p`) should skip calling this.
122
+ */
123
+ export async function maybeOfferRelayOn() {
124
+ await runRelaySetup({ mode: 'offer-once' });
97
125
  }
98
126
  //# sourceMappingURL=onboarding.js.map
@@ -60,7 +60,7 @@ const THIRD_PARTY_KEY_RES = [
60
60
  ];
61
61
  /** Replace every occurrence of each known secret - plus any JWT-, Anthropic-,
62
62
  * or well-known-third-party-key-shaped substring - in `text` with the marker. */
63
- function redactString(text, secrets) {
63
+ export function redactString(text, secrets) {
64
64
  let out = text;
65
65
  for (const secret of secrets) {
66
66
  if (out.includes(secret))
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Token-streaming delta pipeline for the relay daemon.
3
+ *
4
+ * With `--include-partial-messages`, Claude Code emits `stream_event`
5
+ * envelopes (content_block_start / content_block_delta / content_block_stop)
6
+ * carrying text, thinking, and tool-input fragments as they generate. The
7
+ * daemon forwards them - throttled and redacted - to
8
+ * `POST /remote-sessions/:convGuid/stream-delta`, which the server
9
+ * broadcasts to the web CLI as `dispatch:delta`. Purely ephemeral: no DB
10
+ * write, no retry; the persisted assistant/tool entries (whole events)
11
+ * remain the source of truth and replace the streamed view on the next
12
+ * refresh.
13
+ *
14
+ * Redaction across chunk boundaries: a secret split over two fragments
15
+ * would defeat a per-fragment scrub. The accumulator therefore only ever
16
+ * emits up to the last WHITESPACE boundary (holding back the trailing
17
+ * partial token until more input or block close) and scrubs each emitted
18
+ * span. Secrets in our threat set (host OAuth/API/device tokens, JWTs,
19
+ * provider keys) are contiguous non-whitespace tokens, so a held-back
20
+ * partial token can never leak a complete secret and a completed token is
21
+ * always scrubbed whole. Multi-line PEM blocks are the one shape this
22
+ * can't fully hold back - accepted on this ephemeral channel (the
23
+ * persisted path fully redacts, and the streamed text is replaced by the
24
+ * redacted stored message within seconds).
25
+ */
26
+ import { redactString } from './redact.js';
27
+ /** A trailing token longer than this is emitted anyway (minified code,
28
+ * base64 blobs) - holding back indefinitely would stall the stream. No
29
+ * credential in our threat set is this long. */
30
+ const MAX_HOLDBACK = 4096;
31
+ /** PEM key markers. A private key block spans multiple whitespace-
32
+ * separated lines, so the whitespace-boundary holdback would emit
33
+ * `BEGIN` before `END` arrives and per-span redaction would miss it.
34
+ * emitUpTo holds the whole block back until END so it scrubs whole. */
35
+ const PEM_BEGIN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
36
+ const PEM_END = /-----END [A-Z ]*PRIVATE KEY-----/;
37
+ /** Emit boundary: everything up to (and including) the last whitespace.
38
+ * `closing` (block stop) emits everything. */
39
+ export function safeEmitBoundary(s, closing) {
40
+ if (closing)
41
+ return s.length;
42
+ const m = s.match(/\S+$/);
43
+ if (!m)
44
+ return s.length;
45
+ if (m[0].length > MAX_HOLDBACK)
46
+ return s.length;
47
+ return s.length - m[0].length;
48
+ }
49
+ /**
50
+ * Per-spawn accumulator: feed it raw stream_event envelopes, it returns
51
+ * redaction-safe StreamDeltaEvents ready to batch. Skips subagent events
52
+ * (parent_tool_use_id set) - those belong to the nested Task timeline,
53
+ * not the main stream zone.
54
+ */
55
+ export class DeltaAccumulator {
56
+ getSecrets;
57
+ blocks = new Map();
58
+ /** The in-flight message's id, captured from `message_start` -
59
+ * content_block_* events don't carry it themselves. This is what makes
60
+ * the client-side finalize swap line up: persisted assistant rows are
61
+ * keyed `msg_id#n` (Phase 2 source_uuid), streamed blocks `msg_id:n`. */
62
+ currentMessageId = 'msg';
63
+ constructor(getSecrets) {
64
+ this.getSecrets = getSecrets;
65
+ }
66
+ /** Map one parsed stream-json event to zero or more delta events. */
67
+ note(evt) {
68
+ if (evt.type !== 'stream_event')
69
+ return [];
70
+ if (evt.parent_tool_use_id)
71
+ return []; // subagent stream - not ours (Phase 6)
72
+ const inner = evt.event;
73
+ if (!inner || typeof inner.type !== 'string')
74
+ return [];
75
+ if (inner.type === 'message_start') {
76
+ if (typeof inner.message?.id === 'string' && inner.message.id) {
77
+ this.currentMessageId = inner.message.id;
78
+ }
79
+ return [];
80
+ }
81
+ const messageId = this.currentMessageId;
82
+ if (inner.type === 'content_block_start') {
83
+ const idx = typeof inner.index === 'number' ? inner.index : 0;
84
+ const cb = inner.content_block ?? {};
85
+ const type = cb.type === 'text' ? 'text' : cb.type === 'thinking' ? 'thinking' : cb.type === 'tool_use' ? 'tool_use' : null;
86
+ if (!type)
87
+ return [];
88
+ this.blocks.set(`${messageId}:${idx}`, { type, raw: '', rawEmitted: 0, outEmitted: 0, started: true });
89
+ const out = { message_id: messageId, block_index: idx, block_type: type, kind: 'start' };
90
+ if (type === 'tool_use' && typeof cb.name === 'string')
91
+ out.tool_name = cb.name;
92
+ return [out];
93
+ }
94
+ if (inner.type === 'content_block_delta') {
95
+ const idx = typeof inner.index === 'number' ? inner.index : 0;
96
+ const d = inner.delta ?? {};
97
+ const fragment = typeof d.text === 'string' ? d.text
98
+ : typeof d.thinking === 'string' ? d.thinking
99
+ : typeof d.partial_json === 'string' ? d.partial_json
100
+ : null;
101
+ if (fragment === null || fragment === '')
102
+ return [];
103
+ const key = `${messageId}:${idx}`;
104
+ let b = this.blocks.get(key);
105
+ if (!b) {
106
+ // Delta without a start (daemon restarted mid-message or an
107
+ // unknown block type at start time) - infer the type.
108
+ const type = typeof d.partial_json === 'string' ? 'tool_use'
109
+ : typeof d.thinking === 'string' ? 'thinking' : 'text';
110
+ b = { type, raw: '', rawEmitted: 0, outEmitted: 0, started: false };
111
+ this.blocks.set(key, b);
112
+ }
113
+ b.raw += fragment;
114
+ return this.emitUpTo(messageId, idx, b, false);
115
+ }
116
+ if (inner.type === 'content_block_stop') {
117
+ const idx = typeof inner.index === 'number' ? inner.index : 0;
118
+ const key = `${messageId}:${idx}`;
119
+ const b = this.blocks.get(key);
120
+ if (!b)
121
+ return [];
122
+ const out = this.emitUpTo(messageId, idx, b, true);
123
+ out.push({ message_id: messageId, block_index: idx, block_type: b.type, kind: 'stop' });
124
+ this.blocks.delete(key);
125
+ return out;
126
+ }
127
+ return [];
128
+ }
129
+ emitUpTo(messageId, idx, b, closing) {
130
+ // Scan only the not-yet-emitted tail: the boundary can never precede
131
+ // rawEmitted (already a whitespace boundary), and running the regex
132
+ // over the full accumulated `raw` on every fragment is O(n²) over a
133
+ // long block - a big minified/base64 tool-input block would stall the
134
+ // daemon's event loop (which also drives the ingest queue + ticks).
135
+ const tail = b.raw.slice(b.rawEmitted);
136
+ // PEM keys are the one multi-LINE secret the whitespace-boundary
137
+ // holdback doesn't catch: `-----BEGIN … PRIVATE KEY-----` and its
138
+ // `-----END-----` land in different spans, so per-span redactString
139
+ // never sees them together. When an unterminated BEGIN marker is in
140
+ // the pending tail, hold the WHOLE tail back until END arrives (or the
141
+ // block closes), so redactString scrubs the complete block in one span.
142
+ if (!closing && PEM_BEGIN.test(tail) && !PEM_END.test(tail))
143
+ return [];
144
+ const tailBoundary = safeEmitBoundary(tail, closing);
145
+ if (tailBoundary <= 0)
146
+ return [];
147
+ const span = tail.slice(0, tailBoundary);
148
+ const boundary = b.rawEmitted + tailBoundary;
149
+ b.rawEmitted = boundary;
150
+ const scrubbed = redactString(span, this.getSecrets());
151
+ if (!scrubbed)
152
+ return [];
153
+ const evt = {
154
+ message_id: messageId,
155
+ block_index: idx,
156
+ block_type: b.type,
157
+ kind: 'delta',
158
+ text: scrubbed,
159
+ acc_offset: b.outEmitted,
160
+ };
161
+ b.outEmitted += scrubbed.length;
162
+ return [evt];
163
+ }
164
+ }
165
+ // ─── Throttled batcher ──────────────────────────────────────────────────
166
+ const FLUSH_INTERVAL_MS = 150;
167
+ const FLUSH_BYTES = 4096;
168
+ /** Server-side caps (mirror the /stream-delta Zod schema). */
169
+ const MAX_EVENTS_PER_FLUSH = 64;
170
+ const MAX_TEXT_PER_FLUSH = 16_384;
171
+ /**
172
+ * Buffers delta events and flushes them as numbered batches every 150ms
173
+ * or 4KB, whichever first. Fire-and-forget by design: a lost flush
174
+ * self-heals (the client detects the `acc_offset` gap and the next
175
+ * refresh replaces the stream zone with the stored message anyway).
176
+ */
177
+ export class DeltaBatcher {
178
+ send;
179
+ intervalMs;
180
+ buffer = [];
181
+ bufferedText = 0;
182
+ seq = 0;
183
+ timer = null;
184
+ closed = false;
185
+ constructor(send, intervalMs = FLUSH_INTERVAL_MS) {
186
+ this.send = send;
187
+ this.intervalMs = intervalMs;
188
+ }
189
+ push(events) {
190
+ if (this.closed || events.length === 0)
191
+ return;
192
+ for (const e of events) {
193
+ this.buffer.push(e);
194
+ this.bufferedText += e.text?.length ?? 0;
195
+ }
196
+ if (this.bufferedText >= FLUSH_BYTES || this.buffer.length >= MAX_EVENTS_PER_FLUSH) {
197
+ this.flush();
198
+ return;
199
+ }
200
+ if (!this.timer) {
201
+ this.timer = setTimeout(() => this.flush(), this.intervalMs);
202
+ this.timer.unref?.();
203
+ }
204
+ }
205
+ flush() {
206
+ if (this.timer) {
207
+ clearTimeout(this.timer);
208
+ this.timer = null;
209
+ }
210
+ while (this.buffer.length) {
211
+ // Respect both server caps per POST: event count and total text.
212
+ const batch = [];
213
+ let text = 0;
214
+ while (this.buffer.length && batch.length < MAX_EVENTS_PER_FLUSH) {
215
+ const next = this.buffer[0];
216
+ const nextLen = next.text?.length ?? 0;
217
+ if (batch.length > 0 && text + nextLen > MAX_TEXT_PER_FLUSH)
218
+ break;
219
+ // A single oversized event gets truncated rather than wedging the queue.
220
+ if (nextLen > MAX_TEXT_PER_FLUSH)
221
+ next.text = next.text.slice(0, MAX_TEXT_PER_FLUSH);
222
+ batch.push(this.buffer.shift());
223
+ text += next.text?.length ?? 0;
224
+ }
225
+ this.bufferedText = Math.max(0, this.bufferedText - text);
226
+ this.send({ seq: this.seq++, events: batch });
227
+ }
228
+ this.bufferedText = 0;
229
+ }
230
+ close() {
231
+ this.flush();
232
+ this.closed = true;
233
+ }
234
+ }
235
+ //# sourceMappingURL=stream-delta.js.map
@@ -58,31 +58,61 @@ function joinAssistantText(content) {
58
58
  }
59
59
  return parts.join('\n');
60
60
  }
61
- /** Map a single stream-json event to zero or more ingest entries. An
62
- * assistant event may yield one `assistant` entry AND one `tool_use`
63
- * entry per tool_use block within it (server persists each tool call
64
- * as its own `role='tool'` row, then updates it when the matching
65
- * `tool_result` arrives).
66
- *
67
- * `toolNames` (optional) is a per-dispatch `tool_use_id → tool_name`
68
- * map threaded across events by the daemon: assistant events record
69
- * each tool call's name, the later user event with the paired
70
- * `tool_result` reads it back so the server can denormalize `tool_name`
71
- * onto the tool row even when the result lands as a stub. */
72
- export function mapEventToEntries(evt, toolNames) {
61
+ export function mapEventToEntries(evt, ctx) {
62
+ // Back-compat: older call sites (and tests) pass the bare toolNames map.
63
+ const c = ctx instanceof Map ? { toolNames: ctx } : (ctx ?? {});
64
+ const toolNames = c.toolNames;
73
65
  const out = [];
74
66
  if (evt.type === 'system' && evt.subtype === 'init') {
75
67
  const s = evt;
76
- if (s.session_id)
77
- out.push({ kind: 'attach', session_id: s.session_id, cwd: s.cwd, source: 'startup' });
68
+ if (s.session_id) {
69
+ out.push({
70
+ kind: 'attach',
71
+ session_id: s.session_id,
72
+ cwd: s.cwd,
73
+ source: 'startup',
74
+ model: typeof s.model === 'string' ? s.model : undefined,
75
+ tools_count: Array.isArray(s.tools) ? s.tools.length : undefined,
76
+ mcp_count: Array.isArray(s.mcp_servers) ? s.mcp_servers.length : undefined,
77
+ api_key_source: typeof s.apiKeySource === 'string' ? s.apiKeySource : undefined,
78
+ });
79
+ }
78
80
  return out;
79
81
  }
82
+ // Context compaction boundary - the server has a `compact` kind for this
83
+ // (renders as a "context compacted" marker); nothing emitted it before.
84
+ if (evt.type === 'system' && evt.subtype === 'compact_boundary') {
85
+ const trigger = evt.compact_metadata?.trigger ?? evt.trigger;
86
+ out.push({ kind: 'compact', trigger: typeof trigger === 'string' ? trigger : undefined });
87
+ return out;
88
+ }
89
+ // Subagent attribution: the envelope carries the spawning Task/Agent
90
+ // tool_use id on every event from that subagent's turn.
91
+ const parentToolUseId = typeof evt.parent_tool_use_id === 'string'
92
+ ? evt.parent_tool_use_id : undefined;
80
93
  if (evt.type === 'assistant') {
81
94
  const msg = evt.message ?? {};
82
95
  const content = Array.isArray(msg.content) ? msg.content : [];
83
96
  const text = joinAssistantText(content);
84
97
  if (text || content.length) {
85
- out.push({ kind: 'assistant', text, blocks: content, ...usageFields(msg) });
98
+ const entry = { kind: 'assistant', text, blocks: content, ...usageFields(msg), parent_tool_use_id: parentToolUseId };
99
+ // source_uuid = `${msg.id}#${eventSeq}`. The client's stream-zone
100
+ // finalize-swap matches this against its block key `${msg.id}:${block_index}`,
101
+ // i.e. it relies on eventSeq === block_index. That holds because
102
+ // Claude Code (with --include-partial-messages) emits exactly ONE
103
+ // assistant event per content block, in index order (verified
104
+ // against CLI v2.1.199 in the Phase 0 spike: a thinking block and a
105
+ // text block arrived as two separate assistant events, seq 0 and 1,
106
+ // matching block indices 0 and 1). If a future CLI ever packs two
107
+ // blocks into one assistant event, seq lags index for later blocks
108
+ // and those streamed blocks aren't removed until the ack wipes the
109
+ // zone - a cosmetic, self-healing duplication, not data loss.
110
+ if (typeof msg.id === 'string' && msg.id && c.msgSeq) {
111
+ const n = c.msgSeq.get(msg.id) ?? 0;
112
+ c.msgSeq.set(msg.id, n + 1);
113
+ entry.source_uuid = `${msg.id}#${n}`;
114
+ }
115
+ out.push(entry);
86
116
  }
87
117
  for (const block of content) {
88
118
  if (block?.type === 'tool_use' && typeof block.id === 'string') {
@@ -93,6 +123,8 @@ export function mapEventToEntries(evt, toolNames) {
93
123
  tool_use_id: block.id,
94
124
  tool_name: toolName,
95
125
  tool_input: block.input ?? null,
126
+ source_uuid: block.id,
127
+ parent_tool_use_id: parentToolUseId,
96
128
  });
97
129
  }
98
130
  }
@@ -109,6 +141,7 @@ export function mapEventToEntries(evt, toolNames) {
109
141
  tool_name: toolNames?.get(block.tool_use_id),
110
142
  content: block.content ?? null,
111
143
  is_error: Boolean(block.is_error),
144
+ parent_tool_use_id: parentToolUseId,
112
145
  });
113
146
  }
114
147
  }
@@ -130,6 +163,19 @@ export function mapEventToEntries(evt, toolNames) {
130
163
  }
131
164
  return out;
132
165
  }
166
+ // Deliberately unmapped chatter (hook lifecycle, per-chunk thinking
167
+ // token counts, rate-limit notices, and stream_event token deltas -
168
+ // the latter are handled by the separate stream-delta pipeline) -
169
+ // skipped without accounting so a normal session doesn't log dozens of
170
+ // "unmapped" entries. Everything else unknown IS accounted via
171
+ // onUnmapped so a new Claude Code event type shows up in the daemon
172
+ // log instead of vanishing silently.
173
+ const subtype = evt.subtype;
174
+ const KNOWN_NOISE = new Set(['hook_started', 'hook_response', 'thinking_tokens', 'status', 'task_started', 'task_progress', 'task_updated', 'task_notification']);
175
+ if (!(evt.type === 'system' && subtype && KNOWN_NOISE.has(subtype))
176
+ && evt.type !== 'rate_limit_event' && evt.type !== 'stream_event') {
177
+ c.onUnmapped?.(evt.type, subtype);
178
+ }
133
179
  return out;
134
180
  }
135
181
  /** Pull token usage + model + stop_reason off an assistant stream `message`.