gipity 1.0.409 → 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.
- package/dist/claude-setup.js +3 -2
- package/dist/commands/add.js +10 -1
- package/dist/commands/bug.js +69 -0
- package/dist/commands/credits.js +247 -15
- package/dist/commands/job.js +3 -3
- package/dist/commands/push.js +7 -3
- package/dist/commands/relay.js +26 -2
- package/dist/commands/remove.js +9 -3
- package/dist/commands/storage.js +64 -0
- package/dist/commands/uninstall.js +2 -2
- package/dist/commands/upload.js +1 -2
- package/dist/hooks/capture-runner.js +15 -2
- package/dist/index.js +5 -4
- package/dist/knowledge.js +22 -0
- package/dist/platform.js +9 -5
- package/dist/relay/daemon.js +517 -69
- package/dist/relay/ingest-queue.js +148 -0
- package/dist/relay/installers.js +10 -2
- package/dist/relay/onboarding.js +5 -2
- package/dist/relay/redact.js +27 -4
- package/dist/relay/setup.js +4 -4
- package/dist/relay/state.js +6 -3
- package/dist/relay/stream-delta.js +235 -0
- package/dist/relay/stream-json.js +61 -15
- package/dist/setup.js +16 -7
- package/dist/sync.js +227 -106
- package/dist/updater/shim.js +4 -4
- package/dist/upload.js +1 -1
- package/package.json +6 -3
package/dist/relay/daemon.js
CHANGED
|
@@ -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
|
|
@@ -124,10 +142,11 @@ function lockLogPerms(dir, file) {
|
|
|
124
142
|
chmodSync(dir, 0o700);
|
|
125
143
|
}
|
|
126
144
|
catch { /* ignore - best-effort */ }
|
|
127
|
-
// Ensure file exists before chmod; open+close creates it if missing.
|
|
145
|
+
// Ensure file exists before chmod; open+close creates it if missing. Pass
|
|
146
|
+
// mode 0600 so it's owner-only from creation (no umask-default race window).
|
|
128
147
|
if (!existsSync(file)) {
|
|
129
148
|
try {
|
|
130
|
-
closeSync(openSync(file, 'a'));
|
|
149
|
+
closeSync(openSync(file, 'a', 0o600));
|
|
131
150
|
}
|
|
132
151
|
catch { /* ignore */ }
|
|
133
152
|
}
|
|
@@ -379,21 +398,31 @@ async function dispatchLoop(ctx, opts) {
|
|
|
379
398
|
/** Collect the secret strings the daemon must scrub from every captured
|
|
380
399
|
* entry before it reaches the web CLI: the shared Claude credential
|
|
381
400
|
* (whichever of the two env vars Claude Code is using) and this host's own
|
|
382
|
-
* Gipity + device tokens.
|
|
383
|
-
*
|
|
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;
|
|
384
410
|
function getRelaySecrets() {
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
if (relaySecretsCache && now - relaySecretsCache.at < RELAY_SECRETS_TTL_MS) {
|
|
413
|
+
return relaySecretsCache.secrets;
|
|
414
|
+
}
|
|
388
415
|
const auth = readAuthFresh();
|
|
389
416
|
const device = state.getDevice();
|
|
390
|
-
|
|
417
|
+
const secrets = normalizeSecrets([
|
|
391
418
|
process.env.CLAUDE_CODE_OAUTH_TOKEN,
|
|
392
419
|
process.env.ANTHROPIC_API_KEY,
|
|
393
420
|
auth?.accessToken,
|
|
394
421
|
auth?.refreshToken,
|
|
395
422
|
device?.token,
|
|
396
423
|
]);
|
|
424
|
+
relaySecretsCache = { secrets, at: now };
|
|
425
|
+
return secrets;
|
|
397
426
|
}
|
|
398
427
|
/** Post a batch of ingest entries with the daemon's device bearer. Returns
|
|
399
428
|
* whether the server accepted them (2xx). Non-2xx and network errors are
|
|
@@ -403,10 +432,36 @@ function getRelaySecrets() {
|
|
|
403
432
|
* Every entry is run through `redactEntries` first: a dispatched
|
|
404
433
|
* `bypassPermissions` session can read the host's credentials, so this is
|
|
405
434
|
* the single chokepoint that keeps a leaked secret out of the transcript. */
|
|
435
|
+
// Server-side per-entry length caps (remote-sessions.ts ingest schema). The
|
|
436
|
+
// server rejects the ENTIRE batch with a 400 if any entry violates one, which
|
|
437
|
+
// would drop good content (and, for the prompt echo, the "Running…" marker
|
|
438
|
+
// batched with it). We clamp defensively here so a batch never 400s on length
|
|
439
|
+
// - truncating an over-long value is strictly better than losing the batch.
|
|
440
|
+
const INGEST_PROMPT_MAX = 200_000;
|
|
441
|
+
const INGEST_ASSISTANT_MAX = 500_000;
|
|
442
|
+
const INGEST_SYSTEM_MAX = 500;
|
|
443
|
+
const TRUNCATE_SUFFIX = '… [truncated]';
|
|
444
|
+
function clampText(s, max) {
|
|
445
|
+
return s.length <= max ? s : s.slice(0, max - TRUNCATE_SUFFIX.length) + TRUNCATE_SUFFIX;
|
|
446
|
+
}
|
|
447
|
+
/** Clamp the human-text fields that have server caps. Runs AFTER redaction so
|
|
448
|
+
* truncation can't split a secret past the literal-match scrubber. Exported
|
|
449
|
+
* for unit testing. */
|
|
450
|
+
export function clampForIngest(entries) {
|
|
451
|
+
return entries.map(e => {
|
|
452
|
+
if (e.kind === 'prompt')
|
|
453
|
+
return { ...e, prompt: clampText(e.prompt, INGEST_PROMPT_MAX) };
|
|
454
|
+
if (e.kind === 'assistant')
|
|
455
|
+
return { ...e, text: clampText(e.text, INGEST_ASSISTANT_MAX) };
|
|
456
|
+
if (e.kind === 'system')
|
|
457
|
+
return { ...e, content: clampText(e.content, INGEST_SYSTEM_MAX) };
|
|
458
|
+
return e;
|
|
459
|
+
});
|
|
460
|
+
}
|
|
406
461
|
async function postIngest(convGuid, entries) {
|
|
407
462
|
if (!entries.length)
|
|
408
463
|
return { ok: true };
|
|
409
|
-
const safeEntries = redactEntries(entries, getRelaySecrets());
|
|
464
|
+
const safeEntries = clampForIngest(redactEntries(entries, getRelaySecrets()));
|
|
410
465
|
try {
|
|
411
466
|
const res = await deviceFetch('POST', `/remote-sessions/${encodeURIComponent(convGuid)}/ingest`, {
|
|
412
467
|
entries: safeEntries,
|
|
@@ -414,13 +469,16 @@ async function postIngest(convGuid, entries) {
|
|
|
414
469
|
if (!res.ok) {
|
|
415
470
|
const body = await res.text().catch(() => '');
|
|
416
471
|
log('warn', 'ingest post non-2xx', { convGuid, httpStatus: res.status, body: body.slice(0, 200) });
|
|
417
|
-
|
|
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 };
|
|
418
476
|
}
|
|
419
477
|
return { ok: true };
|
|
420
478
|
}
|
|
421
479
|
catch (err) {
|
|
422
480
|
log('warn', 'ingest post network error', { convGuid, err: err?.message });
|
|
423
|
-
return { ok: false };
|
|
481
|
+
return { ok: false, retryable: true };
|
|
424
482
|
}
|
|
425
483
|
}
|
|
426
484
|
/** Fire-and-forget dispatch progress heartbeat. Broadcast-only on the
|
|
@@ -435,6 +493,105 @@ async function postProgress(convGuid, payload) {
|
|
|
435
493
|
/* best-effort */
|
|
436
494
|
}
|
|
437
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
|
+
}
|
|
438
595
|
/** 123 B / 4.2 KB / 1.3 MB - short + readable for the "Invoking…" badge. */
|
|
439
596
|
function formatBytes(n) {
|
|
440
597
|
if (n < 1024)
|
|
@@ -525,10 +682,24 @@ function formatDuration(ms) {
|
|
|
525
682
|
const s = totalSec - m * 60;
|
|
526
683
|
return `${m}:${s.toFixed(1).padStart(4, '0')}`;
|
|
527
684
|
}
|
|
528
|
-
|
|
685
|
+
// The server's ack schema caps `error` at 2000 chars and 400s anything longer
|
|
686
|
+
// - which would leave the dispatch stuck in `delivering` forever (no ack, no
|
|
687
|
+
// broadcast, a permanent queue-cap slot). Some error strings we build embed an
|
|
688
|
+
// arbitrary OS/spawn message, so clamp here to stay under the cap.
|
|
689
|
+
const MAX_ACK_ERROR_CHARS = 2000;
|
|
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;
|
|
529
700
|
try {
|
|
530
701
|
const res = await deviceFetch('POST', `/remote-devices/dispatches/${encodeURIComponent(shortGuid)}/ack`, {
|
|
531
|
-
status, error:
|
|
702
|
+
status, error: safeError, ...(metrics ? { metrics } : {}),
|
|
532
703
|
}, 10_000);
|
|
533
704
|
if (!res.ok) {
|
|
534
705
|
// fetch() doesn't throw on 4xx/5xx - surface it ourselves so a
|
|
@@ -570,6 +741,18 @@ async function handleDispatch(d) {
|
|
|
570
741
|
// Two children on one session would corrupt the .jsonl - this is the
|
|
571
742
|
// serialization point that prevents that.
|
|
572
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); };
|
|
573
756
|
let cwd;
|
|
574
757
|
let bootstrapped;
|
|
575
758
|
try {
|
|
@@ -587,18 +770,30 @@ async function handleDispatch(d) {
|
|
|
587
770
|
// is killed at PROJECT_SYNC_TIMEOUT_MS and reported instead of silently stalling
|
|
588
771
|
// the dispatch (the old in-process bootstrap sync could hang forever).
|
|
589
772
|
if (bootstrapped) {
|
|
590
|
-
|
|
773
|
+
pushSystem('Syncing project files…');
|
|
774
|
+
let syncKilled = false;
|
|
591
775
|
try {
|
|
592
|
-
await
|
|
593
|
-
await postIngest(d.conversation_guid, [{ kind: 'system', content: 'Project files synced.' }]);
|
|
776
|
+
syncKilled = (await runDispatchSync(d, cwd)).killed;
|
|
594
777
|
}
|
|
595
778
|
catch (err) {
|
|
596
779
|
const msg = `project sync ${err?.message || 'failed'}`;
|
|
597
780
|
log('error', 'project sync failed - aborting dispatch', { id: d.short_guid, err: err?.message });
|
|
598
|
-
|
|
781
|
+
pushSystem(`Claude Code not started - ${msg}`);
|
|
782
|
+
await flushQueue();
|
|
599
783
|
await ack(d.short_guid, 'error', msg);
|
|
600
784
|
return;
|
|
601
785
|
}
|
|
786
|
+
if (syncKilled) {
|
|
787
|
+
// The user cancelled (or a newer message for this conv superseded us)
|
|
788
|
+
// WHILE the pre-Claude sync was running. Before this was registered in
|
|
789
|
+
// `running`, a cancel was silently ignored until the 120s sync timeout.
|
|
790
|
+
log('info', 'dispatch cancelled during project sync', { id: d.short_guid });
|
|
791
|
+
pushSystem('Claude Code cancelled (during project sync)');
|
|
792
|
+
await flushQueue();
|
|
793
|
+
await ack(d.short_guid, 'cancelled');
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
pushSystem('Project files synced.');
|
|
602
797
|
}
|
|
603
798
|
// Build argv for `gipity claude -p …` (or with --resume). No shell - argv
|
|
604
799
|
// as array so the message string can't be interpreted as shell syntax.
|
|
@@ -610,6 +805,13 @@ async function handleDispatch(d) {
|
|
|
610
805
|
// prompt is correct (same authority as running `claude -p` in a local
|
|
611
806
|
// terminal yourself).
|
|
612
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);
|
|
613
815
|
// Per-chat model: the user picked it with `/model` in the web CLI. `gipity
|
|
614
816
|
// claude` forwards --model straight through to the `claude` binary, which
|
|
615
817
|
// honors it on both a fresh session and a --resume. null => agent default.
|
|
@@ -675,18 +877,22 @@ async function handleDispatch(d) {
|
|
|
675
877
|
}
|
|
676
878
|
}
|
|
677
879
|
const header = `Running Claude Code - ${counts.join(' + ')} words${resumeNote}`;
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
{ kind: 'system', content: header },
|
|
681
|
-
]);
|
|
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() });
|
|
682
882
|
const t0 = Date.now();
|
|
683
883
|
let exitCode = 1;
|
|
684
884
|
let spawnErr = null;
|
|
685
885
|
let killed = false;
|
|
886
|
+
let runtimeLimit = false;
|
|
887
|
+
let stderrTail = '';
|
|
888
|
+
let startupMs;
|
|
686
889
|
try {
|
|
687
|
-
const result = await spawnGipityClaude(args, cwd, d);
|
|
890
|
+
const result = await spawnGipityClaude(args, cwd, d, queue, { resumeWords: transcript?.words });
|
|
688
891
|
exitCode = result.exitCode;
|
|
689
892
|
killed = result.killed;
|
|
893
|
+
runtimeLimit = result.runtimeLimit ?? false;
|
|
894
|
+
stderrTail = result.stderrTail ?? '';
|
|
895
|
+
startupMs = result.startupMs;
|
|
690
896
|
}
|
|
691
897
|
catch (err) {
|
|
692
898
|
spawnErr = err?.message || String(err);
|
|
@@ -717,15 +923,31 @@ async function handleDispatch(d) {
|
|
|
717
923
|
log('warn', 'sync after dispatch failed', { id: d.short_guid, err: err?.message });
|
|
718
924
|
}
|
|
719
925
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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) {
|
|
729
951
|
log('info', 'dispatch cancelled by user', { id: d.short_guid, ms });
|
|
730
952
|
await ack(d.short_guid, 'cancelled');
|
|
731
953
|
}
|
|
@@ -733,12 +955,12 @@ async function handleDispatch(d) {
|
|
|
733
955
|
await ack(d.short_guid, 'error', spawnErr);
|
|
734
956
|
}
|
|
735
957
|
else if (exitCode === 0) {
|
|
736
|
-
log('info', 'dispatch done', { id: d.short_guid, ms });
|
|
737
|
-
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);
|
|
738
960
|
}
|
|
739
961
|
else {
|
|
740
962
|
log('warn', 'dispatch child exited nonzero', { id: d.short_guid, exitCode, ms });
|
|
741
|
-
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}`);
|
|
742
964
|
}
|
|
743
965
|
}
|
|
744
966
|
/**
|
|
@@ -813,6 +1035,11 @@ export function getRunningConvGuids() {
|
|
|
813
1035
|
* cancelled marker). Used at the top of handleDispatch so a new message
|
|
814
1036
|
* for a busy conv cleanly replaces the in-flight one. No-op if no child
|
|
815
1037
|
* matches. */
|
|
1038
|
+
/** Grace period between SIGTERM and the SIGKILL escalation. A child that traps
|
|
1039
|
+
* or ignores SIGTERM (or is blocked in uninterruptible I/O) must not hang the
|
|
1040
|
+
* handler forever - that would permanently hold one of the concurrency slots.
|
|
1041
|
+
* Overridable for tests. */
|
|
1042
|
+
const KILL_GRACE_MS = parseInt(process.env.GIPITY_RELAY_KILL_GRACE_MS || '10000', 10);
|
|
816
1043
|
export async function killRunningForConv(convGuid) {
|
|
817
1044
|
const matches = [...running.values()].filter(e => e.convGuid === convGuid);
|
|
818
1045
|
if (matches.length === 0)
|
|
@@ -824,7 +1051,29 @@ export async function killRunningForConv(convGuid) {
|
|
|
824
1051
|
}
|
|
825
1052
|
catch { /* ignore - already exited */ }
|
|
826
1053
|
}
|
|
1054
|
+
// Wait for a clean unwind, but escalate to SIGKILL if the grace period
|
|
1055
|
+
// elapses so a stuck child can't wedge a slot. Whichever resolves first,
|
|
1056
|
+
// we still await `exited` so the outgoing children post their cancelled
|
|
1057
|
+
// markers + acks before the replacement spawns.
|
|
1058
|
+
const graceTimers = [];
|
|
1059
|
+
const escalate = new Promise(resolve => {
|
|
1060
|
+
const t = setTimeout(() => {
|
|
1061
|
+
for (const e of matches) {
|
|
1062
|
+
log('warn', 'previous dispatch ignored SIGTERM - escalating to SIGKILL', { conv: convGuid });
|
|
1063
|
+
try {
|
|
1064
|
+
e.child.kill('SIGKILL');
|
|
1065
|
+
}
|
|
1066
|
+
catch { /* already gone */ }
|
|
1067
|
+
}
|
|
1068
|
+
resolve();
|
|
1069
|
+
}, KILL_GRACE_MS);
|
|
1070
|
+
graceTimers.push(t);
|
|
1071
|
+
});
|
|
1072
|
+
await Promise.race([Promise.all(matches.map(e => e.exited)), escalate]);
|
|
1073
|
+
// Ensure every child has actually exited (SIGKILL fires the exit event too).
|
|
827
1074
|
await Promise.all(matches.map(e => e.exited));
|
|
1075
|
+
for (const t of graceTimers)
|
|
1076
|
+
clearTimeout(t);
|
|
828
1077
|
}
|
|
829
1078
|
/** Spawn `gipity claude …` in `cwd` with `--output-format stream-json
|
|
830
1079
|
* --verbose` so every event (assistant messages, tool_use blocks,
|
|
@@ -839,7 +1088,7 @@ export async function killRunningForConv(convGuid) {
|
|
|
839
1088
|
* back to VFS. Runs as a child so we inherit sync's cwd-walk for config
|
|
840
1089
|
* resolution (the daemon itself doesn't chdir into projects).
|
|
841
1090
|
* Non-blocking on failure - caller catches and logs. */
|
|
842
|
-
async function spawnSync(cwd, timeoutMs) {
|
|
1091
|
+
async function spawnSync(cwd, timeoutMs, onSpawn) {
|
|
843
1092
|
// resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
|
|
844
1093
|
// can't launch without an explicit path. An explicit env override is used
|
|
845
1094
|
// verbatim (it may be a full path); only the default name is resolved.
|
|
@@ -850,6 +1099,9 @@ async function spawnSync(cwd, timeoutMs) {
|
|
|
850
1099
|
env: process.env,
|
|
851
1100
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
852
1101
|
});
|
|
1102
|
+
// Hand the child to the caller so it can register the sync in `running`,
|
|
1103
|
+
// making it cancellable (the poller / kill-on-new-message can SIGTERM it).
|
|
1104
|
+
onSpawn?.(child);
|
|
853
1105
|
// Drain pipes so the child doesn't stall on a full buffer.
|
|
854
1106
|
let stdoutLen = 0;
|
|
855
1107
|
let stderrBuf = '';
|
|
@@ -888,7 +1140,7 @@ async function spawnSync(cwd, timeoutMs) {
|
|
|
888
1140
|
}));
|
|
889
1141
|
});
|
|
890
1142
|
}
|
|
891
|
-
export async function spawnGipityClaude(args, cwd, d) {
|
|
1143
|
+
export async function spawnGipityClaude(args, cwd, d, queue, meta) {
|
|
892
1144
|
// resolveCommand: on Windows the bare `gipity` is a .cmd shim that spawn
|
|
893
1145
|
// can't launch without an explicit path. An explicit env override is used
|
|
894
1146
|
// verbatim (it may be a full path); only the default name is resolved.
|
|
@@ -896,7 +1148,10 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
896
1148
|
// Inject stream-json flags here rather than at the call site so every
|
|
897
1149
|
// relay spawn path gets the same protocol. `--verbose` is required by
|
|
898
1150
|
// Claude Code when combining `-p` with `--output-format stream-json`.
|
|
899
|
-
|
|
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'];
|
|
900
1155
|
const env = { ...process.env, GIPITY_CONVERSATION_GUID: d.conversation_guid };
|
|
901
1156
|
return new Promise((resolve, reject) => {
|
|
902
1157
|
const child = spawnCommand(cmd, fullArgs, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
@@ -907,10 +1162,13 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
907
1162
|
let resolveExited = () => { };
|
|
908
1163
|
const exited = new Promise(r => { resolveExited = r; });
|
|
909
1164
|
running.set(d.short_guid, { child, convGuid: d.conversation_guid, exited });
|
|
910
|
-
//
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
-
|
|
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;
|
|
914
1172
|
// Progress heartbeat state. Measured at the daemon boundary - this is
|
|
915
1173
|
// what we actually observe, not anything the child self-reports. These
|
|
916
1174
|
// fields are fully generic (no Claude Code-specific shape) so the same
|
|
@@ -919,33 +1177,146 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
919
1177
|
let stdoutBytesTotal = 0;
|
|
920
1178
|
let lastStdoutByteAt = dispatchStartedAt;
|
|
921
1179
|
let stdoutBytesAtLastTick = 0;
|
|
922
|
-
const
|
|
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) => {
|
|
923
1190
|
const now = Date.now();
|
|
924
1191
|
const delta = stdoutBytesTotal - stdoutBytesAtLastTick;
|
|
925
1192
|
stdoutBytesAtLastTick = stdoutBytesTotal;
|
|
926
|
-
|
|
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 {
|
|
927
1203
|
dispatch_guid: d.short_guid,
|
|
928
|
-
proc_alive:
|
|
1204
|
+
proc_alive: procAlive,
|
|
929
1205
|
stdout_bytes_total: stdoutBytesTotal,
|
|
930
1206
|
stdout_bytes_delta: delta,
|
|
931
1207
|
stdout_idle_ms: Math.max(0, now - lastStdoutByteAt),
|
|
932
1208
|
uptime_ms: Math.max(0, now - dispatchStartedAt),
|
|
933
|
-
|
|
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));
|
|
934
1230
|
}, 2000);
|
|
935
|
-
//
|
|
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
|
|
936
1255
|
// as they arrive. That's the live-streaming path - every assistant
|
|
937
1256
|
// message and tool call appears in the web CLI within a second of
|
|
938
1257
|
// Claude emitting it.
|
|
939
1258
|
// Per-dispatch tool_use_id → tool_name map so a `tool_result` event can
|
|
940
|
-
// 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`, …).
|
|
941
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;
|
|
942
1276
|
const splitter = createLineSplitter((line) => {
|
|
943
1277
|
const evt = parseEvent(line, (reason, snippet) => {
|
|
944
1278
|
log('warn', 'stream-json parse skipped line', { id: d.short_guid, reason, snippet });
|
|
945
1279
|
});
|
|
946
1280
|
if (!evt)
|
|
947
1281
|
return;
|
|
948
|
-
|
|
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
|
+
});
|
|
949
1320
|
if (entries.length === 0)
|
|
950
1321
|
return;
|
|
951
1322
|
// Stamp the read time as the event-time hint (event_at). Stream-json
|
|
@@ -953,15 +1324,19 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
953
1324
|
// emits them, so receipt time is a close, per-event proxy - far
|
|
954
1325
|
// better than the single flush-time created_at on the whole batch.
|
|
955
1326
|
const ts = new Date().toISOString();
|
|
956
|
-
for (const e of entries)
|
|
1327
|
+
for (const e of entries) {
|
|
957
1328
|
e.ts = ts;
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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);
|
|
965
1340
|
});
|
|
966
1341
|
child.stdout?.on('data', (chunk) => {
|
|
967
1342
|
stdoutBytesTotal += chunk.length;
|
|
@@ -970,12 +1345,22 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
970
1345
|
});
|
|
971
1346
|
child.stdout?.on('end', () => splitter.flush());
|
|
972
1347
|
// Stderr: human-readable only (Claude's progress bars, errors).
|
|
973
|
-
// Kept on the daemon's own stderr for `gipity relay log
|
|
974
|
-
//
|
|
975
|
-
//
|
|
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.
|
|
976
1353
|
const errPrefix = C.dim('│ ');
|
|
1354
|
+
const stderrTail = [];
|
|
977
1355
|
const errRl = child.stderr ? createInterface({ input: child.stderr }) : null;
|
|
978
|
-
errRl?.on('line', (line) =>
|
|
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
|
+
});
|
|
979
1364
|
let killed = false;
|
|
980
1365
|
const cleanup = () => {
|
|
981
1366
|
clearInterval(progressTimer);
|
|
@@ -987,20 +1372,83 @@ export async function spawnGipityClaude(args, cwd, d) {
|
|
|
987
1372
|
cleanup();
|
|
988
1373
|
reject(err);
|
|
989
1374
|
});
|
|
990
|
-
|
|
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) => {
|
|
991
1385
|
if (signal === 'SIGTERM' || signal === 'SIGKILL')
|
|
992
1386
|
killed = true;
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
if (
|
|
997
|
-
|
|
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);
|
|
998
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
|
+
});
|
|
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();
|
|
999
1412
|
cleanup();
|
|
1000
|
-
resolve({
|
|
1413
|
+
resolve({
|
|
1414
|
+
exitCode: code ?? 1, killed, runtimeLimit, stderrTail: stderrTail.join(' | '),
|
|
1415
|
+
startupMs: firstEventAt !== undefined ? Math.max(0, firstEventAt - dispatchStartedAt) : undefined,
|
|
1416
|
+
});
|
|
1001
1417
|
});
|
|
1002
1418
|
});
|
|
1003
1419
|
}
|
|
1420
|
+
/** Run the pre-Claude `gipity sync` for a dispatch, registered in `running`
|
|
1421
|
+
* so it's cancellable. Without this the dispatch is invisible to the
|
|
1422
|
+
* cancellation poller and to kill-on-new-message during the sync, so a slow
|
|
1423
|
+
* sync couldn't be interrupted for up to PROJECT_SYNC_TIMEOUT_MS. Returns
|
|
1424
|
+
* `{ killed }` = true when the sync child was SIGTERMed externally (user
|
|
1425
|
+
* cancel or a newer message on the same conv), distinct from a genuine sync
|
|
1426
|
+
* failure (which throws) - the daemon's own timeout uses SIGKILL, so a
|
|
1427
|
+
* SIGTERM here can only be an external interrupt. */
|
|
1428
|
+
async function runDispatchSync(d, cwd) {
|
|
1429
|
+
let killed = false;
|
|
1430
|
+
try {
|
|
1431
|
+
await spawnSync(cwd, PROJECT_SYNC_TIMEOUT_MS, (child) => {
|
|
1432
|
+
const exited = new Promise(resolve => {
|
|
1433
|
+
child.once('exit', (_code, signal) => {
|
|
1434
|
+
if (signal === 'SIGTERM')
|
|
1435
|
+
killed = true;
|
|
1436
|
+
resolve();
|
|
1437
|
+
});
|
|
1438
|
+
});
|
|
1439
|
+
running.set(d.short_guid, { child, convGuid: d.conversation_guid, exited });
|
|
1440
|
+
});
|
|
1441
|
+
return { killed: false };
|
|
1442
|
+
}
|
|
1443
|
+
catch (err) {
|
|
1444
|
+
if (killed)
|
|
1445
|
+
return { killed: true };
|
|
1446
|
+
throw err;
|
|
1447
|
+
}
|
|
1448
|
+
finally {
|
|
1449
|
+
running.delete(d.short_guid);
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1004
1452
|
/** SIGTERM a specific running dispatch. Returns true if one was killed,
|
|
1005
1453
|
* false if no such child was running on this daemon. */
|
|
1006
1454
|
export function killDispatch(shortGuid) {
|