gipity 1.0.410 → 1.0.411

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.
@@ -2,7 +2,7 @@ import { resolveCommand, spawnCommand } from '../platform.js';
2
2
  import { appendFileSync, mkdirSync, existsSync, readFileSync, writeFileSync, chmodSync, closeSync, openSync, unlinkSync } from 'fs';
3
3
  import { stat, readFile } from 'fs/promises';
4
4
  import { createInterface } from 'readline';
5
- import { homedir, hostname, platform as osPlatform } from 'os';
5
+ import { homedir, hostname, platform as osPlatform, loadavg, freemem, totalmem, cpus } from 'os';
6
6
  import { join } from 'path';
7
7
  import { getApiBaseOverride, DEFAULT_API_BASE } from '../config.js';
8
8
  import { getProjectsRoot } from './paths.js';
@@ -11,8 +11,11 @@ import { getAuth, readAuthFresh } from '../auth.js';
11
11
  import { post } from '../api.js';
12
12
  import * as state from './state.js';
13
13
  import { createLineSplitter, parseEvent, mapEventToEntries, } from './stream-json.js';
14
+ import { IngestQueue } from './ingest-queue.js';
15
+ import { DeltaAccumulator, DeltaBatcher } from './stream-delta.js';
16
+ import { randomUUID } from 'crypto';
14
17
  import { deviceFetch, bridgeAbort as bridgeAbortImpl } from './device-http.js';
15
- import { redactEntries, normalizeSecrets } from './redact.js';
18
+ import { redactEntries, redactString, normalizeSecrets } from './redact.js';
16
19
  import { getMachineId } from './machine-id.js';
17
20
  // Re-exported so the existing `relay-bridge-abort.test.ts` keeps working.
18
21
  // New callers should import from device-http.js directly.
