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.
- package/dist/commands/claude.js +36 -43
- package/dist/commands/credits.js +14 -9
- package/dist/commands/setup.js +59 -0
- package/dist/hooks/capture-runner.js +15 -2
- package/dist/index.js +3 -1
- package/dist/login-flow.js +42 -0
- package/dist/relay/daemon.js +445 -67
- package/dist/relay/ingest-queue.js +148 -0
- package/dist/relay/onboarding.js +51 -23
- package/dist/relay/redact.js +1 -1
- package/dist/relay/stream-delta.js +235 -0
- package/dist/relay/stream-json.js +61 -15
- package/dist/setup.js +14 -4
- package/package.json +2 -2
package/dist/relay/daemon.js
CHANGED
|
@@ -2,17 +2,20 @@ 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';
|
|
9
9
|
import { setupClaudeHooks, setupClaudeMd, setupAgentsMd, setupGitignore, DEFAULT_SYNC_IGNORE } from '../setup.js';
|
|
10
|
-
import { getAuth, readAuthFresh } from '../auth.js';
|
|
10
|
+
import { getAuth, readAuthFresh, refreshTokenIfNeeded, accessTokenExpired } 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
|
|
@@ -237,6 +255,9 @@ export async function run(opts = {}) {
|
|
|
237
255
|
// ─── Heartbeat loop ────────────────────────────────────────────────────
|
|
238
256
|
async function heartbeatLoop(ctx) {
|
|
239
257
|
let backoff = 0;
|
|
258
|
+
// Log the "session expired" warning only on the transition into that state,
|
|
259
|
+
// not every 60s, so a genuinely-lapsed session doesn't spam the relay log.
|
|
260
|
+
let sessionWarnLogged = false;
|
|
240
261
|
while (!ctx.abort.signal.aborted) {
|
|
241
262
|
try {
|
|
242
263
|
const r = await deviceFetch('POST', '/remote-devices/heartbeat', {}, 10_000, ctx.abort.signal);
|
|
@@ -248,6 +269,38 @@ async function heartbeatLoop(ctx) {
|
|
|
248
269
|
if (!r.ok)
|
|
249
270
|
throw new Error(`heartbeat ${r.status}`);
|
|
250
271
|
backoff = 0;
|
|
272
|
+
// Keep the USER session warm alongside the device heartbeat. The device
|
|
273
|
+
// token (used for this heartbeat and to claim dispatches) is a SEPARATE,
|
|
274
|
+
// long-lived credential from the user's OAuth session in auth.json — a
|
|
275
|
+
// healthy heartbeat says nothing about whether `gipity sync` / `gipity
|
|
276
|
+
// claude` can authenticate. The relay is long-lived but the access token
|
|
277
|
+
// lives only ~1h and the refresh token ~7d; left idle between dispatches
|
|
278
|
+
// the session lapses and the next dispatch's child scrambles to refresh
|
|
279
|
+
// (or finds it dead). Refreshing here — in the long-lived PARENT (never
|
|
280
|
+
// SIGKILL'd mid-rotate the way a per-dispatch child can be), under the
|
|
281
|
+
// shared cross-process auth lock — renews BOTH tokens well inside their
|
|
282
|
+
// windows (each refresh mints a fresh 7-day token), so a continuously
|
|
283
|
+
// running relay never gets bumped out from disuse, and dispatch children
|
|
284
|
+
// hit refreshTokenIfNeeded's fast path instead of racing to rotate the
|
|
285
|
+
// single-use token. Best-effort: it no-ops while the token is still fresh
|
|
286
|
+
// and never throws. If it can't renew, the session is genuinely dead —
|
|
287
|
+
// log it once so `gipity relay log` shows why dispatches will fail until
|
|
288
|
+
// the user re-logs in (nothing here can revive it without a TTY).
|
|
289
|
+
if (getAuth()) {
|
|
290
|
+
try {
|
|
291
|
+
await refreshTokenIfNeeded();
|
|
292
|
+
if (accessTokenExpired() && !sessionWarnLogged) {
|
|
293
|
+
log('warn', 'user session expired - dispatches will fail to sync until re-login (run: gipity login)');
|
|
294
|
+
sessionWarnLogged = true;
|
|
295
|
+
}
|
|
296
|
+
else if (!accessTokenExpired()) {
|
|
297
|
+
sessionWarnLogged = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
catch (err) {
|
|
301
|
+
log('debug', 'session warm failed', { err: err?.message });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
251
304
|
}
|
|
252
305
|
catch (err) {
|
|
253
306
|
if (ctx.abort.signal.aborted)
|
|
@@ -380,21 +433,31 @@ async function dispatchLoop(ctx, opts) {
|
|
|
380
433
|
/** Collect the secret strings the daemon must scrub from every captured
|
|
381
434
|
* entry before it reaches the web CLI: the shared Claude credential
|
|
382
435
|
* (whichever of the two env vars Claude Code is using) and this host's own
|
|
383
|
-
* Gipity + device tokens.
|
|
384
|
-
*
|
|
436
|
+
* Gipity + device tokens.
|
|
437
|
+
*
|
|
438
|
+
* Cached with a 1s TTL: the stream-delta path calls this for EVERY emitted
|
|
439
|
+
* span (tens/sec during fast generation), and each call does two sync
|
|
440
|
+
* file reads (auth.json + device state). A 1s staleness window is well
|
|
441
|
+
* within the tokens' ~15min lifetime, and the JWT/sk-ant pattern passes in
|
|
442
|
+
* redactString are the backstop if a refresh lands mid-window. */
|
|
443
|
+
let relaySecretsCache = null;
|
|
444
|
+
const RELAY_SECRETS_TTL_MS = 1000;
|
|
385
445
|
function getRelaySecrets() {
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
446
|
+
const now = Date.now();
|
|
447
|
+
if (relaySecretsCache && now - relaySecretsCache.at < RELAY_SECRETS_TTL_MS) {
|
|
448
|
+
return relaySecretsCache.secrets;
|
|
449
|
+
}
|
|
389
450
|
const auth = readAuthFresh();
|
|
390
451
|
const device = state.getDevice();
|
|
391
|
-
|
|
452
|
+
const secrets = normalizeSecrets([
|
|
392
453
|
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
393
454
|
process.env.ANTHROPIC_API_KEY,
|
|
394
455
|
auth?.accessToken,
|
|
395
456
|
auth?.refreshToken,
|
|
396
457
|
device?.token,
|
|
397
458
|
]);
|
|
459
|
+
relaySecretsCache = { secrets, at: now };
|
|
460
|
+
return secrets;
|
|
398
461
|
}
|
|
399
462
|
/** Post a batch of ingest entries with the daemon's device bearer. Returns
|
|
400
463
|
* whether the server accepted them (2xx). Non-2xx and network errors are
|
|
@@ -441,13 +504,16 @@ async function postIngest(convGuid, entries) {
|
|
|
441
504
|
if (!res.ok) {
|
|
442
505
|
const body = await res.text().catch(() => '');
|
|
443
506
|
log('warn', 'ingest post non-2xx', { convGuid, httpStatus: res.status, body: body.slice(0, 200) });
|
|
444
|
-
|
|
507
|
+
// 5xx/429 are transient - worth the queue retrying. A definitive 4xx
|
|
508
|
+
// (schema rejection, missing conv) can never succeed on replay;
|
|
509
|
+
// retrying it would just stall the queue behind a poisoned batch.
|
|
510
|
+
return { ok: false, retryable: res.status >= 500 || res.status === 429 };
|
|
445
511
|
}
|
|
446
512
|
return { ok: true };
|
|
447
513
|
}
|
|
448
514
|
catch (err) {
|
|
449
515
|
log('warn', 'ingest post network error', { convGuid, err: err?.message });
|
|
450
|
-
return { ok: false };
|
|
516
|
+
return { ok: false, retryable: true };
|
|
451
517
|
}
|
|
452
518
|
}
|
|
453
519
|
/** Fire-and-forget dispatch progress heartbeat. Broadcast-only on the
|
|
@@ -462,6 +528,105 @@ async function postProgress(convGuid, payload) {
|
|
|
462
528
|
/* best-effort */
|
|
463
529
|
}
|
|
464
530
|
}
|
|
531
|
+
/** Fire-and-forget token-delta flush. Ephemeral by design (no DB write
|
|
532
|
+
* server-side, no retry here): a lost flush shows as a small `…` gap and
|
|
533
|
+
* the next refresh replaces the streamed view with the stored message. */
|
|
534
|
+
async function postStreamDelta(convGuid, dispatchGuid, flush) {
|
|
535
|
+
try {
|
|
536
|
+
await deviceFetch('POST', `/remote-sessions/${encodeURIComponent(convGuid)}/stream-delta`, {
|
|
537
|
+
dispatch_guid: dispatchGuid,
|
|
538
|
+
seq: flush.seq,
|
|
539
|
+
events: flush.events,
|
|
540
|
+
}, 5_000);
|
|
541
|
+
}
|
|
542
|
+
catch {
|
|
543
|
+
/* best-effort */
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
/** Short human hint for a tool call shown on the progress line. */
|
|
547
|
+
export function toolHint(name, input) {
|
|
548
|
+
if (!input || typeof input !== 'object')
|
|
549
|
+
return undefined;
|
|
550
|
+
if (name === 'Bash' && typeof input.command === 'string')
|
|
551
|
+
return input.command;
|
|
552
|
+
const path = input.file_path ?? input.path ?? input.pattern ?? input.query ?? input.url ?? input.description;
|
|
553
|
+
return typeof path === 'string' ? path : undefined;
|
|
554
|
+
}
|
|
555
|
+
export class PhaseTracker {
|
|
556
|
+
phase = 'starting';
|
|
557
|
+
lastEventAt = Date.now();
|
|
558
|
+
retry = null;
|
|
559
|
+
/** Insertion-ordered open tool calls (tool_use seen, no tool_result yet). */
|
|
560
|
+
openTools = new Map();
|
|
561
|
+
note(evt) {
|
|
562
|
+
this.lastEventAt = Date.now();
|
|
563
|
+
// Token deltas refine the phase in real time: thinking fragments mean
|
|
564
|
+
// thinking, text fragments mean responding. Subagent streams
|
|
565
|
+
// (parent_tool_use_id) don't perturb the main phase - the open Task
|
|
566
|
+
// tool_use already puts us in the 'tool' phase.
|
|
567
|
+
if (evt.type === 'stream_event') {
|
|
568
|
+
if (evt.parent_tool_use_id)
|
|
569
|
+
return;
|
|
570
|
+
const d = evt.event?.delta;
|
|
571
|
+
if (this.openTools.size === 0 && d) {
|
|
572
|
+
if (typeof d.thinking === 'string')
|
|
573
|
+
this.phase = 'thinking';
|
|
574
|
+
else if (typeof d.text === 'string')
|
|
575
|
+
this.phase = 'responding';
|
|
576
|
+
}
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
if (evt.type === 'system') {
|
|
580
|
+
// thinking_tokens fires per thinking chunk - the only signal we get
|
|
581
|
+
// during long extended thinking without partial messages.
|
|
582
|
+
if (evt.subtype === 'thinking_tokens' && this.openTools.size === 0)
|
|
583
|
+
this.phase = 'thinking';
|
|
584
|
+
else if (evt.subtype === 'api_retry') {
|
|
585
|
+
this.retry = {
|
|
586
|
+
attempt: typeof evt.attempt === 'number' ? evt.attempt : 1,
|
|
587
|
+
max: typeof evt.max_retries === 'number' ? evt.max_retries : undefined,
|
|
588
|
+
};
|
|
589
|
+
this.phase = 'retry';
|
|
590
|
+
}
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
if (evt.type === 'assistant') {
|
|
594
|
+
this.retry = null;
|
|
595
|
+
const content = Array.isArray(evt.message?.content) ? evt.message.content : [];
|
|
596
|
+
for (const b of content) {
|
|
597
|
+
if (b?.type === 'tool_use' && typeof b.id === 'string') {
|
|
598
|
+
this.openTools.set(b.id, {
|
|
599
|
+
name: typeof b.name === 'string' ? b.name : 'tool',
|
|
600
|
+
hint: toolHint(b.name, b.input),
|
|
601
|
+
startedAt: Date.now(),
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
this.phase = this.openTools.size > 0 ? 'tool' : 'responding';
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (evt.type === 'user') {
|
|
609
|
+
const content = Array.isArray(evt.message?.content) ? evt.message.content : [];
|
|
610
|
+
for (const b of content) {
|
|
611
|
+
if (b?.type === 'tool_result' && typeof b.tool_use_id === 'string')
|
|
612
|
+
this.openTools.delete(b.tool_use_id);
|
|
613
|
+
}
|
|
614
|
+
// Tool results feed the next model turn - thinking until proven otherwise.
|
|
615
|
+
if (this.openTools.size === 0 && this.phase === 'tool')
|
|
616
|
+
this.phase = 'thinking';
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
if (evt.type === 'result')
|
|
620
|
+
this.phase = 'finishing';
|
|
621
|
+
}
|
|
622
|
+
/** The most recently started still-open tool, if any. */
|
|
623
|
+
currentTool() {
|
|
624
|
+
let last = null;
|
|
625
|
+
for (const t of this.openTools.values())
|
|
626
|
+
last = t;
|
|
627
|
+
return last;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
465
630
|
/** 123 B / 4.2 KB / 1.3 MB - short + readable for the "Invoking…" badge. */
|
|
466
631
|
function formatBytes(n) {
|
|
467
632
|
if (n < 1024)
|
|
@@ -557,11 +722,19 @@ function formatDuration(ms) {
|
|
|
557
722
|
// broadcast, a permanent queue-cap slot). Some error strings we build embed an
|
|
558
723
|
// arbitrary OS/spawn message, so clamp here to stay under the cap.
|
|
559
724
|
const MAX_ACK_ERROR_CHARS = 2000;
|
|
560
|
-
async function ack(shortGuid, status, error) {
|
|
561
|
-
|
|
725
|
+
async function ack(shortGuid, status, error, metrics) {
|
|
726
|
+
// Redact BEFORE clamping (truncation must not split a secret past the
|
|
727
|
+
// literal-match scrubber). The ack `error` is broadcast to the web CLI
|
|
728
|
+
// and rendered - it can embed a spawn message or the child's stderr
|
|
729
|
+
// tail, which on a hosted relay could echo a host credential. The twin
|
|
730
|
+
// ingest system-marker is already redacted via redactEntries; this
|
|
731
|
+
// path was the redaction hole (findings: security review 2026-07-03).
|
|
732
|
+
const safeError = error != null
|
|
733
|
+
? redactString(error, getRelaySecrets()).slice(0, MAX_ACK_ERROR_CHARS)
|
|
734
|
+
: null;
|
|
562
735
|
try {
|
|
563
736
|
const res = await deviceFetch('POST', `/remote-devices/dispatches/${encodeURIComponent(shortGuid)}/ack`, {
|
|
564
|
-
status, error: safeError,
|
|
737
|
+
status, error: safeError, ...(metrics ? { metrics } : {}),
|
|
565
738
|
}, 10_000);
|
|
566
739
|
if (!res.ok) {
|
|
567
740
|
// fetch() doesn't throw on 4xx/5xx - surface it ourselves so a
|
|
@@ -603,6 +776,18 @@ async function handleDispatch(d) {
|
|
|
603
776
|
// Two children on one session would corrupt the .jsonl - this is the
|
|
604
777
|
// serialization point that prevents that.
|
|
605
778
|
await killRunningForConv(d.conversation_guid);
|
|
779
|
+
// One ordered ingest queue per dispatch: markers, prompt echo, stream
|
|
780
|
+
// entries, and the tail all flow through it in order, with backoff
|
|
781
|
+
// retry instead of the old fire-and-forget loss on network errors.
|
|
782
|
+
// Daemon-authored entries get a random source_uuid so retried batches
|
|
783
|
+
// dedup server-side.
|
|
784
|
+
const queue = new IngestQueue((entries) => postIngest(d.conversation_guid, entries), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
|
|
785
|
+
const pushSystem = (content) => {
|
|
786
|
+
queue.push({ kind: 'system', content, ts: new Date().toISOString(), source_uuid: randomUUID() });
|
|
787
|
+
};
|
|
788
|
+
/** Drain the queue (bounded) so content lands before the ack that
|
|
789
|
+
* closes the web CLI's live view. */
|
|
790
|
+
const flushQueue = async () => { await queue.close(30_000); };
|
|
606
791
|
let cwd;
|
|
607
792
|
let bootstrapped;
|
|
608
793
|
try {
|
|
@@ -620,7 +805,7 @@ async function handleDispatch(d) {
|
|
|
620
805
|
// is killed at PROJECT_SYNC_TIMEOUT_MS and reported instead of silently stalling
|
|
621
806
|
// the dispatch (the old in-process bootstrap sync could hang forever).
|
|
622
807
|
if (bootstrapped) {
|
|
623
|
-
|
|
808
|
+
pushSystem('Syncing project files…');
|
|
624
809
|
let syncKilled = false;
|
|
625
810
|
try {
|
|
626
811
|
syncKilled = (await runDispatchSync(d, cwd)).killed;
|
|
@@ -628,7 +813,8 @@ async function handleDispatch(d) {
|
|
|
628
813
|
catch (err) {
|
|
629
814
|
const msg = `project sync ${err?.message || 'failed'}`;
|
|
630
815
|
log('error', 'project sync failed - aborting dispatch', { id: d.short_guid, err: err?.message });
|
|
631
|
-
|
|
816
|
+
pushSystem(`Claude Code not started - ${msg}`);
|
|
817
|
+
await flushQueue();
|
|
632
818
|
await ack(d.short_guid, 'error', msg);
|
|
633
819
|
return;
|
|
634
820
|
}
|
|
@@ -637,11 +823,12 @@ async function handleDispatch(d) {
|
|
|
637
823
|
// WHILE the pre-Claude sync was running. Before this was registered in
|
|
638
824
|
// `running`, a cancel was silently ignored until the 120s sync timeout.
|
|
639
825
|
log('info', 'dispatch cancelled during project sync', { id: d.short_guid });
|
|
640
|
-
|
|
826
|
+
pushSystem('Claude Code cancelled (during project sync)');
|
|
827
|
+
await flushQueue();
|
|
641
828
|
await ack(d.short_guid, 'cancelled');
|
|
642
829
|
return;
|
|
643
830
|
}
|
|
644
|
-
|
|
831
|
+
pushSystem('Project files synced.');
|
|
645
832
|
}
|
|
646
833
|
// Build argv for `gipity claude -p …` (or with --resume). No shell - argv
|
|
647
834
|
// as array so the message string can't be interpreted as shell syntax.
|
|
@@ -653,6 +840,13 @@ async function handleDispatch(d) {
|
|
|
653
840
|
// prompt is correct (same authority as running `claude -p` in a local
|
|
654
841
|
// terminal yourself).
|
|
655
842
|
const args = ['claude', '-p', d.message, '--permission-mode', 'bypassPermissions'];
|
|
843
|
+
// Relay sessions run in -p mode, where Claude Code's AskUserQuestion tool
|
|
844
|
+
// is unavailable - so without help the model would ask clarifying
|
|
845
|
+
// questions as prose the user just types back. This system-prompt
|
|
846
|
+
// addendum gives it a structured channel: emit a fenced gipity-question
|
|
847
|
+
// block, which the web CLI renders as an interactive card (clickable
|
|
848
|
+
// options + free-text). Verified to work down to Haiku (Phase 5 spike).
|
|
849
|
+
args.push('--append-system-prompt', GIPITY_QUESTION_PROTOCOL);
|
|
656
850
|
// Per-chat model: the user picked it with `/model` in the web CLI. `gipity
|
|
657
851
|
// claude` forwards --model straight through to the `claude` binary, which
|
|
658
852
|
// honors it on both a fresh session and a --resume. null => agent default.
|
|
@@ -718,18 +912,22 @@ async function handleDispatch(d) {
|
|
|
718
912
|
}
|
|
719
913
|
}
|
|
720
914
|
const header = `Running Claude Code - ${counts.join(' + ')} words${resumeNote}`;
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
{ kind: 'system', content: header },
|
|
724
|
-
]);
|
|
915
|
+
const ts = new Date().toISOString();
|
|
916
|
+
queue.push({ kind: 'prompt', prompt: d.message, ts, source_uuid: randomUUID() }, { kind: 'system', content: header, ts, source_uuid: randomUUID() });
|
|
725
917
|
const t0 = Date.now();
|
|
726
918
|
let exitCode = 1;
|
|
727
919
|
let spawnErr = null;
|
|
728
920
|
let killed = false;
|
|
921
|
+
let runtimeLimit = false;
|
|
922
|
+
let stderrTail = '';
|
|
923
|
+
let startupMs;
|
|
729
924
|
try {
|
|
730
|
-
const result = await spawnGipityClaude(args, cwd, d);
|
|
925
|
+
const result = await spawnGipityClaude(args, cwd, d, queue, { resumeWords: transcript?.words });
|
|
731
926
|
exitCode = result.exitCode;
|
|
732
927
|
killed = result.killed;
|
|
928
|
+
runtimeLimit = result.runtimeLimit ?? false;
|
|
929
|
+
stderrTail = result.stderrTail ?? '';
|
|
930
|
+
startupMs = result.startupMs;
|
|
733
931
|
}
|
|
734
932
|
catch (err) {
|
|
735
933
|
spawnErr = err?.message || String(err);
|
|
@@ -760,15 +958,31 @@ async function handleDispatch(d) {
|
|
|
760
958
|
log('warn', 'sync after dispatch failed', { id: d.short_guid, err: err?.message });
|
|
761
959
|
}
|
|
762
960
|
}
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
961
|
+
// Nonzero exit with no useful message: include the child's last few
|
|
962
|
+
// stderr lines so the visible marker carries the real error instead of
|
|
963
|
+
// just an exit code (previously stderr only reached the daemon's log).
|
|
964
|
+
const stderrNote = stderrTail ? `: ${stderrTail.slice(0, 300)}` : '';
|
|
965
|
+
const tail = runtimeLimit
|
|
966
|
+
? `stopped after ${dur} (runtime limit)`
|
|
967
|
+
: killed
|
|
968
|
+
? `cancelled (${dur})`
|
|
969
|
+
: spawnErr
|
|
970
|
+
? `failed (${dur}: ${spawnErr})`
|
|
971
|
+
: exitCode === 0
|
|
972
|
+
? `finished (${dur})`
|
|
973
|
+
: `failed (${dur}, exit ${exitCode}${stderrNote})`;
|
|
974
|
+
pushSystem(`Claude Code ${tail}`);
|
|
975
|
+
await flushQueue();
|
|
976
|
+
// Observability: how long the relay took before the agent produced its
|
|
977
|
+
// first output (project sync + Claude Code cold start + MCP). Recorded on
|
|
978
|
+
// the dispatch row; tracked, not enforced. Undefined if the child never
|
|
979
|
+
// emitted an event (spawn failure).
|
|
980
|
+
const metrics = startupMs !== undefined ? { startup_ms: startupMs } : undefined;
|
|
981
|
+
if (runtimeLimit) {
|
|
982
|
+
log('warn', 'dispatch hit runtime limit', { id: d.short_guid, ms });
|
|
983
|
+
await ack(d.short_guid, 'error', `Claude Code stopped after ${dur} (runtime limit)`, metrics);
|
|
984
|
+
}
|
|
985
|
+
else if (killed) {
|
|
772
986
|
log('info', 'dispatch cancelled by user', { id: d.short_guid, ms });
|
|
773
987
|
await ack(d.short_guid, 'cancelled');
|
|
774
988
|
}
|
|
@@ -776,12 +990,12 @@ async function handleDispatch(d) {
|
|
|
776
990
|
await ack(d.short_guid, 'error', spawnErr);
|
|
777
991
|
}
|
|
778
992
|
else if (exitCode === 0) {
|
|
779
|
-
log('info', 'dispatch done', { id: d.short_guid, ms });
|
|
780
|
-
await ack(d.short_guid, 'done');
|
|
993
|
+
log('info', 'dispatch done', { id: d.short_guid, ms, startupMs });
|
|
994
|
+
await ack(d.short_guid, 'done', undefined, metrics);
|
|
781
995
|
}
|
|
782
996
|
else {
|
|
783
997
|
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}`);
|
|
998
|
+
await ack(d.short_guid, 'error', `gipity claude exited with code ${exitCode}${stderrNote}`);
|
|
785
999
|
}
|
|
786
1000
|
}
|
|
787
1001
|
/**
|
|
@@ -961,7 +1175,7 @@ async function spawnSync(cwd, timeoutMs, onSpawn) {
|
|
|
961
1175
|
}));
|
|
962
1176
|
});
|
|
963
1177
|
}
|
|
964
|
-
export async function spawnGipityClaude(args, cwd, d) {
|
|
1178
|
+
export async function spawnGipityClaude(args, cwd, d, queue, meta) {
|
|
965
1179
|
// resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
|
|
966
1180
|
// can't launch without an explicit path. An explicit env override is used
|
|
967
1181
|
// verbatim (it may be a full path); only the default name is resolved.
|
|
@@ -969,7 +1183,10 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
969
1183
|
// Inject stream-json flags here rather than at the call site so every
|
|
970
1184
|
// relay spawn path gets the same protocol. `--verbose` is required by
|
|
971
1185
|
// Claude Code when combining `-p` with `--output-format stream-json`.
|
|
972
|
-
|
|
1186
|
+
// `--include-partial-messages` adds stream_event token deltas, which
|
|
1187
|
+
// feed the ephemeral live-typing channel (see stream-delta.ts); whole
|
|
1188
|
+
// assistant/tool events still arrive unchanged for the persistent path.
|
|
1189
|
+
const fullArgs = [...args, '--output-format', 'stream-json', '--verbose', '--include-partial-messages'];
|
|
973
1190
|
const env = { ...process.env, GIPITY_CONVERSATION_GUID: d.conversation_guid };
|
|
974
1191
|
return new Promise((resolve, reject) => {
|
|
975
1192
|
const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -980,10 +1197,13 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
980
1197
|
let resolveExited = () => { };
|
|
981
1198
|
const exited = new Promise(r => { resolveExited = r; });
|
|
982
1199
|
running.set(d.short_guid, { child, convGuid: d.conversation_guid, exited });
|
|
983
|
-
//
|
|
984
|
-
//
|
|
985
|
-
//
|
|
986
|
-
|
|
1200
|
+
// Ordered per-dispatch ingest queue. Entries flow through it in
|
|
1201
|
+
// arrival order with backoff retry - a transient network/server error
|
|
1202
|
+
// no longer drops stream content permanently (source_uuid dedup makes
|
|
1203
|
+
// the retries safe). Falls back to a local queue when the caller
|
|
1204
|
+
// didn't pass one (tests, direct invocation).
|
|
1205
|
+
const q = queue ?? new IngestQueue((entries) => postIngest(d.conversation_guid, entries), { onWarn: (msg, meta) => log('warn', msg, { id: d.short_guid, ...meta }) });
|
|
1206
|
+
const ownQueue = !queue;
|
|
987
1207
|
// Progress heartbeat state. Measured at the daemon boundary - this is
|
|
988
1208
|
// what we actually observe, not anything the child self-reports. These
|
|
989
1209
|
// fields are fully generic (no Claude Code-specific shape) so the same
|
|
@@ -992,33 +1212,146 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
992
1212
|
let stdoutBytesTotal = 0;
|
|
993
1213
|
let lastStdoutByteAt = dispatchStartedAt;
|
|
994
1214
|
let stdoutBytesAtLastTick = 0;
|
|
995
|
-
const
|
|
1215
|
+
const phases = new PhaseTracker();
|
|
1216
|
+
// Rich-heartbeat state, updated from the stream: the agent's current
|
|
1217
|
+
// context size (latest input_tokens off a usage-bearing event) so the
|
|
1218
|
+
// client can show context-window fill. Machine stats are read fresh per
|
|
1219
|
+
// tick (os.* are in-memory kernel values - instant, no I/O).
|
|
1220
|
+
let contextTokens;
|
|
1221
|
+
let firstEventAt; // for the startup-latency metric
|
|
1222
|
+
const CPU_COUNT = cpus().length;
|
|
1223
|
+
const TOTAL_MEM = totalmem();
|
|
1224
|
+
const buildProgressPayload = (procAlive) => {
|
|
996
1225
|
const now = Date.now();
|
|
997
1226
|
const delta = stdoutBytesTotal - stdoutBytesAtLastTick;
|
|
998
1227
|
stdoutBytesAtLastTick = stdoutBytesTotal;
|
|
999
|
-
|
|
1228
|
+
const tool = phases.currentTool();
|
|
1229
|
+
// The hint (Bash command / file path / URL) comes straight from
|
|
1230
|
+
// tool input, so it can contain a secret the agent echoed - this
|
|
1231
|
+
// liveness channel must scrub it, exactly like the ingest and delta
|
|
1232
|
+
// channels do (it's the third path the same tool_use fans out to).
|
|
1233
|
+
// `current_tool` is clamped to the server's max(100) so a long MCP
|
|
1234
|
+
// tool name (`mcp__server__tool`) can't 400 every tick and starve
|
|
1235
|
+
// the client's liveness stream.
|
|
1236
|
+
const hint = tool?.hint ? redactString(tool.hint, getRelaySecrets()).slice(0, 200) : undefined;
|
|
1237
|
+
return {
|
|
1000
1238
|
dispatch_guid: d.short_guid,
|
|
1001
|
-
proc_alive:
|
|
1239
|
+
proc_alive: procAlive,
|
|
1002
1240
|
stdout_bytes_total: stdoutBytesTotal,
|
|
1003
1241
|
stdout_bytes_delta: delta,
|
|
1004
1242
|
stdout_idle_ms: Math.max(0, now - lastStdoutByteAt),
|
|
1005
1243
|
uptime_ms: Math.max(0, now - dispatchStartedAt),
|
|
1006
|
-
|
|
1244
|
+
phase: phases.phase,
|
|
1245
|
+
current_tool: tool?.name?.slice(0, 100),
|
|
1246
|
+
current_tool_hint: hint,
|
|
1247
|
+
tool_elapsed_ms: tool ? Math.max(0, now - tool.startedAt) : undefined,
|
|
1248
|
+
last_event_ms: Math.max(0, now - phases.lastEventAt),
|
|
1249
|
+
retry: phases.retry ?? undefined,
|
|
1250
|
+
// Rich heartbeat: real host + session telemetry.
|
|
1251
|
+
machine_load1: Math.round(loadavg()[0] * 100) / 100,
|
|
1252
|
+
machine_free_mem: freemem(),
|
|
1253
|
+
machine_total_mem: TOTAL_MEM,
|
|
1254
|
+
machine_cpus: CPU_COUNT,
|
|
1255
|
+
context_tokens: contextTokens,
|
|
1256
|
+
resume_words: meta?.resumeWords,
|
|
1257
|
+
};
|
|
1258
|
+
};
|
|
1259
|
+
// Immediate first tick: don't make the user stare at a static "Running
|
|
1260
|
+
// Claude Code" line for the first ~2s. Fire one heartbeat right away so
|
|
1261
|
+
// the spinner + machine/id readout appears the moment we spawn.
|
|
1262
|
+
void postProgress(d.conversation_guid, buildProgressPayload(true));
|
|
1263
|
+
const progressTimer = setInterval(() => {
|
|
1264
|
+
void postProgress(d.conversation_guid, buildProgressPayload(child.exitCode === null));
|
|
1007
1265
|
}, 2000);
|
|
1008
|
-
//
|
|
1266
|
+
// Max-runtime guard: a wedged child (hung tool, upstream stall) must
|
|
1267
|
+
// not tick `proc_alive:true` forever - after the limit, terminate it
|
|
1268
|
+
// with the same SIGTERM→SIGKILL escalation the cancel path uses and
|
|
1269
|
+
// surface a visible marker + error ack (via `runtimeLimit`).
|
|
1270
|
+
let runtimeLimit = false;
|
|
1271
|
+
const maxRuntimeMs = parseInt(process.env.GIPITY_RELAY_MAX_RUNTIME_MS || String(45 * 60_000), 10);
|
|
1272
|
+
const maxRuntimeTimer = maxRuntimeMs > 0 ? setTimeout(() => {
|
|
1273
|
+
runtimeLimit = true;
|
|
1274
|
+
log('warn', 'dispatch hit max runtime - terminating child', { id: d.short_guid, maxRuntimeMs });
|
|
1275
|
+
try {
|
|
1276
|
+
child.kill('SIGTERM');
|
|
1277
|
+
}
|
|
1278
|
+
catch { /* already gone */ }
|
|
1279
|
+
setTimeout(() => {
|
|
1280
|
+
if (child.exitCode === null) {
|
|
1281
|
+
try {
|
|
1282
|
+
child.kill('SIGKILL');
|
|
1283
|
+
}
|
|
1284
|
+
catch { /* already gone */ }
|
|
1285
|
+
}
|
|
1286
|
+
}, KILL_GRACE_MS).unref();
|
|
1287
|
+
}, maxRuntimeMs) : null;
|
|
1288
|
+
maxRuntimeTimer?.unref();
|
|
1289
|
+
// Stdout: NDJSON stream → parse → enqueue each event's ingest entries
|
|
1009
1290
|
// as they arrive. That's the live-streaming path - every assistant
|
|
1010
1291
|
// message and tool call appears in the web CLI within a second of
|
|
1011
1292
|
// Claude emitting it.
|
|
1012
1293
|
// 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)
|
|
1294
|
+
// be denormalized with its tool's name (the result block omits it);
|
|
1295
|
+
// msgSeq disambiguates per-block assistant events sharing a message id
|
|
1296
|
+
// (their source_uuid becomes `msg_x#0`, `msg_x#1`, …).
|
|
1014
1297
|
const toolNames = new Map();
|
|
1298
|
+
const msgSeq = new Map();
|
|
1299
|
+
const unmapped = new Map();
|
|
1300
|
+
// Token-delta pipeline: accumulate stream_event fragments, emit
|
|
1301
|
+
// redaction-safe spans, batch every 150ms/4KB to /stream-delta.
|
|
1302
|
+
const deltaAcc = new DeltaAccumulator(getRelaySecrets);
|
|
1303
|
+
const deltaBatcher = new DeltaBatcher((flush) => {
|
|
1304
|
+
void postStreamDelta(d.conversation_guid, d.short_guid, flush);
|
|
1305
|
+
});
|
|
1306
|
+
// A spawn with background subagents can emit several init and result
|
|
1307
|
+
// events (the loop re-invokes when a task completes). Attach once;
|
|
1308
|
+
// buffer results and post only the last (its cost is cumulative).
|
|
1309
|
+
let attached = false;
|
|
1310
|
+
let finalResult = null;
|
|
1015
1311
|
const splitter = createLineSplitter((line) => {
|
|
1016
1312
|
const evt = parseEvent(line, (reason, snippet) => {
|
|
1017
1313
|
log('warn', 'stream-json parse skipped line', { id: d.short_guid, reason, snippet });
|
|
1018
1314
|
});
|
|
1019
1315
|
if (!evt)
|
|
1020
1316
|
return;
|
|
1021
|
-
|
|
1317
|
+
phases.note(evt);
|
|
1318
|
+
// Startup-latency metric: the FIRST stream event means Claude Code has
|
|
1319
|
+
// finished the pre-spawn work (project sync + cold start + MCP connect)
|
|
1320
|
+
// and is now producing output. Time from dispatch start to here is what
|
|
1321
|
+
// the user waits through before anything happens - the thing we want to
|
|
1322
|
+
// track per-dispatch (see remote_dispatches.metrics).
|
|
1323
|
+
if (firstEventAt === undefined)
|
|
1324
|
+
firstEventAt = Date.now();
|
|
1325
|
+
// Track the agent's live context size for the heartbeat: an assistant
|
|
1326
|
+
// (or result) event's usage.input_tokens IS the context loaded for that
|
|
1327
|
+
// turn. Take the max seen so the readout doesn't jump around between
|
|
1328
|
+
// a small cache-read turn and the full-context turn.
|
|
1329
|
+
const usageIn = evt?.message?.usage?.input_tokens;
|
|
1330
|
+
if (typeof usageIn === 'number' && usageIn > (contextTokens ?? 0))
|
|
1331
|
+
contextTokens = usageIn;
|
|
1332
|
+
deltaBatcher.push(deltaAcc.note(evt));
|
|
1333
|
+
let entries = mapEventToEntries(evt, {
|
|
1334
|
+
toolNames, msgSeq,
|
|
1335
|
+
onUnmapped: (type, subtype) => {
|
|
1336
|
+
const key = subtype ? `${type}/${subtype}` : type;
|
|
1337
|
+
unmapped.set(key, (unmapped.get(key) ?? 0) + 1);
|
|
1338
|
+
},
|
|
1339
|
+
});
|
|
1340
|
+
if (entries.length === 0)
|
|
1341
|
+
return;
|
|
1342
|
+
entries = entries.filter(e => {
|
|
1343
|
+
if (e.kind === 'attach') {
|
|
1344
|
+
if (attached)
|
|
1345
|
+
return false;
|
|
1346
|
+
attached = true;
|
|
1347
|
+
return true;
|
|
1348
|
+
}
|
|
1349
|
+
if (e.kind === 'result') {
|
|
1350
|
+
finalResult = e;
|
|
1351
|
+
return false;
|
|
1352
|
+
}
|
|
1353
|
+
return true;
|
|
1354
|
+
});
|
|
1022
1355
|
if (entries.length === 0)
|
|
1023
1356
|
return;
|
|
1024
1357
|
// Stamp the read time as the event-time hint (event_at). Stream-json
|
|
@@ -1026,15 +1359,19 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
1026
1359
|
// emits them, so receipt time is a close, per-event proxy - far
|
|
1027
1360
|
// better than the single flush-time created_at on the whole batch.
|
|
1028
1361
|
const ts = new Date().toISOString();
|
|
1029
|
-
for (const e of entries)
|
|
1362
|
+
for (const e of entries) {
|
|
1030
1363
|
e.ts = ts;
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1364
|
+
// Every entry needs a dedup key so a queue re-POST after a partial
|
|
1365
|
+
// server success can't double-insert. assistant/tool_use already
|
|
1366
|
+
// carry one (msg_id#n / tool_use_id); compact and any future
|
|
1367
|
+
// keyless kind get a stable UUID here - stable because the daemon
|
|
1368
|
+
// maps each stream line exactly once and the queue retries the
|
|
1369
|
+
// same object (the stream path never re-maps, unlike a transcript
|
|
1370
|
+
// replay), so the same uuid reaches the server on every retry.
|
|
1371
|
+
if (!e.source_uuid)
|
|
1372
|
+
e.source_uuid = randomUUID();
|
|
1373
|
+
}
|
|
1374
|
+
q.push(...entries);
|
|
1038
1375
|
});
|
|
1039
1376
|
child.stdout?.on('data', (chunk) => {
|
|
1040
1377
|
stdoutBytesTotal += chunk.length;
|
|
@@ -1043,12 +1380,22 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
1043
1380
|
});
|
|
1044
1381
|
child.stdout?.on('end', () => splitter.flush());
|
|
1045
1382
|
// Stderr: human-readable only (Claude's progress bars, errors).
|
|
1046
|
-
// Kept on the daemon's own stderr for `gipity relay log
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1383
|
+
// Kept on the daemon's own stderr for `gipity relay log`, plus a
|
|
1384
|
+
// small tail ring so a crash with no stream output can include the
|
|
1385
|
+
// real error in the visible failure marker instead of just an exit
|
|
1386
|
+
// code. The readline interface is closed in the error/exit handler
|
|
1387
|
+
// so the listener doesn't outlive the child.
|
|
1049
1388
|
const errPrefix = C.dim('│ ');
|
|
1389
|
+
const stderrTail = [];
|
|
1050
1390
|
const errRl = child.stderr ? createInterface({ input: child.stderr }) : null;
|
|
1051
|
-
errRl?.on('line', (line) =>
|
|
1391
|
+
errRl?.on('line', (line) => {
|
|
1392
|
+
process.stderr.write(errPrefix + line + '\n');
|
|
1393
|
+
if (line.trim()) {
|
|
1394
|
+
stderrTail.push(line.trim());
|
|
1395
|
+
if (stderrTail.length > 3)
|
|
1396
|
+
stderrTail.shift();
|
|
1397
|
+
}
|
|
1398
|
+
});
|
|
1052
1399
|
let killed = false;
|
|
1053
1400
|
const cleanup = () => {
|
|
1054
1401
|
clearInterval(progressTimer);
|
|
@@ -1060,17 +1407,48 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
1060
1407
|
cleanup();
|
|
1061
1408
|
reject(err);
|
|
1062
1409
|
});
|
|
1063
|
-
|
|
1410
|
+
// Finalize on 'close', NOT 'exit': 'exit' fires when the process dies,
|
|
1411
|
+
// which can be BEFORE the last stdout chunks drain. Claude emits the
|
|
1412
|
+
// `result` footer (with cumulative cost) immediately before exiting,
|
|
1413
|
+
// so finalizing on 'exit' races that line - `finalResult` would be
|
|
1414
|
+
// parsed by the splitter's `'end'` flush AFTER we'd already resolved,
|
|
1415
|
+
// silently losing the session footer + cost. 'close' fires only once
|
|
1416
|
+
// all stdio is closed (after `'end'` → `splitter.flush()`), so every
|
|
1417
|
+
// event is mapped by the time we get here. Carries the same (code,
|
|
1418
|
+
// signal) as 'exit'.
|
|
1419
|
+
child.on('close', async (code, signal) => {
|
|
1064
1420
|
if (signal === 'SIGTERM' || signal === 'SIGKILL')
|
|
1065
1421
|
killed = true;
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1069
|
-
if (
|
|
1070
|
-
|
|
1422
|
+
// Post the buffered session footer now that we know it's final (a
|
|
1423
|
+
// spawn with background subagents emits several result events; the
|
|
1424
|
+
// last carries the cumulative cost).
|
|
1425
|
+
if (finalResult) {
|
|
1426
|
+
finalResult.ts = new Date().toISOString();
|
|
1427
|
+
finalResult.source_uuid = randomUUID();
|
|
1428
|
+
q.push(finalResult);
|
|
1071
1429
|
}
|
|
1430
|
+
if (unmapped.size > 0) {
|
|
1431
|
+
log('info', 'unmapped stream events this dispatch', {
|
|
1432
|
+
id: d.short_guid,
|
|
1433
|
+
counts: Object.fromEntries(unmapped),
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
if (maxRuntimeTimer)
|
|
1437
|
+
clearTimeout(maxRuntimeTimer);
|
|
1438
|
+
deltaBatcher.close();
|
|
1439
|
+
// Final heartbeat with proc_alive:false so the web CLI's progress
|
|
1440
|
+
// line flips to Exited even if the 2s interval just missed the
|
|
1441
|
+
// exit (the interval is cleared in cleanup()).
|
|
1442
|
+
void postProgress(d.conversation_guid, buildProgressPayload(false));
|
|
1443
|
+
// Only drain here when we own the queue; otherwise handleDispatch
|
|
1444
|
+
// closes it after pushing its tail marker (order preserved).
|
|
1445
|
+
if (ownQueue)
|
|
1446
|
+
await q.close();
|
|
1072
1447
|
cleanup();
|
|
1073
|
-
resolve({
|
|
1448
|
+
resolve({
|
|
1449
|
+
exitCode: code ?? 1, killed, runtimeLimit, stderrTail: stderrTail.join(' | '),
|
|
1450
|
+
startupMs: firstEventAt !== undefined ? Math.max(0, firstEventAt - dispatchStartedAt) : undefined,
|
|
1451
|
+
});
|
|
1074
1452
|
});
|
|
1075
1453
|
});
|
|
1076
1454
|
}
|