@@ -32,6 +35,21 @@ const MAX_CONCURRENT_DISPATCHES = Math.max(1, parseInt(process.env.GIPITY_RELAY_
32
35
  // Cap how long the pre-Claude project sync (and the post-dispatch push-back) may
33
36
  // run before we kill it - a stalled sync must never hang a dispatch forever.
34
37
  const PROJECT_SYNC_TIMEOUT_MS = parseInt(process.env.GIPITY_RELAY_SYNC_TIMEOUT_MS || '120000', 10);
38
+ /** System-prompt addendum enabling the interactive question card on the
39
+ * relay path (AskUserQuestion is unavailable in -p mode). The model emits
40
+ * a fenced `gipity-question` JSON block instead of asking in prose; the
41
+ * web CLI swaps it for a clickable card. Kept directive because a soft
42
+ * "you may" framing let weaker models fall back to prose (Phase 5 spike). */
43
+ const GIPITY_QUESTION_PROTOCOL = [
44
+ 'INTERACTIVE QUESTIONS: You are running without an interactive terminal, so the user answers you through a web UI.',
45
+ 'Whenever you would ask the user a clarifying question or offer them a choice, DO NOT write it as prose.',
46
+ 'Instead output ONLY a fenced code block tagged `gipity-question` containing JSON of exactly this shape, then end your turn:',
47
+ '```gipity-question',
48
+ '{"questions":[{"header":"Short label","question":"The question?","options":[{"label":"Choice A","description":"what it means"},{"label":"Choice B","description":"what it means"}],"multiSelect":false}]}',
49
+ '```',
50
+ 'Rules: keep option labels short; add a one-line description each; set "multiSelect":true only when several choices can apply; you may include multiple questions in the array; the user can always type a custom answer, so you need not add an "Other" option.',
51
+ 'This block is the only way the user can answer, so never ask in plain text when a decision is needed to proceed.',
52
+ ].join('\n');
35
53
  // ─── HTTP helpers ──────────────────────────────────────────────────────
36
54
  // Device-auth fetch lives in ./device-http.ts - shared with the capture
37
55
  // hook runner so both POST to /remote-sessions/:convGuid/ingest with the
@@ -380,21 +398,31 @@ async function dispatchLoop(ctx, opts) {
380
398
  /** Collect the secret strings the daemon must scrub from every captured
381
399
  * entry before it reaches the web CLI: the shared Claude credential
382
400
  * (whichever of the two env vars Claude Code is using) and this host's own
383
- * Gipity + device tokens. Recomputed per batch - cheap, and picks up a
384
- * token refresh without a daemon restart. */
401
+ * Gipity + device tokens.
402
+ *
403
+ * Cached with a 1s TTL: the stream-delta path calls this for EVERY emitted
404
+ * span (tens/sec during fast generation), and each call does two sync
405
+ * file reads (auth.json + device state). A 1s staleness window is well
406
+ * within the tokens' ~15min lifetime, and the JWT/sk-ant pattern passes in
407
+ * redactString are the backstop if a refresh lands mid-window. */
408
+ let relaySecretsCache = null;
409
+ const RELAY_SECRETS_TTL_MS = 1000;
385
410
  function getRelaySecrets() {
386
- // Read auth.json fresh - a child process may have refreshed the tokens
387
- // since the daemon's cached getAuth(). (The JWT-pattern pass in
388
- // redactEntries is the backstop if this still races a refresh.)
411
+ const now = Date.now();
412
+ if (relaySecretsCache && now - relaySecretsCache.at < RELAY_SECRETS_TTL_MS) {
413
+ return relaySecretsCache.secrets;
414
+ }
389
415
  const auth = readAuthFresh();
390
416
  const device = state.getDevice();
391
- return normalizeSecrets([
417
+ const secrets = normalizeSecrets([
392
418
  process.env.CLAUDE_CODE_OAUTH_TOKEN,
393
419
  process.env.ANTHROPIC_API_KEY,
394
420
  auth?.accessToken,
395
421
  auth?.refreshToken,
396
422
  device?.token,
397
423
  ]);
424
+ relaySecretsCache = { secrets, at: now };
425
+ return secrets;
398
426
  }
399
427
  /** Post a batch of ingest entries with the daemon's device bearer. Returns
400
428
  * whether the server accepted them (2xx). Non-2xx and network errors are
@@ -441,13 +469,16 @@ async function postIngest(convGuid, entries) {
441
469
  if (!res.ok) {
442
470
  const body = await res.text().catch(() => '');
443
471
  log('warn', 'ingest post non-2xx', { convGuid, httpStatus: res.status, body: body.slice(0, 200) });
444
- return { ok: false };
472
+ // 5xx/429 are transient - worth the queue retrying. A definitive 4xx
473
+ // (schema rejection, missing conv) can never succeed on replay;
474
+ // retrying it would just stall the queue behind a poisoned batch.
475
+ return { ok: false, retryable: res.status >= 500 || res.status === 429 };
445
476
  }
446
477
  return { ok: true };
447
478
  }
448
479
  catch (err) {
449
480
  log('warn', 'ingest post network error', { convGuid, err: err?.message });
450
- return { ok: false };
481
+ return { ok: false, retryable: true };
451
482
  }
452
483
  }
453
484
  /** Fire-and-forget dispatch progress heartbeat. Broadcast-only on the
@@ -462,6 +493,105 @@ async function postProgress(convGuid, payload) {
462
493
  /* best-effort */
463
494
  }
464
495
  }
496
+ /** Fire-and-forget token-delta flush. Ephemeral by design (no DB write
497
+ * server-side, no retry here): a lost flush shows as a small `…` gap and
498
+ * the next refresh replaces the streamed view with the stored message. */
499
+ async function postStreamDelta(convGuid, dispatchGuid, flush) {
500
+ try {
501
+ await deviceFetch('POST', `/remote-sessions/${encodeURIComponent(convGuid)}/stream-delta`, {
502
+ dispatch_guid: dispatchGuid,
503
+ seq: flush.seq,
504
+ events: flush.events,
505
+ }, 5_000);
506
+ }
507
+ catch {
508
+ /* best-effort */
509
+ }
510
+ }
511
+ /** Short human hint for a tool call shown on the progress line. */
512
+ export function toolHint(name, input) {
513
+ if (!input || typeof input !== 'object')
514
+ return undefined;
515
+ if (name === 'Bash' && typeof input.command === 'string')
516
+ return input.command;
517
+ const path = input.file_path ?? input.path ?? input.pattern ?? input.query ?? input.url ?? input.description;
518
+ return typeof path === 'string' ? path : undefined;
519
+ }
520
+ export class PhaseTracker {
521
+ phase = 'starting';
522
+ lastEventAt = Date.now();
523
+ retry = null;
524
+ /** Insertion-ordered open tool calls (tool_use seen, no tool_result yet). */
525
+ openTools = new Map();
526
+ note(evt) {
527
+ this.lastEventAt = Date.now();
528
+ // Token deltas refine the phase in real time: thinking fragments mean
529
+ // thinking, text fragments mean responding. Subagent streams
530
+ // (parent_tool_use_id) don't perturb the main phase - the open Task
531
+ // tool_use already puts us in the 'tool' phase.
532
+ if (evt.type === 'stream_event') {
533
+ if (evt.parent_tool_use_id)
534
+ return;
535
+ const d = evt.event?.delta;
536
+ if (this.openTools.size === 0 && d) {
537
+ if (typeof d.thinking === 'string')
538
+ this.phase = 'thinking';
539
+ else if (typeof d.text === 'string')
540
+ this.phase = 'responding';
541
+ }
542
+ return;
543
+ }
544
+ if (evt.type === 'system') {
545
+ // thinking_tokens fires per thinking chunk - the only signal we get
546
+ // during long extended thinking without partial messages.
547
+ if (evt.subtype === 'thinking_tokens' && this.openTools.size === 0)
548
+ this.phase = 'thinking';
549
+ else if (evt.subtype === 'api_retry') {
550
+ this.retry = {
551
+ attempt: typeof evt.attempt === 'number' ? evt.attempt : 1,
552
+ max: typeof evt.max_retries === 'number' ? evt.max_retries : undefined,
553
+ };
554
+ this.phase = 'retry';
555
+ }
556
+ return;
557
+ }
558
+ if (evt.type === 'assistant') {
559
+ this.retry = null;
560
+ const content = Array.isArray(evt.message?.content) ? evt.message.content : [];
561
+ for (const b of content) {
562
+ if (b?.type === 'tool_use' && typeof b.id === 'string') {
563
+ this.openTools.set(b.id, {
564
+ name: typeof b.name === 'string' ? b.name : 'tool',
565
+ hint: toolHint(b.name, b.input),
566
+ startedAt: Date.now(),
567
+ });
568
+ }
569
+ }
570
+ this.phase = this.openTools.size > 0 ? 'tool' : 'responding';
571
+ return;
572
+ }
573
+ if (evt.type === 'user') {
574
+ const content = Array.isArray(evt.message?.content) ? evt.message.content : [];
575
+ for (const b of content) {
576
+ if (b?.type === 'tool_result' && typeof b.tool_use_id === 'string')
577
+ this.openTools.delete(b.tool_use_id);
578
+ }
579
+ // Tool results feed the next model turn - thinking until proven otherwise.
580
+ if (this.openTools.size === 0 && this.phase === 'tool')
581
+ this.phase = 'thinking';
582
+ return;
583
+ }
584
+ if (evt.type === 'result')
585
+ this.phase = 'finishing';
586
+ }
587
+ /** The most recently started still-open tool, if any. */
588
+ currentTool() {
589
+ let last = null;
590
+ for (const t of this.openTools.values())
591
+ last = t;
592
+ return last;
593
+ }
594
+ }
465
595
  /** 123 B / 4.2 KB / 1.3 MB - short + readable for the "Invoking…" badge. */
466
596
  function formatBytes(n) {
467
597
  if (n < 1024)
@@ -557,11 +687,19 @@ function formatDuration(ms) {
557
687
  // broadcast, a permanent queue-cap slot). Some error strings we build embed an
558
688
  // arbitrary OS/spawn message, so clamp here to stay under the cap.
559
689
  const MAX_ACK_ERROR_CHARS = 2000;
560
- async function ack(shortGuid, status, error) {
561
- const safeError = error != null ? error.slice(0, MAX_ACK_ERROR_CHARS) : null;
690
+ async function ack(shortGuid, status, error, metrics) {
691
+ // Redact BEFORE clamping (truncation must not split a secret past the
692
+ // literal-match scrubber). The ack `error` is broadcast to the web CLI
693
+ // and rendered - it can embed a spawn message or the child's stderr
694
+ // tail, which on a hosted relay could echo a host credential. The twin
695
+ // ingest system-marker is already redacted via redactEntries; this
696
+ // path was the redaction hole (findings: security review 2026-07-03).
697
+ const safeError = error != null
698
+ ? redactString(error, getRelaySecrets()).slice(0, MAX_ACK_ERROR_CHARS)
699
+ : null;
562
700
  try {
563
701
  const res = await deviceFetch('POST', `/remote-devices/dispatches/${encodeURIComponent(shortGuid)}/ack`, {
564
- status, error: safeError,
702
+ status, error: safeError, ...(metrics ? { metrics } : {}),
565
703
  }, 10_000);
566
704
  if (!res.ok) {
567
705
  // fetch() doesn't throw on 4xx/5xx - surface it ourselves so a
@@ -603,6 +741,18 @@ async function handleDispatch(d) {
603
741
  // Two children on one session would corrupt the .jsonl - this is the
604
742
  // serialization point that prevents that.
605
743
  await killRunningForConv(d.conversation_guid);
744
+ // One ordered ingest queue per dispatch: markers, prompt echo, stream
745
+ // entries, and the tail all flow through it in order, with backoff
746
+ // retry instead of the old fire-and-forget loss on network errors.
747
+ // Daemon-authored entries get a random source_uuid so retried batches
748
+ // dedup server-side.
749
+ const queue = new IngestQueue((entries) => postIngest(d.conversation_guid, entries), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
750
+ const pushSystem = (content) => {
751
+ queue.push({ kind: 'system', content, ts: new Date().toISOString(), source_uuid: randomUUID() });
752
+ };
753
+ /** Drain the queue (bounded) so content lands before the ack that
754
+ * closes the web CLI's live view. */
755
+ const flushQueue = async () => { await queue.close(30_000); };
606
756
  let cwd;
607
757
  let bootstrapped;
608
758
  try {
@@ -620,7 +770,7 @@ async function handleDispatch(d) {
620
770
  // is killed at PROJECT_SYNC_TIMEOUT_MS and reported instead of silently stalling
621
771
  // the dispatch (the old in-process bootstrap sync could hang forever).
622
772
  if (bootstrapped) {
623
- await postIngest(d.conversation_guid, [{ kind: 'system', content: 'Syncing project files…' }]);
773
+ pushSystem('Syncing project files…');
624
774
  let syncKilled = false;
625
775
  try {
626
776
  syncKilled = (await runDispatchSync(d, cwd)).killed;
@@ -628,7 +778,8 @@ async function handleDispatch(d) {
628
778
  catch (err) {
629
779
  const msg = `project sync ${err?.message || 'failed'}`;
630
780
  log('error', 'project sync failed - aborting dispatch', { id: d.short_guid, err: err?.message });
631
- await postIngest(d.conversation_guid, [{ kind: 'system', content: `Claude Code not started - ${msg}` }]);
781
+ pushSystem(`Claude Code not started - ${msg}`);
782
+ await flushQueue();
632
783
  await ack(d.short_guid, 'error', msg);
633
784
  return;
634
785
  }
@@ -637,11 +788,12 @@ async function handleDispatch(d) {
637
788
  // WHILE the pre-Claude sync was running. Before this was registered in
638
789
  // `running`, a cancel was silently ignored until the 120s sync timeout.
639
790
  log('info', 'dispatch cancelled during project sync', { id: d.short_guid });
640
- await postIngest(d.conversation_guid, [{ kind: 'system', content: 'Claude Code cancelled (during project sync)' }]);
791
+ pushSystem('Claude Code cancelled (during project sync)');
792
+ await flushQueue();
641
793
  await ack(d.short_guid, 'cancelled');
642
794
  return;
643
795
  }
644
- await postIngest(d.conversation_guid, [{ kind: 'system', content: 'Project files synced.' }]);
796
+ pushSystem('Project files synced.');
645
797
  }
646
798
  // Build argv for `gipity claude -p …` (or with --resume). No shell - argv
647
799
  // as array so the message string can't be interpreted as shell syntax.
@@ -653,6 +805,13 @@ async function handleDispatch(d) {
653
805
  // prompt is correct (same authority as running `claude -p` in a local
654
806
  // terminal yourself).
655
807
  const args = ['claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
808
+ // Relay sessions run in -p mode, where Claude Code's AskUserQuestion tool
809
+ // is unavailable - so without help the model would ask clarifying
810
+ // questions as prose the user just types back. This system-prompt
811
+ // addendum gives it a structured channel: emit a fenced gipity-question
812
+ // block, which the web CLI renders as an interactive card (clickable
813
+ // options + free-text). Verified to work down to Haiku (Phase 5 spike).
814
+ args.push('--append-system-prompt', GIPITY_QUESTION_PROTOCOL);
656
815
  // Per-chat model: the user picked it with `/model` in the web CLI. `gipity
657
816
  // claude` forwards --model straight through to the `claude` binary, which
658
817
  // honors it on both a fresh session and a --resume. null => agent default.
@@ -718,18 +877,22 @@ async function handleDispatch(d) {
718
877
  }
719
878
  }
720
879
  const header = `Running Claude Code - ${counts.join(' + ')} words${resumeNote}`;
721
- await postIngest(d.conversation_guid, [
722
- { kind: 'prompt', prompt: d.message },
723
- { kind: 'system', content: header },
724
- ]);
880
+ const ts = new Date().toISOString();
881
+ queue.push({ kind: 'prompt', prompt: d.message, ts, source_uuid: randomUUID() }, { kind: 'system', content: header, ts, source_uuid: randomUUID() });
725
882
  const t0 = Date.now();
726
883
  let exitCode = 1;
727
884
  let spawnErr = null;
728
885
  let killed = false;
886
+ let runtimeLimit = false;
887
+ let stderrTail = '';
888
+ let startupMs;
729
889
  try {
730
- const result = await spawnGipityClaude(args, cwd, d);
890
+ const result = await spawnGipityClaude(args, cwd, d, queue, { resumeWords: transcript?.words });
731
891
  exitCode = result.exitCode;
732
892
  killed = result.killed;
893
+ runtimeLimit = result.runtimeLimit ?? false;
894
+ stderrTail = result.stderrTail ?? '';
895
+ startupMs = result.startupMs;
733
896
  }
734
897
  catch (err) {
735
898
  spawnErr = err?.message || String(err);
@@ -760,15 +923,31 @@ async function handleDispatch(d) {
760
923
  log('warn', 'sync after dispatch failed', { id: d.short_guid, err: err?.message });
761
924
  }
762
925
  }
763
- const tail = killed
764
- ? `cancelled (${dur})`
765
- : spawnErr
766
- ? `failed (${dur}: ${spawnErr})`
767
- : exitCode === 0
768
- ? `finished (${dur})`
769
- : `failed (${dur}, exit ${exitCode})`;
770
- await postIngest(d.conversation_guid, [{ kind: 'system', content: `Claude Code ${tail}` }]);
771
- if (killed) {
926
+ // Nonzero exit with no useful message: include the child's last few
927
+ // stderr lines so the visible marker carries the real error instead of
928
+ // just an exit code (previously stderr only reached the daemon's log).
929
+ const stderrNote = stderrTail ? `: ${stderrTail.slice(0, 300)}` : '';
930
+ const tail = runtimeLimit
931
+ ? `stopped after ${dur} (runtime limit)`
932
+ : killed
933
+ ? `cancelled (${dur})`
934
+ : spawnErr
935
+ ? `failed (${dur}: ${spawnErr})`
936
+ : exitCode === 0
937
+ ? `finished (${dur})`
938
+ : `failed (${dur}, exit ${exitCode}${stderrNote})`;
939
+ pushSystem(`Claude Code ${tail}`);
940
+ await flushQueue();
941
+ // Observability: how long the relay took before the agent produced its
942
+ // first output (project sync + Claude Code cold start + MCP). Recorded on
943
+ // the dispatch row; tracked, not enforced. Undefined if the child never
944
+ // emitted an event (spawn failure).
945
+ const metrics = startupMs !== undefined ? { startup_ms: startupMs } : undefined;
946
+ if (runtimeLimit) {
947
+ log('warn', 'dispatch hit runtime limit', { id: d.short_guid, ms });
948
+ await ack(d.short_guid, 'error', `Claude Code stopped after ${dur} (runtime limit)`, metrics);
949
+ }
950
+ else if (killed) {
772
951
  log('info', 'dispatch cancelled by user', { id: d.short_guid, ms });
773
952
  await ack(d.short_guid, 'cancelled');
774
953
  }
@@ -776,12 +955,12 @@ async function handleDispatch(d) {
776
955
  await ack(d.short_guid, 'error', spawnErr);
777
956
  }
778
957
  else if (exitCode === 0) {
779
- log('info', 'dispatch done', { id: d.short_guid, ms });
780
- await ack(d.short_guid, 'done');
958
+ log('info', 'dispatch done', { id: d.short_guid, ms, startupMs });
959
+ await ack(d.short_guid, 'done', undefined, metrics);
781
960
  }
782
961
  else {
783
962
  log('warn', 'dispatch child exited nonzero', { id: d.short_guid, exitCode, ms });
784
- await ack(d.short_guid, 'error', `gipity claude exited with code ${exitCode}`);
963
+ await ack(d.short_guid, 'error', `gipity claude exited with code ${exitCode}${stderrNote}`);
785
964
  }
786
965
  }
787
966
  /**
@@ -961,7 +1140,7 @@ async function spawnSync(cwd, timeoutMs, onSpawn) {
961
1140
  }));
962
1141
  });
963
1142
  }
964
- export async function spawnGipityClaude(args, cwd, d) {
1143
+ export async function spawnGipityClaude(args, cwd, d, queue, meta) {
965
1144
  // resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
966
1145
  // can't launch without an explicit path. An explicit env override is used
967
1146
  // verbatim (it may be a full path); only the default name is resolved.
@@ -969,7 +1148,10 @@ export async function spawnGipityClaude(args, cwd, d) {
969
1148
  // Inject stream-json flags here rather than at the call site so every
970
1149
  // relay spawn path gets the same protocol. `--verbose` is required by
971
1150
  // Claude Code when combining `-p` with `--output-format stream-json`.
972
- const fullArgs = [...args, '--output-format', 'stream-json', '--verbose'];
1151
+ // `--include-partial-messages` adds stream_event token deltas, which
1152
+ // feed the ephemeral live-typing channel (see stream-delta.ts); whole
1153
+ // assistant/tool events still arrive unchanged for the persistent path.
1154
+ const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
973
1155
  const env = { ...process.env, GIPITY_CONVERSATION_GUID: d.conversation_guid };
974
1156
  return new Promise((resolve, reject) => {
975
1157
  const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -980,10 +1162,13 @@ export async function spawnGipityClaude(args, cwd, d) {
980
1162
  let resolveExited = () => { };
981
1163
  const exited = new Promise(r => { resolveExited = r; });
982
1164
  running.set(d.short_guid, { child, convGuid: d.conversation_guid, exited });
983
- // Track in-flight ingest POSTs for this spawn. On exit we await them
984
- // before resolving the outer promise so `handleDispatch` doesn't
985
- // move on to its tail marker while the last batch is still in flight.
986
- const pendingPosts = new Set();
1165
+ // Ordered per-dispatch ingest queue. Entries flow through it in
1166
+ // arrival order with backoff retry - a transient network/server error
1167
+ // no longer drops stream content permanently (source_uuid dedup makes
1168
+ // the retries safe). Falls back to a local queue when the caller
1169
+ // didn't pass one (tests, direct invocation).
1170
+ const q = queue ?? new IngestQueue((entries) => postIngest(d.conversation_guid, entries), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
1171
+ const ownQueue = !queue;
987
1172
  // Progress heartbeat state. Measured at the daemon boundary - this is
988
1173
  // what we actually observe, not anything the child self-reports. These
989
1174
  // fields are fully generic (no Claude Code-specific shape) so the same
@@ -992,33 +1177,146 @@ export async function spawnGipityClaude(args, cwd, d) {
992
1177
  let stdoutBytesTotal = 0;
993
1178
  let lastStdoutByteAt = dispatchStartedAt;
994
1179
  let stdoutBytesAtLastTick = 0;
995
- const progressTimer = setInterval(() => {
1180
+ const phases = new PhaseTracker();
1181
+ // Rich-heartbeat state, updated from the stream: the agent's current
1182
+ // context size (latest input_tokens off a usage-bearing event) so the
1183
+ // client can show context-window fill. Machine stats are read fresh per
1184
+ // tick (os.* are in-memory kernel values - instant, no I/O).
1185
+ let contextTokens;
1186
+ let firstEventAt; // for the startup-latency metric
1187
+ const CPU_COUNT = cpus().length;
1188
+ const TOTAL_MEM = totalmem();
1189
+ const buildProgressPayload = (procAlive) => {
996
1190
  const now = Date.now();
997
1191
  const delta = stdoutBytesTotal - stdoutBytesAtLastTick;
998
1192
  stdoutBytesAtLastTick = stdoutBytesTotal;
999
- void postProgress(d.conversation_guid, {
1193
+ const tool = phases.currentTool();
1194
+ // The hint (Bash command / file path / URL) comes straight from
1195
+ // tool input, so it can contain a secret the agent echoed - this
1196
+ // liveness channel must scrub it, exactly like the ingest and delta
1197
+ // channels do (it's the third path the same tool_use fans out to).
1198
+ // `current_tool` is clamped to the server's max(100) so a long MCP
1199
+ // tool name (`mcp__server__tool`) can't 400 every tick and starve
1200
+ // the client's liveness stream.
1201
+ const hint = tool?.hint ? redactString(tool.hint, getRelaySecrets()).slice(0, 200) : undefined;
1202
+ return {
1000
1203
  dispatch_guid: d.short_guid,
1001
- proc_alive: child.exitCode === null,
1204
+ proc_alive: procAlive,
1002
1205
  stdout_bytes_total: stdoutBytesTotal,
1003
1206
  stdout_bytes_delta: delta,
1004
1207
  stdout_idle_ms: Math.max(0, now - lastStdoutByteAt),
1005
1208
  uptime_ms: Math.max(0, now - dispatchStartedAt),
1006
- });
1209
+ phase: phases.phase,
1210
+ current_tool: tool?.name?.slice(0, 100),
1211
+ current_tool_hint: hint,
1212
+ tool_elapsed_ms: tool ? Math.max(0, now - tool.startedAt) : undefined,
1213
+ last_event_ms: Math.max(0, now - phases.lastEventAt),
1214
+ retry: phases.retry ?? undefined,
1215
+ // Rich heartbeat: real host + session telemetry.
1216
+ machine_load1: Math.round(loadavg()[0] * 100) / 100,
1217
+ machine_free_mem: freemem(),
1218
+ machine_total_mem: TOTAL_MEM,
1219
+ machine_cpus: CPU_COUNT,
1220
+ context_tokens: contextTokens,
1221
+ resume_words: meta?.resumeWords,
1222
+ };
1223
+ };
1224
+ // Immediate first tick: don't make the user stare at a static "Running
1225
+ // Claude Code" line for the first ~2s. Fire one heartbeat right away so
1226
+ // the spinner + machine/id readout appears the moment we spawn.
1227
+ void postProgress(d.conversation_guid, buildProgressPayload(true));
1228
+ const progressTimer = setInterval(() => {
1229
+ void postProgress(d.conversation_guid, buildProgressPayload(child.exitCode === null));
1007
1230
  }, 2000);
1008
- // Stdout: NDJSON stream parse POST each event's ingest entries
1231
+ // Max-runtime guard: a wedged child (hung tool, upstream stall) must
1232
+ // not tick `proc_alive:true` forever - after the limit, terminate it
1233
+ // with the same SIGTERM→SIGKILL escalation the cancel path uses and
1234
+ // surface a visible marker + error ack (via `runtimeLimit`).
1235
+ let runtimeLimit = false;
1236
+ const maxRuntimeMs = parseInt(process.env.GIPITY_RELAY_MAX_RUNTIME_MS || String(45 * 60_000), 10);
1237
+ const maxRuntimeTimer = maxRuntimeMs > 0 ? setTimeout(() => {
1238
+ runtimeLimit = true;
1239
+ log('warn', 'dispatch hit max runtime - terminating child', { id: d.short_guid, maxRuntimeMs });
1240
+ try {
1241
+ child.kill('SIGTERM');
1242
+ }
1243
+ catch { /* already gone */ }
1244
+ setTimeout(() => {
1245
+ if (child.exitCode === null) {
1246
+ try {
1247
+ child.kill('SIGKILL');
1248
+ }
1249
+ catch { /* already gone */ }
1250
+ }
1251
+ }, KILL_GRACE_MS).unref();
1252
+ }, maxRuntimeMs) : null;
1253
+ maxRuntimeTimer?.unref();
1254
+ // Stdout: NDJSON stream → parse → enqueue each event's ingest entries
1009
1255
  // as they arrive. That's the live-streaming path - every assistant
1010
1256
  // message and tool call appears in the web CLI within a second of
1011
1257
  // Claude emitting it.
1012
1258
  // Per-dispatch tool_use_id → tool_name map so a `tool_result` event can
1013
- // be denormalized with its tool's name (the result block omits it).
1259
+ // be denormalized with its tool's name (the result block omits it);
1260
+ // msgSeq disambiguates per-block assistant events sharing a message id
1261
+ // (their source_uuid becomes `msg_x#0`, `msg_x#1`, …).
1014
1262
  const toolNames = new Map();
1263
+ const msgSeq = new Map();
1264
+ const unmapped = new Map();
1265
+ // Token-delta pipeline: accumulate stream_event fragments, emit
1266
+ // redaction-safe spans, batch every 150ms/4KB to /stream-delta.
1267
+ const deltaAcc = new DeltaAccumulator(getRelaySecrets);
1268
+ const deltaBatcher = new DeltaBatcher((flush) => {
1269
+ void postStreamDelta(d.conversation_guid, d.short_guid, flush);
1270
+ });
1271
+ // A spawn with background subagents can emit several init and result
1272
+ // events (the loop re-invokes when a task completes). Attach once;
1273
+ // buffer results and post only the last (its cost is cumulative).
1274
+ let attached = false;
1275
+ let finalResult = null;
1015
1276
  const splitter = createLineSplitter((line) => {
1016
1277
  const evt = parseEvent(line, (reason, snippet) => {
1017
1278
  log('warn', 'stream-json parse skipped line', { id: d.short_guid, reason, snippet });
1018
1279
  });
1019
1280
  if (!evt)
1020
1281
  return;
1021
- const entries = mapEventToEntries(evt, toolNames);
1282
+ phases.note(evt);
1283
+ // Startup-latency metric: the FIRST stream event means Claude Code has
1284
+ // finished the pre-spawn work (project sync + cold start + MCP connect)
1285
+ // and is now producing output. Time from dispatch start to here is what
1286
+ // the user waits through before anything happens - the thing we want to
1287
+ // track per-dispatch (see remote_dispatches.metrics).
1288
+ if (firstEventAt === undefined)
1289
+ firstEventAt = Date.now();
1290
+ // Track the agent's live context size for the heartbeat: an assistant
1291
+ // (or result) event's usage.input_tokens IS the context loaded for that
1292
+ // turn. Take the max seen so the readout doesn't jump around between
1293
+ // a small cache-read turn and the full-context turn.
1294
+ const usageIn = evt?.message?.usage?.input_tokens;
1295
+ if (typeof usageIn === 'number' && usageIn > (contextTokens ?? 0))
1296
+ contextTokens = usageIn;
1297
+ deltaBatcher.push(deltaAcc.note(evt));
1298
+ let entries = mapEventToEntries(evt, {
1299
+ toolNames, msgSeq,
1300
+ onUnmapped: (type, subtype) => {
1301
+ const key = subtype ? `${type}/${subtype}` : type;
1302
+ unmapped.set(key, (unmapped.get(key) ?? 0) + 1);
1303
+ },
1304
+ });
1305
+ if (entries.length === 0)
1306
+ return;
1307
+ entries = entries.filter(e => {
1308
+ if (e.kind === 'attach') {
1309
+ if (attached)
1310
+ return false;
1311
+ attached = true;
1312
+ return true;
1313
+ }
1314
+ if (e.kind === 'result') {
1315
+ finalResult = e;
1316
+ return false;
1317
+ }
1318
+ return true;
1319
+ });
1022
1320
  if (entries.length === 0)
1023
1321
  return;
1024
1322
  // Stamp the read time as the event-time hint (event_at). Stream-json
@@ -1026,15 +1324,19 @@ export async function spawnGipityClaude(args, cwd, d) {
1026
1324
  // emits them, so receipt time is a close, per-event proxy - far
1027
1325
  // better than the single flush-time created_at on the whole batch.
1028
1326
  const ts = new Date().toISOString();
1029
- for (const e of entries)
1327
+ for (const e of entries) {
1030
1328
  e.ts = ts;
1031
- // Fire-and-forget POST but tracked so the drain on exit can
1032
- // `allSettled` the set before we claim the spawn is done.
1033
- const p = postIngest(d.conversation_guid, entries)
1034
- .then(() => { })
1035
- .catch(() => { })
1036
- .finally(() => { pendingPosts.delete(p); });
1037
- pendingPosts.add(p);
1329
+ // Every entry needs a dedup key so a queue re-POST after a partial
1330
+ // server success can't double-insert. assistant/tool_use already
1331
+ // carry one (msg_id#n / tool_use_id); compact and any future
1332
+ // keyless kind get a stable UUID here - stable because the daemon
1333
+ // maps each stream line exactly once and the queue retries the
1334
+ // same object (the stream path never re-maps, unlike a transcript
1335
+ // replay), so the same uuid reaches the server on every retry.
1336
+ if (!e.source_uuid)
1337
+ e.source_uuid = randomUUID();
1338
+ }
1339
+ q.push(...entries);
1038
1340
  });
1039
1341
  child.stdout?.on('data', (chunk) => {
1040
1342
  stdoutBytesTotal += chunk.length;
@@ -1043,12 +1345,22 @@ export async function spawnGipityClaude(args, cwd, d) {
1043
1345
  });
1044
1346
  child.stdout?.on('end', () => splitter.flush());
1045
1347
  // Stderr: human-readable only (Claude's progress bars, errors).
1046
- // Kept on the daemon's own stderr for `gipity relay log`. The
1047
- // readline interface is closed in the error/exit handler so the
1048
- // listener doesn't outlive the child.
1348
+ // Kept on the daemon's own stderr for `gipity relay log`, plus a
1349
+ // small tail ring so a crash with no stream output can include the
1350
+ // real error in the visible failure marker instead of just an exit
1351
+ // code. The readline interface is closed in the error/exit handler
1352
+ // so the listener doesn't outlive the child.
1049
1353
  const errPrefix = C.dim('│ ');
1354
+ const stderrTail = [];
1050
1355
  const errRl = child.stderr ? createInterface({ input: child.stderr }) : null;
1051
- errRl?.on('line', (line) => process.stderr.write(errPrefix + line + '\n'));
1356
+ errRl?.on('line', (line) => {
1357
+ process.stderr.write(errPrefix + line + '\n');
1358
+ if (line.trim()) {
1359
+ stderrTail.push(line.trim());
1360
+ if (stderrTail.length > 3)
1361
+ stderrTail.shift();
1362
+ }
1363
+ });
1052
1364
  let killed = false;
1053
1365
  const cleanup = () => {
1054
1366
  clearInterval(progressTimer);
@@ -1060,17 +1372,48 @@ export async function spawnGipityClaude(args, cwd, d) {
1060
1372
  cleanup();
1061
1373
  reject(err);
1062
1374
  });
1063
- child.on('exit', async (code, signal) => {
1375
+ // Finalize on 'close', NOT 'exit': 'exit' fires when the process dies,
1376
+ // which can be BEFORE the last stdout chunks drain. Claude emits the
1377
+ // `result` footer (with cumulative cost) immediately before exiting,
1378
+ // so finalizing on 'exit' races that line - `finalResult` would be
1379
+ // parsed by the splitter's `'end'` flush AFTER we'd already resolved,
1380
+ // silently losing the session footer + cost. 'close' fires only once
1381
+ // all stdio is closed (after `'end'` → `splitter.flush()`), so every
1382
+ // event is mapped by the time we get here. Carries the same (code,
1383
+ // signal) as 'exit'.
1384
+ child.on('close', async (code, signal) => {
1064
1385
  if (signal === 'SIGTERM' || signal === 'SIGKILL')
1065
1386
  killed = true;
1066
- // Wait for the last in-flight POSTs so the tail marker lands
1067
- // after all content from this spawn. Safe: pendingPosts always
1068
- // settle (catch + finally), so allSettled never hangs.
1069
- if (pendingPosts.size > 0) {
1070
- await Promise.allSettled([...pendingPosts]);
1387
+ // Post the buffered session footer now that we know it's final (a
1388
+ // spawn with background subagents emits several result events; the
1389
+ // last carries the cumulative cost).
1390
+ if (finalResult) {
1391
+ finalResult.ts = new Date().toISOString();
1392
+ finalResult.source_uuid = randomUUID();
1393
+ q.push(finalResult);
1394
+ }
1395
+ if (unmapped.size > 0) {
1396
+ log('info', 'unmapped stream events this dispatch', {
1397
+ id: d.short_guid,
1398
+ counts: Object.fromEntries(unmapped),
1399
+ });
1071
1400
  }
1401
+ if (maxRuntimeTimer)
1402
+ clearTimeout(maxRuntimeTimer);
1403
+ deltaBatcher.close();
1404
+ // Final heartbeat with proc_alive:false so the web CLI's progress
1405
+ // line flips to Exited even if the 2s interval just missed the
1406
+ // exit (the interval is cleared in cleanup()).
1407
+ void postProgress(d.conversation_guid, buildProgressPayload(false));
1408
+ // Only drain here when we own the queue; otherwise handleDispatch
1409
+ // closes it after pushing its tail marker (order preserved).
1410
+ if (ownQueue)
1411
+ await q.close();
1072
1412
  cleanup();
1073
- resolve({ exitCode: code ?? 1, killed });
1413
+ resolve({
1414
+ exitCode: code ?? 1, killed, runtimeLimit, stderrTail: stderrTail.join(' | '),
1415
+ startupMs: firstEventAt !== undefined ? Math.max(0, firstEventAt - dispatchStartedAt) : undefined,
1416
+ });
1074
1417
  });
1075
1418
  });
1076
1419
  }