@oar-sh/oar 0.9.2 → 0.9.3
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/CHANGELOG.md +269 -0
- package/README.md +407 -424
- package/bin/oar.js +25 -10
- package/package.json +7 -4
- package/server/README.md +1 -1
- package/server/claude-worker/claude-session-process.mjs +981 -44
- package/server/claude-worker/claude-session-worker.mjs +45 -4
- package/server/claude-worker/claude-turn-publisher.mjs +81 -20
- package/server/claude-worker/claude-worker-link-wiring.mjs +9 -0
- package/server/copilot-worker/copilot-ask-user-tool.mjs +89 -0
- package/server/copilot-worker/copilot-continuation-signals.mjs +55 -0
- package/server/copilot-worker/copilot-question-bridge.mjs +34 -12
- package/server/copilot-worker/copilot-sdk-event-normalizer.mjs +101 -12
- package/server/copilot-worker/copilot-sdk-session-process.mjs +2269 -492
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +82 -20
- package/server/db-schema.mjs +20 -0
- package/server/migrations/0004-steer-settle-markers.mjs +119 -0
- package/server/public/app/api-client.js +22 -2
- package/server/public/app/ask-user-view.js +102 -13
- package/server/public/app/attachments-view.js +37 -2
- package/server/public/app/background-task-timeout-options.mjs +9 -0
- package/server/public/app/background-tasks-view.mjs +33 -18
- package/server/public/app/bootstrap.js +34 -8
- package/server/public/app/composer-control-state.mjs +52 -34
- package/server/public/app/conversation-draft-sync.mjs +187 -0
- package/server/public/app/conversation-list-filter.mjs +86 -0
- package/server/public/app/conversation-provider-indicator.mjs +8 -0
- package/server/public/app/conversation-view.js +919 -194
- package/server/public/app/emoji-view.js +2 -0
- package/server/public/app/font-scaling.js +4 -37
- package/server/public/app/journal-view.js +145 -5
- package/server/public/app/model-selector-options.mjs +1 -1
- package/server/public/app/question-multi-select.mjs +52 -0
- package/server/public/app/relay-prompt-sanitizer.mjs +17 -2
- package/server/public/app/settings-modal.js +8 -6
- package/server/public/app/socket-handlers.js +19 -0
- package/server/public/app/steered-turn-merge.mjs +100 -0
- package/server/public/app/store.js +126 -15
- package/server/public/app/sync-outbox.mjs +16 -8
- package/server/public/app/tool-activity-emoji.mjs +36 -0
- package/server/public/app/transcript-viewport-anchor.mjs +210 -0
- package/server/public/index.html +206 -9
- package/server/public/sw.js +7 -0
- package/server/relay-tools.md +2 -0
- package/server/repositories/message-repository.mjs +61 -18
- package/server/repositories/question-repository.mjs +15 -0
- package/server/repositories/session-repository.mjs +7 -31
- package/server/routes/messages-routes.mjs +488 -46
- package/server/routes/sessions-routes.mjs +298 -55
- package/server/server-runtime.mjs +65 -25
- package/server/services/live-turn-picker.mjs +47 -0
- package/server/services/relay-prompt-sanitizer.mjs +17 -2
- package/server/services/relay-question-context.mjs +46 -0
- package/server/services/sdk-session-import-service.mjs +49 -7
- package/server/services/session-worker-registry-service.mjs +75 -0
- package/server/services/session-worker-supervisor-service.mjs +7 -1
- package/server/services/session-worker-websocket-service.mjs +23 -1
- package/server/services/steer-resend.mjs +45 -0
- package/shared/background-task-timeout.mjs +12 -4
- package/shared/control-poller.mjs +4 -1
- package/shared/question-multi-select.mjs +16 -0
- package/shared/relay-stop-failure.mjs +15 -0
- package/shared/slash-command-guard.mjs +76 -0
- package/shared/steer-note.mjs +66 -0
- package/shared/steer-settle-failure.mjs +89 -0
- package/shared/steer-settle-markers.mjs +18 -0
- package/shared/worker-crash-guard.mjs +40 -25
- package/shared/worker-runtime/heartbeat.mjs +55 -1
- package/shared/worker-runtime/runner-link-wiring.mjs +44 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +119 -8
- package/server/claude-worker/claude-session-test-harness.mjs +0 -250
- package/server/claude-worker/fixtures/workflow-run-record.json +0 -1
- package/server/copilot-worker/copilot-sdk-test-harness.mjs +0 -382
- package/server/copilot-worker/fixtures/abort-turn.json +0 -588
- package/server/copilot-worker/fixtures/ask-user-turn.json +0 -1154
- package/server/copilot-worker/fixtures/background-timer-continuation.json +0 -445
- package/server/copilot-worker/fixtures/background-timer-turn.json +0 -497
- package/server/copilot-worker/fixtures/happy-turn.json +0 -566
- package/server/copilot-worker/fixtures/quota-turn.json +0 -647
- package/server/copilot-worker/fixtures/reasoning-turn.json +0 -625
- package/server/copilot-worker/fixtures/resume-turn.json +0 -700
- package/server/copilot-worker/fixtures/subagent-turn.json +0 -1378
- package/server/copilot-worker/fixtures/tool-permission-turn.json +0 -1293
- package/server/routes/messages-routes-test-harness.mjs +0 -111
- package/server/services/fixtures/claude-auth-stub.cmd +0 -1
- package/server/services/fixtures/claude-auth-stub.mjs +0 -76
- package/server/services/fixtures/claude-auth-stub.sh +0 -4
- package/server/services/fixtures/cli-install-stub.cmd +0 -1
- package/server/services/fixtures/cli-install-stub.mjs +0 -98
- package/server/services/fixtures/cli-install-stub.sh +0 -4
- package/server/services/fixtures/grok-stub.cmd +0 -1
- package/server/services/fixtures/grok-stub.mjs +0 -89
- package/server/services/fixtures/grok-stub.sh +0 -4
- package/shared/fixtures/copilot-catalog-raw-2026-09-07.json +0 -2363
|
@@ -6,8 +6,10 @@ import { buildClaudeUserContent } from './claude-attachments.mjs';
|
|
|
6
6
|
import {
|
|
7
7
|
compactBoundaryActivityAction,
|
|
8
8
|
createSdkMessageNormalizer,
|
|
9
|
+
displayToolName,
|
|
9
10
|
formatApiRetryNotice,
|
|
10
11
|
isSubagentToolName,
|
|
12
|
+
summarizeToolInput,
|
|
11
13
|
} from './sdk-message-normalizer.mjs';
|
|
12
14
|
import { digestFromRunRecord, digestFromJournal } from './workflow-progress-digest.mjs';
|
|
13
15
|
import { createClaudeSessionRootResolver } from '../services/claude-session-root-service.mjs';
|
|
@@ -26,8 +28,21 @@ import {
|
|
|
26
28
|
} from './claude-sdk-adapter.mjs';
|
|
27
29
|
import { parseThinkingDisplay, parseThinkingEnabled } from '../../shared/claude-thinking.mjs';
|
|
28
30
|
import { relocateClaudeTranscriptForCwd } from './claude-transcript-relocator.mjs';
|
|
29
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
attemptFields,
|
|
33
|
+
buildSteerSettleFailure,
|
|
34
|
+
createClaudeTurnPublisher,
|
|
35
|
+
isStaleAttemptError,
|
|
36
|
+
} from './claude-turn-publisher.mjs';
|
|
30
37
|
import { createAskUserBridge } from '../../shared/ask-user-bridge.mjs';
|
|
38
|
+
import { buildRelayStopFailure } from '../../shared/relay-stop-failure.mjs';
|
|
39
|
+
import { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT } from '../../shared/steer-settle-markers.mjs';
|
|
40
|
+
import { stripSteerNote, withSteerNoteContent } from '../../shared/steer-note.mjs';
|
|
41
|
+
import {
|
|
42
|
+
stripSlashCommandGuard,
|
|
43
|
+
stripSlashCommandGuardContent,
|
|
44
|
+
withSlashCommandGuardContent,
|
|
45
|
+
} from '../../shared/slash-command-guard.mjs';
|
|
31
46
|
|
|
32
47
|
/**
|
|
33
48
|
* Which system-prompt append a relay mode gets (claude-sdk-adapter's
|
|
@@ -42,8 +57,11 @@ function modeAppendClass(relayMode) {
|
|
|
42
57
|
return mode === 'ask' || mode === 'autopilot' ? mode : 'none';
|
|
43
58
|
}
|
|
44
59
|
|
|
60
|
+
export { STEER_FOLDED_TEXT, STEER_STOPPED_TEXT };
|
|
61
|
+
|
|
45
62
|
/** Task types that run a model of their own (vs. bash/monitor/workflow). */
|
|
46
63
|
const AGENT_TASK_TYPES = new Set(['local_agent', 'agent', 'subagent']);
|
|
64
|
+
const MAX_TASK_TOOL_CALL_CHARS = 300;
|
|
47
65
|
|
|
48
66
|
// ---------------------------------------------------------------------------
|
|
49
67
|
// Workflow progress sources (CLI 2.1.226 on-disk formats)
|
|
@@ -265,13 +283,16 @@ function contentText(content) {
|
|
|
265
283
|
function turnOpeningUserSignature(sdkMessage) {
|
|
266
284
|
if (sdkMessage?.type !== 'user' || sdkMessage?.parent_tool_use_id) return null;
|
|
267
285
|
const content = sdkMessage?.message?.content;
|
|
268
|
-
|
|
286
|
+
// A steered push carries the hidden steer note, and a "/x" the slash-command
|
|
287
|
+
// guard (shared/slash-command-guard.mjs); every comparison is against the
|
|
288
|
+
// user's own text, so both are stripped here, once, for all of them.
|
|
289
|
+
if (typeof content === 'string') return stripSteerNote(stripSlashCommandGuard(content));
|
|
269
290
|
if (!Array.isArray(content)) return null;
|
|
270
291
|
if (content.some((block) => block?.type === 'tool_result')) return null;
|
|
271
292
|
// Unlike turnOpeningUserText this keeps '' — an attachment-only message
|
|
272
293
|
// replays with an empty text block, and the absorbed-steering match must
|
|
273
294
|
// still recognize it (expectedText is '' for those too).
|
|
274
|
-
return contentText(content);
|
|
295
|
+
return stripSteerNote(contentText(stripSlashCommandGuardContent(content)));
|
|
275
296
|
}
|
|
276
297
|
|
|
277
298
|
function turnOpeningUserText(sdkMessage) {
|
|
@@ -405,6 +426,15 @@ export function createClaudeSessionRunner({
|
|
|
405
426
|
// the slowest cold start observed; the primary absorbed-steering fix in
|
|
406
427
|
// resolveContext should make this fire only for unknown variants.
|
|
407
428
|
pendingDeliveredTimeoutMs = 5 * 60_000,
|
|
429
|
+
// How long a STEERED entry (pushed while a turn was already running) may sit
|
|
430
|
+
// unattached once the process goes idle before it is settled as merged into
|
|
431
|
+
// the turn it was folded into. The CLI folds a mid-turn push into the running
|
|
432
|
+
// turn and answers with one result on that turn, without replaying the
|
|
433
|
+
// steered text — so a short grace after idle (long enough to rule out the CLI
|
|
434
|
+
// opening its own turn for it, which it does immediately if at all) is all it
|
|
435
|
+
// takes to distinguish a fold from a queued run. Far below the watchdog so a
|
|
436
|
+
// folded row settles as answered, not failed with a "resend" error.
|
|
437
|
+
steeredFoldGraceMs = 2_000,
|
|
408
438
|
// Backstop for the compaction hold. A compaction is announced ONCE by
|
|
409
439
|
// `status: 'compacting'` (the CLI's periodic re-emit is gated on the
|
|
410
440
|
// remote-control client's activity callback, which the plain SDK `query()`
|
|
@@ -422,9 +452,38 @@ export function createClaudeSessionRunner({
|
|
|
422
452
|
// by the first turn to open regardless.
|
|
423
453
|
compactReplayAdoptionMs = 60_000,
|
|
424
454
|
continuationRetryDelayMs = 500,
|
|
455
|
+
// Backoff between attempts to save a consumed steer's settle marker (~31 s
|
|
456
|
+
// in all); after the last one the row fails terminally instead.
|
|
457
|
+
settleRetryDelaysMs = [1_000, 2_000, 4_000, 8_000, 8_000, 8_000],
|
|
458
|
+
// How many times the terminal failure itself is posted before the worker
|
|
459
|
+
// stops trying and hands the row to the relay via the heartbeat's
|
|
460
|
+
// `settleFailed` (a persistent HTTP error — say a 401 after a token change —
|
|
461
|
+
// would otherwise spin forever).
|
|
462
|
+
maxTerminalSettleAttempts = 5,
|
|
463
|
+
// After a Stop that cut off pushed steers, the CLI immediately opens a
|
|
464
|
+
// follow-up turn for them (a bare init, no user replay) that re-runs the
|
|
465
|
+
// interrupted tool call before answering (SDK probe 2026-09-24, CLI
|
|
466
|
+
// 2.1.281). A turn starting within this window of the Stop — or of the
|
|
467
|
+
// previous suppressed turn's end — is interrupted before it can do anything.
|
|
468
|
+
stopFollowUpWindowMs = 3_000,
|
|
469
|
+
// How long a stopped turn's row stays reported to the heartbeat after its
|
|
470
|
+
// interrupt and final publishes resolved (the abort ack is still landing),
|
|
471
|
+
// and the hard cap on that linger.
|
|
472
|
+
stoppedRowLingerMs = 5_000,
|
|
473
|
+
stoppedRowLingerMaxMs = 60_000,
|
|
474
|
+
// False while the connected relay does not understand the `steering-held`
|
|
475
|
+
// hand-back (a worker updated on disk before the relay restarted): a held
|
|
476
|
+
// delivery is then pushed the legacy way instead of being handed back into
|
|
477
|
+
// a retry-and-backoff requeue.
|
|
478
|
+
canHandBackHeldDelivery = () => true,
|
|
425
479
|
workflowPollMs = 2_000,
|
|
426
480
|
resolveWorkflowSessionDirImpl = null,
|
|
427
481
|
askUserBridgeOptions = {},
|
|
482
|
+
// Called with `true`/`false` whenever the runner flips between accepting
|
|
483
|
+
// deliveries and holding them (see isDeliveryHeld). The worker wires it to
|
|
484
|
+
// the socket link: a hold withdraws the relay's readiness, and its end
|
|
485
|
+
// re-arms it at once so a held message steers into the resumed turn.
|
|
486
|
+
onDeliveryReadinessChange = () => {},
|
|
428
487
|
dbg = () => {},
|
|
429
488
|
} = {}) {
|
|
430
489
|
const publisher = createClaudeTurnPublisher({
|
|
@@ -435,6 +494,35 @@ export function createClaudeSessionRunner({
|
|
|
435
494
|
const resolveWorkflowSessionDir = resolveWorkflowSessionDirImpl || createDefaultWorkflowSessionDirResolver();
|
|
436
495
|
let claudeNativeSessionId = '';
|
|
437
496
|
let proc = null;
|
|
497
|
+
// Turn-opening deliveries between entry and push (adaptProcess and a cold
|
|
498
|
+
// spawn await in between); counted so a second delivery arriving in that
|
|
499
|
+
// gap is held instead of racing the first into the CLI.
|
|
500
|
+
let admittingDeliveries = 0;
|
|
501
|
+
// Settings re-inits (see the init handler). The window opens at
|
|
502
|
+
// adaptProcess's first control request — never when nothing changes — and
|
|
503
|
+
// closes when the delivery it serves is registered in pendingDelivered; the
|
|
504
|
+
// count is how many bare inits inside it are our own. A count, not a flag:
|
|
505
|
+
// a continuation the CLI opens inside the same window inits the same way,
|
|
506
|
+
// and ignoring every init there swallowed its boundary and swapped its
|
|
507
|
+
// answer with the delivered one. Over-counting (a request that never
|
|
508
|
+
// re-inits) only degrades to that. Some CLI paths merely mark the init
|
|
509
|
+
// stale and re-init when the pushed input is dequeued; that init lands
|
|
510
|
+
// after the window, in the delivered-row branch. Known gap: when that
|
|
511
|
+
// happens AND a continuation inits inside the window, the continuation's
|
|
512
|
+
// init is taken for ours and its answer attaches to the delivered row —
|
|
513
|
+
// the same misattribution as a continuation whose init follows the push.
|
|
514
|
+
let settingsReinitsOwed = 0;
|
|
515
|
+
let settingsWindowOpen = false;
|
|
516
|
+
let lastReportedDeliveryReady = true;
|
|
517
|
+
// Rows whose prompt the CLI consumed and whose settle marker is being saved
|
|
518
|
+
// (id → relay message). Runner-level, not per process: a process dying
|
|
519
|
+
// mid-settle must not drop them from the heartbeat's owned set.
|
|
520
|
+
const settlingMessages = new Map();
|
|
521
|
+
// id → terminalError for settling rows the worker gave up posting; reported
|
|
522
|
+
// in the heartbeat's `settleFailed` until the relay confirms it failed them.
|
|
523
|
+
const settleFailedTerminals = new Map();
|
|
524
|
+
// Row id → the latest in-flight /api/queue-consumed POST touching it.
|
|
525
|
+
const consumedMarkChains = new Map();
|
|
438
526
|
|
|
439
527
|
async function persistNativeSessionId(conversationId, sessionId) {
|
|
440
528
|
const normalized = String(sessionId || '').trim();
|
|
@@ -471,22 +559,103 @@ export function createClaudeSessionRunner({
|
|
|
471
559
|
}
|
|
472
560
|
|
|
473
561
|
// Every queue row this worker currently owes work for, as { id, attemptId }
|
|
474
|
-
// entries: the running turn (delivered or continuation)
|
|
475
|
-
// message queued behind it
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
//
|
|
562
|
+
// entries: the running turn (delivered or continuation), any delivered
|
|
563
|
+
// message queued behind it, and every consumed steer whose settle marker is
|
|
564
|
+
// still being saved. The heartbeat reports all of them (id-only — its call
|
|
565
|
+
// site unwraps) so the server's owner-recovery never replays a row the
|
|
566
|
+
// worker still holds; the crash guard takes the entries whole so its
|
|
567
|
+
// requeues stay fenced to this attempt — and a settling row carries the
|
|
568
|
+
// terminal failure the guard must send instead of a requeue, because its
|
|
569
|
+
// prompt must never run twice.
|
|
479
570
|
function getActiveQueueMessageIds() {
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
571
|
+
const live = proc
|
|
572
|
+
? [proc.activeCtx?.message, ...proc.pendingDelivered.map((entry) => entry.ctx.message)]
|
|
573
|
+
: [];
|
|
574
|
+
const entries = live.map((message) => ({
|
|
575
|
+
id: String(message?.id || '').trim(),
|
|
576
|
+
attemptId: message?.attemptId || null,
|
|
577
|
+
}));
|
|
578
|
+
// A stopped row whose abort ack has not landed yet: the crash guard (and
|
|
579
|
+
// SIGTERM) must fail it the way the ack would, never requeue it — that
|
|
580
|
+
// would re-run a prompt the user explicitly stopped.
|
|
581
|
+
for (const { message } of lingeringStopped.values()) {
|
|
582
|
+
entries.push({
|
|
486
583
|
id: String(message?.id || '').trim(),
|
|
487
584
|
attemptId: message?.attemptId || null,
|
|
488
|
-
|
|
585
|
+
terminalError: buildRelayStopFailure(),
|
|
586
|
+
});
|
|
587
|
+
}
|
|
588
|
+
for (const { message, variant } of settlingMessages.values()) {
|
|
589
|
+
entries.push({
|
|
590
|
+
id: String(message?.id || '').trim(),
|
|
591
|
+
attemptId: message?.attemptId || null,
|
|
592
|
+
terminalError: buildSteerSettleFailure(message, { variant, agentLabel: 'Claude' }),
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
return entries.filter((entry) => entry.id);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Claim a consumed row as settling — synchronously, BEFORE whatever detaches
|
|
600
|
+
* it from pendingDelivered/activeCtx, so no heartbeat can see it unowned.
|
|
601
|
+
* `variant` picks the terminal wording should its settle never land.
|
|
602
|
+
*/
|
|
603
|
+
function claimSettling(message, variant = 'folded') {
|
|
604
|
+
const id = String(message?.id || '').trim();
|
|
605
|
+
if (id && !settlingMessages.has(id)) settlingMessages.set(id, { message, variant });
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Mark rows whose prompt the CLI has taken as consumed on the relay, for the
|
|
610
|
+
* paths where no response carries the mark (a stopped turn, a handoff).
|
|
611
|
+
* Best-effort: the worker's own settle still owns these rows while it lives;
|
|
612
|
+
* the mark only protects them when it does not.
|
|
613
|
+
*/
|
|
614
|
+
function markConsumed(conversationId, entries = [], { consumed = true } = {}) {
|
|
615
|
+
const rows = entries
|
|
616
|
+
.map((entry) => ({ id: String(entry?.id || '').trim(), attemptId: entry?.attemptId || null }))
|
|
489
617
|
.filter((entry) => entry.id);
|
|
618
|
+
if (!rows.length) return Promise.resolve();
|
|
619
|
+
// Per-row ordering: a stopped turn's mark and the un-mark of a steer that
|
|
620
|
+
// then runs its own turn are separate fire-and-forget POSTs, and must
|
|
621
|
+
// reach the relay in the order they were decided.
|
|
622
|
+
const previous = Promise.allSettled(rows.map((row) => consumedMarkChains.get(row.id)).filter(Boolean));
|
|
623
|
+
const post = previous.then(() => api('POST', '/api/queue-consumed', {
|
|
624
|
+
conversationId: String(conversationId || sdkSessionId || ''),
|
|
625
|
+
entries: rows,
|
|
626
|
+
...(consumed ? {} : { consumed: false }),
|
|
627
|
+
})).catch((error) => {
|
|
628
|
+
dbg('consumed mark failed', rows.map((entry) => entry.id).join(','), error?.message || String(error));
|
|
629
|
+
});
|
|
630
|
+
for (const row of rows) consumedMarkChains.set(row.id, post);
|
|
631
|
+
post.then(() => {
|
|
632
|
+
for (const row of rows) if (consumedMarkChains.get(row.id) === post) consumedMarkChains.delete(row.id);
|
|
633
|
+
});
|
|
634
|
+
return post;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
/** Release a settled row — unless it was handed to the relay to fail. */
|
|
639
|
+
function releaseSettling(id) {
|
|
640
|
+
const key = String(id || '').trim();
|
|
641
|
+
if (!settleFailedTerminals.has(key)) settlingMessages.delete(key);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Heartbeat payload: rows whose terminal failure the relay must post. */
|
|
645
|
+
function getSettleFailed() {
|
|
646
|
+
return [...settleFailedTerminals.entries()].map(([id, terminalError]) => ({
|
|
647
|
+
id,
|
|
648
|
+
attemptId: settlingMessages.get(id)?.message?.attemptId || null,
|
|
649
|
+
terminalError,
|
|
650
|
+
}));
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function acknowledgeSettleFailed(ids = []) {
|
|
654
|
+
for (const id of ids) {
|
|
655
|
+
const key = String(id || '').trim();
|
|
656
|
+
if (!settleFailedTerminals.delete(key)) continue;
|
|
657
|
+
settlingMessages.delete(key);
|
|
658
|
+
}
|
|
490
659
|
}
|
|
491
660
|
|
|
492
661
|
function isTurnActive() {
|
|
@@ -524,6 +693,7 @@ export function createClaudeSessionRunner({
|
|
|
524
693
|
discarded: false,
|
|
525
694
|
registered: kind === 'delivered',
|
|
526
695
|
bufferedActions: [],
|
|
696
|
+
resultBuffered: false,
|
|
527
697
|
controlState: null,
|
|
528
698
|
finalized: false,
|
|
529
699
|
resolveDone: null,
|
|
@@ -547,6 +717,7 @@ export function createClaudeSessionRunner({
|
|
|
547
717
|
proc.continuationInitPending = false;
|
|
548
718
|
proc.notificationPendingAt = 0;
|
|
549
719
|
proc.taskSettledAt = 0;
|
|
720
|
+
proc.lastApiRetryAt = 0;
|
|
550
721
|
if (proc.initModel && !ctx.state.responseModel) ctx.state.responseModel = proc.initModel;
|
|
551
722
|
ctx.controlState = controlPoller?.start?.({
|
|
552
723
|
queueMessageId: ctx.message?.id || '',
|
|
@@ -580,13 +751,33 @@ export function createClaudeSessionRunner({
|
|
|
580
751
|
* back to back can replay out of order, and pairing by position alone would
|
|
581
752
|
* answer each with the other's turn.
|
|
582
753
|
*/
|
|
583
|
-
function attachDeliveredContext(index = 0) {
|
|
754
|
+
function attachDeliveredContext(index = 0, { provisional = false } = {}) {
|
|
584
755
|
const [entry] = proc.pendingDelivered.splice(index, 1);
|
|
585
756
|
entry.ctx.deliveredEntry = entry;
|
|
757
|
+
// A provisional (post-compaction) attach may still be unwound back onto
|
|
758
|
+
// the queue; its un-mark waits for the adoption to commit.
|
|
759
|
+
if (!provisional) unmarkConsumedOnOwnTurn(entry);
|
|
586
760
|
activateContext(entry.ctx);
|
|
587
761
|
return entry.ctx;
|
|
588
762
|
}
|
|
589
763
|
|
|
764
|
+
/**
|
|
765
|
+
* A steer marked consumed by the turn it was pushed into, now running as a
|
|
766
|
+
* turn of its own: from here it is an ordinary in-flight row, and a worker
|
|
767
|
+
* death mid-turn must not fail it with "check the reply above".
|
|
768
|
+
*/
|
|
769
|
+
function unmarkConsumedOnOwnTurn(entry) {
|
|
770
|
+
if (!entry?.consumedMarked) return;
|
|
771
|
+
entry.consumedMarked = false;
|
|
772
|
+
markConsumed(entry.ctx.message?.conversationId, [entry.ctx.message], { consumed: false });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** A provisional post-compaction adoption became final. */
|
|
776
|
+
function commitAdoption(ctx) {
|
|
777
|
+
ctx.adoptedFromCompaction = false;
|
|
778
|
+
unmarkConsumedOnOwnTurn(ctx.deliveredEntry);
|
|
779
|
+
}
|
|
780
|
+
|
|
590
781
|
/**
|
|
591
782
|
* Undo a post-compaction adoption: this turn was never the delivered
|
|
592
783
|
* message's, and nothing has published on it (the provisional flag is
|
|
@@ -619,6 +810,9 @@ export function createClaudeSessionRunner({
|
|
|
619
810
|
// not to the row: left set, `finalizeContext` would take its interrupted
|
|
620
811
|
// branch when the row finally runs for real and publish no response at all.
|
|
621
812
|
ctx.interrupted = false;
|
|
813
|
+
// Nothing will close this context as a stopped turn now, so a Stop guard
|
|
814
|
+
// armed by that abort opens its window here instead.
|
|
815
|
+
anchorStopFollowUpWindow();
|
|
622
816
|
controlPoller?.stop?.(ctx.controlState);
|
|
623
817
|
ctx.controlState = null;
|
|
624
818
|
const entry = ctx.deliveredEntry;
|
|
@@ -765,6 +959,22 @@ export function createClaudeSessionRunner({
|
|
|
765
959
|
if (absorbed >= 0) {
|
|
766
960
|
const outgoing = proc.activeCtx;
|
|
767
961
|
const restored = outgoing.adoptedFromCompaction;
|
|
962
|
+
// A continuation whose turn already ENDED — its result is buffered,
|
|
963
|
+
// only waiting on its row's registration — was not interrupted by
|
|
964
|
+
// this replay: the replay opens the delivered message's own turn.
|
|
965
|
+
// Handing off would discard the continuation and its buffered
|
|
966
|
+
// answer with it. Let the registration publish that answer on the
|
|
967
|
+
// continuation row first; the delivered message gets a fresh turn.
|
|
968
|
+
const finishedContinuation = !restored
|
|
969
|
+
&& outgoing.kind === 'continuation'
|
|
970
|
+
&& !outgoing.registered
|
|
971
|
+
&& !outgoing.discarded
|
|
972
|
+
&& outgoing.resultBuffered === true;
|
|
973
|
+
if (finishedContinuation) {
|
|
974
|
+
detachActiveContext(outgoing);
|
|
975
|
+
proc.handoffSettling = waitForContextRegistration(outgoing);
|
|
976
|
+
return attachDeliveredContext(absorbed);
|
|
977
|
+
}
|
|
768
978
|
if (restored) {
|
|
769
979
|
// The outgoing context was only PROVISIONALLY adopted and has
|
|
770
980
|
// published nothing. Settling it as an absorbed turn would tell
|
|
@@ -807,7 +1017,7 @@ export function createClaudeSessionRunner({
|
|
|
807
1017
|
// this prompt" — after this replay that premise is false, and putting
|
|
808
1018
|
// the row back would fail or re-run a message the CLI already took.
|
|
809
1019
|
if (signature !== null && signature === proc.activeCtx.deliveredEntry?.expectedText) {
|
|
810
|
-
proc.activeCtx
|
|
1020
|
+
commitAdoption(proc.activeCtx);
|
|
811
1021
|
return proc.activeCtx;
|
|
812
1022
|
}
|
|
813
1023
|
if (signature !== null && isTaskNotificationReplay(sdkMessage)) {
|
|
@@ -839,7 +1049,7 @@ export function createClaudeSessionRunner({
|
|
|
839
1049
|
// lifecycle timer a window to reap a context that is already settling.
|
|
840
1050
|
if (!sdkMessage?.parent_tool_use_id
|
|
841
1051
|
&& (type === 'assistant' || type === 'stream_event' || type === 'result')) {
|
|
842
|
-
proc.activeCtx
|
|
1052
|
+
commitAdoption(proc.activeCtx);
|
|
843
1053
|
}
|
|
844
1054
|
}
|
|
845
1055
|
return proc.activeCtx;
|
|
@@ -879,7 +1089,7 @@ export function createClaudeSessionRunner({
|
|
|
879
1089
|
&& Date.now() < proc.compactReplayUntil
|
|
880
1090
|
&& isCompactionReplayCandidate(sdkMessage)
|
|
881
1091
|
) {
|
|
882
|
-
const ctx = attachDeliveredContext();
|
|
1092
|
+
const ctx = attachDeliveredContext(0, { provisional: true });
|
|
883
1093
|
// Provisional: the summary row alone cannot rule out a continuation
|
|
884
1094
|
// whose notification replays after it (see the hand-back above).
|
|
885
1095
|
ctx.adoptedFromCompaction = true;
|
|
@@ -894,7 +1104,12 @@ export function createClaudeSessionRunner({
|
|
|
894
1104
|
|
|
895
1105
|
if (type === 'assistant' || type === 'stream_event' || type === 'user') {
|
|
896
1106
|
if (proc.lastBoundary !== 'self-opened') {
|
|
897
|
-
|
|
1107
|
+
// A background subagent's frames arrive at top level carrying
|
|
1108
|
+
// `parent_tool_use_id` (same marker the adoption commit checks): they
|
|
1109
|
+
// are a LIVE task's chatter, not the delivered message's turn, and
|
|
1110
|
+
// attaching on them would both publish the wrong output and splice the
|
|
1111
|
+
// entry out of the fold reaper's reach.
|
|
1112
|
+
if (proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) return attachDeliveredContext();
|
|
898
1113
|
// Between-turn chatter — a background subagent's stream, a stray
|
|
899
1114
|
// top-level tool_result — is not a turn of its own. Only traffic that
|
|
900
1115
|
// follows a self-opened boundary (the CLI's task-notification replay)
|
|
@@ -911,7 +1126,9 @@ export function createClaudeSessionRunner({
|
|
|
911
1126
|
proc.lastBoundary = null;
|
|
912
1127
|
return null;
|
|
913
1128
|
}
|
|
914
|
-
if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length
|
|
1129
|
+
if (proc.lastBoundary !== 'self-opened' && proc.pendingDelivered.length && !sdkMessage?.parent_tool_use_id) {
|
|
1130
|
+
return attachDeliveredContext();
|
|
1131
|
+
}
|
|
915
1132
|
if (proc.lastBoundary === 'self-opened') return openContinuationContext();
|
|
916
1133
|
return null;
|
|
917
1134
|
}
|
|
@@ -932,6 +1149,9 @@ export function createClaudeSessionRunner({
|
|
|
932
1149
|
}
|
|
933
1150
|
if (!ctx.registered) {
|
|
934
1151
|
ctx.bufferedActions.push(action);
|
|
1152
|
+
// Kept as a flag, not read off the buffer: the registration drain
|
|
1153
|
+
// splices batches out of it while they publish.
|
|
1154
|
+
if (action.channel === 'result') ctx.resultBuffered = true;
|
|
935
1155
|
return;
|
|
936
1156
|
}
|
|
937
1157
|
await publisher.dispatchAction(ctx.message, action, ctx.state);
|
|
@@ -946,6 +1166,44 @@ export function createClaudeSessionRunner({
|
|
|
946
1166
|
// and model fallbacks must survive that.
|
|
947
1167
|
const turnRef = proc?.turn || null;
|
|
948
1168
|
const procModel = proc?.model || null;
|
|
1169
|
+
// Steers pushed into this turn and still unsettled: the CLI has their
|
|
1170
|
+
// prompts, so the relay marks them consumed with this turn's commit and
|
|
1171
|
+
// never re-runs them even if this worker dies before settling them.
|
|
1172
|
+
// Only while this turn is still the active one: a continuation finalizing
|
|
1173
|
+
// late from its registration drain (see the finished-continuation path in
|
|
1174
|
+
// resolveContext) no longer owns the steers pending behind a newer turn.
|
|
1175
|
+
const consumedEntries = (proc?.activeCtx === ctx ? proc.pendingDelivered : []).filter((entry) => entry.steered);
|
|
1176
|
+
for (const entry of consumedEntries) entry.consumedMarked = true;
|
|
1177
|
+
const consumedSteerIds = consumedEntries
|
|
1178
|
+
.map((entry) => ({ id: String(entry.ctx.message?.id || '').trim(), attemptId: entry.ctx.message?.attemptId || null }))
|
|
1179
|
+
.filter((entry) => entry.id);
|
|
1180
|
+
|
|
1181
|
+
if (ctx.interrupted || proc?.aborted) {
|
|
1182
|
+
// A stopped turn closes FIRST and publishes after, off the stream loop:
|
|
1183
|
+
// the CLI opens its follow-up turn for any pushed steers right after
|
|
1184
|
+
// the interrupt, and the Stop guard can only kill that turn at its init
|
|
1185
|
+
// if the loop reaches the init promptly — the usage reads (an
|
|
1186
|
+
// experimental control request with a 10 s timeout) and relay POSTs
|
|
1187
|
+
// would otherwise sit in front of it. Same shape as the per-turn
|
|
1188
|
+
// runner's abort path otherwise: surface what streamed, let the
|
|
1189
|
+
// server-side abort control own the queue row's fate.
|
|
1190
|
+
markConsumed(ctx.message?.conversationId, consumedSteerIds);
|
|
1191
|
+
const publishChain = (async () => {
|
|
1192
|
+
await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
|
|
1193
|
+
const [contextUsage, planUsage] = await Promise.all([
|
|
1194
|
+
readContextUsageImpl(turnRef, dbg),
|
|
1195
|
+
readPlanUsageImpl(turnRef, dbg),
|
|
1196
|
+
]);
|
|
1197
|
+
ctx.state.contextUsage = contextUsage;
|
|
1198
|
+
ctx.state.planUsage = planUsage;
|
|
1199
|
+
await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
|
|
1200
|
+
await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
|
|
1201
|
+
})().catch((error) => dbg('stopped-turn publish failed', error?.message || String(error)));
|
|
1202
|
+
lingerStoppedRow(ctx, publishChain);
|
|
1203
|
+
closeContext(ctx, true);
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
949
1207
|
// The control transport is alive for the process's whole life now, but
|
|
950
1208
|
// the snapshot still belongs to this turn's finalize so the composer
|
|
951
1209
|
// indicator updates with each reply.
|
|
@@ -959,18 +1217,15 @@ export function createClaudeSessionRunner({
|
|
|
959
1217
|
await publisher.publishContextUsage({ message: ctx.message, state: ctx.state, model: procModel, sdkSessionId });
|
|
960
1218
|
await publisher.publishPlanUsage({ message: ctx.message, state: ctx.state, sdkSessionId });
|
|
961
1219
|
|
|
962
|
-
if (ctx.
|
|
963
|
-
|
|
964
|
-
// let the server-side abort control own the queue row's fate.
|
|
965
|
-
await publisher.publishFinalStream(ctx.message, ctx.state.lastStreamedText);
|
|
966
|
-
} else if (ctx.state.result?.isError) {
|
|
967
|
-
await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel });
|
|
1220
|
+
if (ctx.state.result?.isError) {
|
|
1221
|
+
await publisher.publishErrorResult({ message: ctx.message, state: ctx.state, responseModel, consumedSteerIds });
|
|
968
1222
|
} else {
|
|
969
1223
|
await publisher.publishCompletedTurn({
|
|
970
1224
|
message: ctx.message,
|
|
971
1225
|
state: ctx.state,
|
|
972
1226
|
responseModel,
|
|
973
1227
|
planBoardPosted: ctx.planBoardPosted,
|
|
1228
|
+
consumedSteerIds,
|
|
974
1229
|
});
|
|
975
1230
|
}
|
|
976
1231
|
closeContext(ctx, true);
|
|
@@ -1019,13 +1274,19 @@ export function createClaudeSessionRunner({
|
|
|
1019
1274
|
if (proc && proc.activeCtx === ctx) {
|
|
1020
1275
|
proc.activeCtx = null;
|
|
1021
1276
|
proc.lastBoundary = null;
|
|
1277
|
+
proc.settleSeqAtTurnEnd = proc.taskSettleSeq;
|
|
1022
1278
|
}
|
|
1023
1279
|
}
|
|
1024
1280
|
|
|
1025
1281
|
function closeContext(ctx, handled) {
|
|
1282
|
+
// The Stop guard's window runs from the stopped turn's END: the CLI opens
|
|
1283
|
+
// its follow-up right after the interrupted result, and anything slow in
|
|
1284
|
+
// between must not eat the window.
|
|
1285
|
+
if (ctx.interrupted) anchorStopFollowUpWindow();
|
|
1026
1286
|
detachActiveContext(ctx);
|
|
1027
1287
|
ctx.resolveDone?.(handled);
|
|
1028
1288
|
evaluateLifecycle();
|
|
1289
|
+
syncDeliveryReadiness();
|
|
1029
1290
|
}
|
|
1030
1291
|
|
|
1031
1292
|
/**
|
|
@@ -1033,9 +1294,21 @@ export function createClaudeSessionRunner({
|
|
|
1033
1294
|
* active context NOW. The caller attaches the delivered context
|
|
1034
1295
|
* synchronously; onSdkMessage awaits the returned promise before
|
|
1035
1296
|
* dispatching the new context's actions so the two rows' publishes stay
|
|
1036
|
-
* ordered.
|
|
1297
|
+
* ordered. That promise resolves after the settle's FIRST publish attempt,
|
|
1298
|
+
* not the whole retry ladder: onSdkMessage runs inside the SDK stream loop,
|
|
1299
|
+
* and waiting out ~30 s of retries there froze the new turn's stream. A
|
|
1300
|
+
* failed first attempt keeps retrying in the background with the row still
|
|
1301
|
+
* owned (settling).
|
|
1037
1302
|
*/
|
|
1038
1303
|
function handoffAbsorbedTurn(ctx) {
|
|
1304
|
+
// Claimed as settling BEFORE the detach: from the detach on nothing else
|
|
1305
|
+
// reports this row, and a heartbeat landing in between would recover it
|
|
1306
|
+
// and re-run a prompt the CLI already consumed.
|
|
1307
|
+
const delivered = ctx.kind === 'delivered' && Boolean(ctx.message?.id);
|
|
1308
|
+
if (delivered) {
|
|
1309
|
+
claimSettling(ctx.message, 'handoff');
|
|
1310
|
+
markConsumed(ctx.message.conversationId, [ctx.message]);
|
|
1311
|
+
}
|
|
1039
1312
|
detachActiveContext(ctx);
|
|
1040
1313
|
if (ctx.kind === 'continuation' && !ctx.registered) {
|
|
1041
1314
|
// Registration (or its buffered-action drain) is still in flight:
|
|
@@ -1049,12 +1322,18 @@ export function createClaudeSessionRunner({
|
|
|
1049
1322
|
// so reading it later would see the absorbed turn's text too.
|
|
1050
1323
|
const fallbackText = String(ctx.state.lastStreamedText || ctx.normalizer.finalStreamText() || '').trim();
|
|
1051
1324
|
dbg('active turn absorbed a delivered message; handing off', ctx.message?.id || '(unregistered continuation)');
|
|
1052
|
-
|
|
1325
|
+
let releaseOrder = () => {};
|
|
1326
|
+
const firstAttempt = new Promise((resolve) => { releaseOrder = resolve; });
|
|
1327
|
+
settleHandedOffContext(ctx, fallbackText, { onFirstAttempt: () => releaseOrder() }).catch((error) => {
|
|
1053
1328
|
dbg('absorbed-turn handoff settle failed', error?.message || String(error));
|
|
1329
|
+
}).finally(() => {
|
|
1330
|
+
releaseOrder();
|
|
1331
|
+
if (delivered) releaseSettling(ctx.message.id);
|
|
1054
1332
|
});
|
|
1333
|
+
return firstAttempt;
|
|
1055
1334
|
}
|
|
1056
1335
|
|
|
1057
|
-
async function settleHandedOffContext(ctx, fallbackText) {
|
|
1336
|
+
async function settleHandedOffContext(ctx, fallbackText, { onFirstAttempt = () => {} } = {}) {
|
|
1058
1337
|
if (ctx.finalized) return;
|
|
1059
1338
|
ctx.finalized = true;
|
|
1060
1339
|
controlPoller?.stop?.(ctx.controlState);
|
|
@@ -1075,11 +1354,14 @@ export function createClaudeSessionRunner({
|
|
|
1075
1354
|
// NEVER requeue an absorbed delivered row: the CLI already consumed
|
|
1076
1355
|
// the prompt into the running turn — re-delivery would execute it a
|
|
1077
1356
|
// second time (and the requeue route marks the healthy worker errored).
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1357
|
+
// `absorbed: true` becomes `kind='absorbed'` on the assistant message,
|
|
1358
|
+
// and the transcript renders the reply as continuing through the next
|
|
1359
|
+
// (steered) user message — so the text carries no prose note when
|
|
1360
|
+
// something streamed; the empty case keeps one so an exported or
|
|
1361
|
+
// marker-blind view still explains the stub.
|
|
1362
|
+
const text = fallbackText || '_(Answered together with the next message.)_';
|
|
1081
1363
|
await publisher.publishFinalStream(ctx.message, text);
|
|
1082
|
-
await
|
|
1364
|
+
await publishSettleMarker(ctx.message, { text, model, kind: 'absorbed', variant: 'handoff', onFirstAttempt });
|
|
1083
1365
|
} else if (fallbackText) {
|
|
1084
1366
|
await publisher.publishFinalStream(ctx.message, fallbackText);
|
|
1085
1367
|
await publisher.publishResponse(ctx.message, { text: fallbackText, model });
|
|
@@ -1092,8 +1374,83 @@ export function createClaudeSessionRunner({
|
|
|
1092
1374
|
closeContext(ctx, true);
|
|
1093
1375
|
}
|
|
1094
1376
|
|
|
1095
|
-
|
|
1377
|
+
function anchorStopFollowUpWindow() {
|
|
1378
|
+
if (!proc?.stopFollowUp || proc.stopFollowUp.anchored) return;
|
|
1379
|
+
proc.stopFollowUp.anchored = true;
|
|
1380
|
+
proc.stopFollowUp.until = Date.now() + stopFollowUpWindowMs;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
/**
|
|
1384
|
+
* The stopped turn's row until the Stop has fully landed: the context closes
|
|
1385
|
+
* on the interrupted result, but the row stays `processing` until the
|
|
1386
|
+
* control poller's abort ack reaches the relay — sent only after the
|
|
1387
|
+
* interrupt call resolves, which races that result. Reported to the
|
|
1388
|
+
* heartbeat meanwhile, or an owner-heartbeat recovery in the gap fails a
|
|
1389
|
+
* streamed row with the wrong wording, or requeues (re-runs) one that
|
|
1390
|
+
* produced nothing yet.
|
|
1391
|
+
*/
|
|
1392
|
+
const lingeringStopped = new Map();
|
|
1393
|
+
|
|
1394
|
+
function lingerStoppedRow(ctx, publishChain) {
|
|
1395
|
+
const id = String(ctx.message?.id || '').trim();
|
|
1396
|
+
if (!id || ctx.kind !== 'delivered') return;
|
|
1397
|
+
const token = {};
|
|
1398
|
+
lingeringStopped.set(id, { message: ctx.message, token });
|
|
1399
|
+
const release = () => {
|
|
1400
|
+
if (lingeringStopped.get(id)?.token === token) lingeringStopped.delete(id);
|
|
1401
|
+
};
|
|
1402
|
+
const cap = setTimeout(release, stoppedRowLingerMaxMs);
|
|
1403
|
+
cap.unref?.();
|
|
1404
|
+
Promise.allSettled([publishChain, ctx.interruptDone || Promise.resolve()])
|
|
1405
|
+
// The ack POST starts right after the interrupt resolves; give it time.
|
|
1406
|
+
.then(() => new Promise((resolve) => {
|
|
1407
|
+
const timer = setTimeout(resolve, stoppedRowLingerMs);
|
|
1408
|
+
timer.unref?.();
|
|
1409
|
+
}))
|
|
1410
|
+
.then(() => {
|
|
1411
|
+
clearTimeout(cap);
|
|
1412
|
+
release();
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
function interruptActiveTurn(ctx) {
|
|
1417
|
+
ctx.interruptDone = runInterrupt(ctx);
|
|
1418
|
+
return ctx.interruptDone;
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
async function runInterrupt(ctx) {
|
|
1096
1422
|
ctx.interrupted = true;
|
|
1423
|
+
// Steers already pushed into the turn being stopped went with it: if the
|
|
1424
|
+
// CLI does not open a turn of its own for one, it settles as 'stopped',
|
|
1425
|
+
// not as handled by the reply the Stop cut short. Still never requeued —
|
|
1426
|
+
// the CLI saw the prompt (2026-09-20 decision).
|
|
1427
|
+
let stoppedSteers = 0;
|
|
1428
|
+
for (const entry of proc?.pendingDelivered || []) {
|
|
1429
|
+
if (!entry.steered) continue;
|
|
1430
|
+
entry.stoppedWithTurn = true;
|
|
1431
|
+
stoppedSteers += 1;
|
|
1432
|
+
}
|
|
1433
|
+
if (proc && stoppedSteers) {
|
|
1434
|
+
// The CLI re-opens a turn for the queued steers right after the
|
|
1435
|
+
// interrupt — re-running the stopped tool call — so the Stop would not
|
|
1436
|
+
// stick. Arm the guard that interrupts those follow-up turns (see
|
|
1437
|
+
// maybeSuppressStopFollowUp). The window opens when the stopped turn
|
|
1438
|
+
// closes (closeContext anchors it). A task that settled before the Stop
|
|
1439
|
+
// may have its continuation queued in the CLI already: then the next
|
|
1440
|
+
// turn is ambiguous and the guard only stands down (it is never killed).
|
|
1441
|
+
proc.stopFollowUp = {
|
|
1442
|
+
settleSeq: proc.taskSettleSeq,
|
|
1443
|
+
until: Number.POSITIVE_INFINITY,
|
|
1444
|
+
anchored: false,
|
|
1445
|
+
remaining: stoppedSteers,
|
|
1446
|
+
standDown: isContinuationImminent(),
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
// A late abort (the control poller does not re-check after its GET) can
|
|
1450
|
+
// land on a context that has already closed: nothing will anchor the
|
|
1451
|
+
// window at close, so it opens now.
|
|
1452
|
+
if (ctx.finalized || ctx !== proc?.activeCtx) anchorStopFollowUpWindow();
|
|
1453
|
+
syncDeliveryReadiness();
|
|
1097
1454
|
try {
|
|
1098
1455
|
await proc.turn.interrupt();
|
|
1099
1456
|
} catch (error) {
|
|
@@ -1133,7 +1490,8 @@ export function createClaudeSessionRunner({
|
|
|
1133
1490
|
// the next publish. Bash & co. get no model at all.
|
|
1134
1491
|
const agentLike = AGENT_TASK_TYPES.has(String(task.taskType || ''));
|
|
1135
1492
|
const pinnedModel = (task.toolUseId && processRef.subagentSpawns.get(task.toolUseId)) || '';
|
|
1136
|
-
const
|
|
1493
|
+
const activity = (task.toolUseId && processRef.subagentActivity.get(task.toolUseId)) || {};
|
|
1494
|
+
const model = agentLike ? (activity.model || pinnedModel || processRef.initModel || null) : null;
|
|
1137
1495
|
return {
|
|
1138
1496
|
taskId,
|
|
1139
1497
|
taskType: task.taskType,
|
|
@@ -1141,11 +1499,12 @@ export function createClaudeSessionRunner({
|
|
|
1141
1499
|
startedAt: task.startedAt || null,
|
|
1142
1500
|
summary: task.summary || null,
|
|
1143
1501
|
lastToolName: task.lastToolName || null,
|
|
1502
|
+
lastToolCall: (agentLike && activity.lastToolCall) || null,
|
|
1144
1503
|
totalTokens: task.totalTokens ?? null,
|
|
1145
1504
|
toolUseId: task.toolUseId || null,
|
|
1146
1505
|
subagentType: task.subagentType || null,
|
|
1147
1506
|
model,
|
|
1148
|
-
modelInherited: Boolean(model && !pinnedModel),
|
|
1507
|
+
modelInherited: Boolean(model && !pinnedModel && !activity.model),
|
|
1149
1508
|
// The workflow digest (poller below) rides the same row; the relay
|
|
1150
1509
|
// sanitizer whitelists and re-clamps it before storing.
|
|
1151
1510
|
...(task.workflowProgress ? { workflowProgress: task.workflowProgress } : {}),
|
|
@@ -1450,6 +1809,43 @@ export function createClaudeSessionRunner({
|
|
|
1450
1809
|
}
|
|
1451
1810
|
}
|
|
1452
1811
|
|
|
1812
|
+
// A subagent's own assistant frames (parent_tool_use_id = the spawning
|
|
1813
|
+
// tool_use id, the same key as task.toolUseId) carry what the task events
|
|
1814
|
+
// never do: the API model that actually answers (a spawn pin is only an
|
|
1815
|
+
// alias like "opus") and the full input of the tool it is calling now.
|
|
1816
|
+
// Single-lined and capped: the panel crops it to its width.
|
|
1817
|
+
function recordSubagentActivity(sdkMessage) {
|
|
1818
|
+
const parentId = String(sdkMessage?.parent_tool_use_id || '').trim();
|
|
1819
|
+
if (!parentId) return;
|
|
1820
|
+
const entry = proc.subagentActivity.get(parentId) || {};
|
|
1821
|
+
const before = `${entry.model || ''}\n${entry.lastToolCall || ''}`;
|
|
1822
|
+
const model = String(sdkMessage?.message?.model || '').trim();
|
|
1823
|
+
if (model && model !== '<synthetic>') entry.model = model;
|
|
1824
|
+
const blocks = Array.isArray(sdkMessage?.message?.content) ? sdkMessage.message.content : [];
|
|
1825
|
+
for (const block of blocks) {
|
|
1826
|
+
if (block?.type === 'tool_use') {
|
|
1827
|
+
const toolName = displayToolName(block.name) || 'tool';
|
|
1828
|
+
const summary = summarizeToolInput(toolName, block.input).replace(/\s+/g, ' ').trim();
|
|
1829
|
+
entry.lastToolCall = (summary ? `Tool (${toolName}): ${summary}` : `Tool (${toolName})`)
|
|
1830
|
+
.slice(0, MAX_TASK_TOOL_CALL_CHARS);
|
|
1831
|
+
} else if (block?.type === 'text' && String(block.text || '').trim()) {
|
|
1832
|
+
// Writing prose means the last tool call is over; the panel falls
|
|
1833
|
+
// back to the SDK's progress summary instead of a stale command.
|
|
1834
|
+
entry.lastToolCall = null;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
proc.subagentActivity.delete(parentId);
|
|
1838
|
+
proc.subagentActivity.set(parentId, entry);
|
|
1839
|
+
while (proc.subagentActivity.size > 100) {
|
|
1840
|
+
proc.subagentActivity.delete(proc.subagentActivity.keys().next().value);
|
|
1841
|
+
}
|
|
1842
|
+
// Foreground subagents stream the same frames; only a change on a live
|
|
1843
|
+
// background task is worth a publish (and a panel re-render).
|
|
1844
|
+
if (before === `${entry.model || ''}\n${entry.lastToolCall || ''}`) return;
|
|
1845
|
+
const ownsLiveTask = [...proc.liveTasks.values()].some((task) => task.toolUseId === parentId);
|
|
1846
|
+
if (ownsLiveTask) publishBackgroundTasks({ throttled: true });
|
|
1847
|
+
}
|
|
1848
|
+
|
|
1453
1849
|
// Between-turn notices carried into the next activated context, as either a
|
|
1454
1850
|
// plain line or a prepared publisher action (a compaction boundary has to
|
|
1455
1851
|
// keep the structured metadata the transcript's break row is built from —
|
|
@@ -1518,6 +1914,7 @@ export function createClaudeSessionRunner({
|
|
|
1518
1914
|
const type = String(sdkMessage?.type || '');
|
|
1519
1915
|
if (type === 'assistant') {
|
|
1520
1916
|
recordSubagentSpawns(sdkMessage);
|
|
1917
|
+
recordSubagentActivity(sdkMessage);
|
|
1521
1918
|
return;
|
|
1522
1919
|
}
|
|
1523
1920
|
if (type !== 'system') return;
|
|
@@ -1550,7 +1947,20 @@ export function createClaudeSessionRunner({
|
|
|
1550
1947
|
// of the async stream consumer), so it is correctly excluded here.
|
|
1551
1948
|
//
|
|
1552
1949
|
if (!proc.activeCtx && !proc.pendingDelivered.length) {
|
|
1553
|
-
|
|
1950
|
+
// Not a re-init a settings change caused: setModel /
|
|
1951
|
+
// setPermissionMode / applyFlagSettings make the CLI re-init (live
|
|
1952
|
+
// probe, CLI 2.1.281), and adaptProcess issues them BEFORE the
|
|
1953
|
+
// delivery they belong to is registered and pushed. Reading that
|
|
1954
|
+
// init as self-opened routed the whole answer to a continuation row
|
|
1955
|
+
// and stranded the delivered message (conv dad758cd, 2026-09-25,
|
|
1956
|
+
// right after a /model switch). Inside that window an init beyond the
|
|
1957
|
+
// ones owed is a continuation only if a task settled since the last
|
|
1958
|
+
// turn ended — a stray extra re-init must not strand the delivery the
|
|
1959
|
+
// same way.
|
|
1960
|
+
if (settingsReinitsOwed > 0) settingsReinitsOwed -= 1;
|
|
1961
|
+
else if (!settingsWindowOpen || proc.taskSettleSeq !== proc.settleSeqAtTurnEnd) {
|
|
1962
|
+
proc.lastBoundary = 'self-opened';
|
|
1963
|
+
}
|
|
1554
1964
|
} else if (!proc.activeCtx && !firstInit) {
|
|
1555
1965
|
// A LATER init, with a delivered row waiting, is the CLI opening a turn
|
|
1556
1966
|
// of its own — the live-verified continuation shape is a bare init with
|
|
@@ -1667,6 +2077,17 @@ export function createClaudeSessionRunner({
|
|
|
1667
2077
|
if (typeof last === 'string' && last.startsWith('Anthropic API')) proc.pendingActivities.pop();
|
|
1668
2078
|
carryPendingActivity(notice);
|
|
1669
2079
|
}
|
|
2080
|
+
// A retry with no active turn means the CLI is mid-request for a turn
|
|
2081
|
+
// it is about to open — likely for a pending delivered entry. Hold the
|
|
2082
|
+
// fold clock so the entry is not settled as "merged" out from under
|
|
2083
|
+
// the turn that will answer it. The hold must be a freshness window,
|
|
2084
|
+
// not just a clock reset: a 529 backoff routinely exceeds the 2s fold
|
|
2085
|
+
// grace, so a reset alone would re-arm and fire between notices.
|
|
2086
|
+
proc.lastApiRetryAt = Date.now();
|
|
2087
|
+
for (const entry of proc.pendingDelivered) {
|
|
2088
|
+
entry.foldSince = 0;
|
|
2089
|
+
entry.unattachedSince = 0;
|
|
2090
|
+
}
|
|
1670
2091
|
}
|
|
1671
2092
|
return;
|
|
1672
2093
|
}
|
|
@@ -1705,6 +2126,7 @@ export function createClaudeSessionRunner({
|
|
|
1705
2126
|
for (const taskId of previous.keys()) {
|
|
1706
2127
|
if (!proc.liveTasks.has(taskId)) {
|
|
1707
2128
|
proc.taskSettledAt = Date.now();
|
|
2129
|
+
proc.taskSettleSeq += 1;
|
|
1708
2130
|
break;
|
|
1709
2131
|
}
|
|
1710
2132
|
}
|
|
@@ -1765,6 +2187,7 @@ export function createClaudeSessionRunner({
|
|
|
1765
2187
|
// continuation is coming, so nothing should pin the process for one.
|
|
1766
2188
|
if (taskId && proc.knownTasks.has(taskId) && sdkMessage.skip_transcript !== true) {
|
|
1767
2189
|
proc.notificationPendingAt = Date.now();
|
|
2190
|
+
proc.taskSettleSeq += 1;
|
|
1768
2191
|
}
|
|
1769
2192
|
if (!activeContextPublishes()) {
|
|
1770
2193
|
const status = String(sdkMessage.status || '').trim() || 'unknown';
|
|
@@ -1778,6 +2201,82 @@ export function createClaudeSessionRunner({
|
|
|
1778
2201
|
}
|
|
1779
2202
|
}
|
|
1780
2203
|
|
|
2204
|
+
/**
|
|
2205
|
+
* A turn the CLI opens by itself right now would follow the Stop: the
|
|
2206
|
+
* guard is live, no turn is active, and everything still pending was
|
|
2207
|
+
* stopped with the turn (a message delivered after the Stop is held, never
|
|
2208
|
+
* pushed — and pushing one disarms the guard).
|
|
2209
|
+
*/
|
|
2210
|
+
function isStopFollowUpPosition(now = Date.now()) {
|
|
2211
|
+
const guard = proc?.stopFollowUp;
|
|
2212
|
+
if (!guard || guard.remaining <= 0 || now >= guard.until) return false;
|
|
2213
|
+
if (proc.activeCtx) return false;
|
|
2214
|
+
return proc.pendingDelivered.length > 0 && proc.pendingDelivered.every((entry) => entry.stoppedWithTurn);
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* ...and that turn can only be the follow-up for the stopped steers: no
|
|
2219
|
+
* background task has settled since the Stop, and none was about to
|
|
2220
|
+
* continue when it happened (either one's continuation opens the same
|
|
2221
|
+
* bare-init way, and must run).
|
|
2222
|
+
*/
|
|
2223
|
+
function isStopFollowUpArmed(now = Date.now()) {
|
|
2224
|
+
if (!isStopFollowUpPosition(now)) return false;
|
|
2225
|
+
const guard = proc.stopFollowUp;
|
|
2226
|
+
// A counter, not the settle timestamps: a settle in the Stop's own
|
|
2227
|
+
// millisecond must still count as after it.
|
|
2228
|
+
return !guard.standDown && proc.taskSettleSeq === guard.settleSeq;
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
function isStopFollowUpPending(now = Date.now()) {
|
|
2232
|
+
return Boolean(proc?.suppressingTurn) || isStopFollowUpArmed(now);
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
/**
|
|
2236
|
+
* Kill the CLI's follow-up turn for steers the user stopped. Without this
|
|
2237
|
+
* the Stop does not stick: the follow-up re-runs the interrupted tool call
|
|
2238
|
+
* and answers the steers on a phantom continuation. Interrupting it at its
|
|
2239
|
+
* init ends it with an error result and no model output (SDK probe
|
|
2240
|
+
* 2026-09-24). Everything that turn emits is swallowed — its error result
|
|
2241
|
+
* must not settle a stopped steer as a failed turn — except process-level
|
|
2242
|
+
* frames (task bookkeeping, background subagents' streams). Returns
|
|
2243
|
+
* 'swallow', 'observe-only', 'continuation' (the guard stood down: the turn
|
|
2244
|
+
* is a task's continuation and must open as one rather than attach to a
|
|
2245
|
+
* stopped steer's row), or null for a message to handle normally.
|
|
2246
|
+
*/
|
|
2247
|
+
function maybeSuppressStopFollowUp(sdkMessage) {
|
|
2248
|
+
const type = String(sdkMessage?.type || '');
|
|
2249
|
+
const subtype = String(sdkMessage?.subtype || '');
|
|
2250
|
+
if (proc.suppressingTurn) {
|
|
2251
|
+
// A background subagent's frames belong to a live task, not the turn.
|
|
2252
|
+
if (sdkMessage?.parent_tool_use_id) return 'observe-only';
|
|
2253
|
+
if (type === 'result') {
|
|
2254
|
+
proc.suppressingTurn = false;
|
|
2255
|
+
// Several stopped steers may come back as several turns: the next
|
|
2256
|
+
// one gets its own window from this one's end.
|
|
2257
|
+
if (proc.stopFollowUp) proc.stopFollowUp.until = Date.now() + stopFollowUpWindowMs;
|
|
2258
|
+
dbg('stopped-steer follow-up turn ended');
|
|
2259
|
+
return 'swallow';
|
|
2260
|
+
}
|
|
2261
|
+
return type === 'system' && subtype !== 'init' ? 'observe-only' : 'swallow';
|
|
2262
|
+
}
|
|
2263
|
+
if (type !== 'system' || subtype !== 'init' || !isStopFollowUpPosition()) return null;
|
|
2264
|
+
if (!isStopFollowUpArmed()) {
|
|
2265
|
+
// Stood down for a task continuation: spent, and the turn is the task's.
|
|
2266
|
+
proc.stopFollowUp = null;
|
|
2267
|
+
dbg('stop guard stood down for a task continuation');
|
|
2268
|
+
return 'continuation';
|
|
2269
|
+
}
|
|
2270
|
+
proc.suppressingTurn = true;
|
|
2271
|
+
proc.stopFollowUp.remaining -= 1;
|
|
2272
|
+
dbg('interrupting the CLI follow-up turn for steers the user stopped');
|
|
2273
|
+
const turnRef = proc.turn;
|
|
2274
|
+
Promise.resolve()
|
|
2275
|
+
.then(() => turnRef?.interrupt?.())
|
|
2276
|
+
.catch((error) => dbg('follow-up interrupt failed', error?.message || String(error)));
|
|
2277
|
+
return 'swallow';
|
|
2278
|
+
}
|
|
2279
|
+
|
|
1781
2280
|
async function onSdkMessage(processRef, sdkMessage) {
|
|
1782
2281
|
// A superseded process (push-race respawn, mode-recycle timeout) may still
|
|
1783
2282
|
// be draining its stream; its late messages must not mutate the state of
|
|
@@ -1785,7 +2284,13 @@ export function createClaudeSessionRunner({
|
|
|
1785
2284
|
// cleanupProcess when the old stream ends.
|
|
1786
2285
|
if (!proc || proc !== processRef) return;
|
|
1787
2286
|
proc.lastEventAt = Date.now();
|
|
2287
|
+
const suppressed = maybeSuppressStopFollowUp(sdkMessage);
|
|
2288
|
+
if (suppressed === 'swallow') return;
|
|
1788
2289
|
observeProcessLevel(sdkMessage);
|
|
2290
|
+
if (suppressed === 'observe-only') return;
|
|
2291
|
+
// Its traffic opens a continuation of its own instead of attaching to the
|
|
2292
|
+
// stopped steer waiting in pendingDelivered (which still settles stopped).
|
|
2293
|
+
if (suppressed === 'continuation') proc.lastBoundary = 'self-opened';
|
|
1789
2294
|
const ctx = resolveContext(sdkMessage);
|
|
1790
2295
|
if (proc.handoffSettling) {
|
|
1791
2296
|
// An absorbed-steering handoff just settled the previous context;
|
|
@@ -1816,6 +2321,17 @@ export function createClaudeSessionRunner({
|
|
|
1816
2321
|
return fresh(proc.notificationPendingAt) || fresh(proc.taskSettledAt);
|
|
1817
2322
|
}
|
|
1818
2323
|
|
|
2324
|
+
/**
|
|
2325
|
+
* The CLI recently reported an API retry with no turn open: it is
|
|
2326
|
+
* mid-request for a turn it has not opened yet (retry backoffs run tens of
|
|
2327
|
+
* seconds), so the fold/stale reapers must not settle a pending delivered
|
|
2328
|
+
* entry out from under it. Cleared when a turn opens (activateContext).
|
|
2329
|
+
*/
|
|
2330
|
+
function isApiRetryPending() {
|
|
2331
|
+
if (!proc) return false;
|
|
2332
|
+
return Boolean(proc.lastApiRetryAt) && Date.now() - proc.lastApiRetryAt < notificationGraceMs;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
1819
2335
|
function hasLiveWork() {
|
|
1820
2336
|
if (!proc) return false;
|
|
1821
2337
|
return Boolean(
|
|
@@ -1867,14 +2383,188 @@ export function createClaudeSessionRunner({
|
|
|
1867
2383
|
restoreAdoptedContext(ctx);
|
|
1868
2384
|
}
|
|
1869
2385
|
|
|
2386
|
+
/**
|
|
2387
|
+
* Settle steered entries the CLI folded into a turn without replaying them.
|
|
2388
|
+
* A mid-turn push is folded into the running turn and answered by that turn's
|
|
2389
|
+
* single result — the steered entry never gets its own replay, so
|
|
2390
|
+
* resolveContext's absorbed-steering handoff never fires and the entry would
|
|
2391
|
+
* otherwise sit until the 5-minute watchdog fails it with a bogus "resend".
|
|
2392
|
+
* Once nothing turn-shaped could still attach the entry (no active turn, no
|
|
2393
|
+
* control round-trip, no compaction, no imminent task continuation — but
|
|
2394
|
+
* deliberately NOT "no live background tasks", see the gate comment) for the
|
|
2395
|
+
* short fold grace, settle it as merged into the reply it was folded into.
|
|
2396
|
+
*/
|
|
2397
|
+
function reapFoldedSteeredEntries(now) {
|
|
2398
|
+
if (!proc || !proc.pendingDelivered.length) return;
|
|
2399
|
+
if (!(steeredFoldGraceMs > 0)) return;
|
|
2400
|
+
if (!proc.sawInit) return;
|
|
2401
|
+
// Live background tasks deliberately do NOT hold the fold clock: a task
|
|
2402
|
+
// can run for an hour and the CLI opens turns for pushed messages
|
|
2403
|
+
// regardless, so a steered entry folded into an already-finished turn
|
|
2404
|
+
// would otherwise sit `processing` — steer slot occupied, worker.ready
|
|
2405
|
+
// suppressed, every later message in the conversation stuck behind it —
|
|
2406
|
+
// until the task exits (live 2026-09-20: a hung 35-minute Bash task
|
|
2407
|
+
// wedged exactly this way). If the CLI is instead going to open a real
|
|
2408
|
+
// turn for the push, it does so within the grace, tasks or no tasks.
|
|
2409
|
+
// A task that just SETTLED is different: its continuation turn is about
|
|
2410
|
+
// to dequeue and may replay the steered text, so that window holds the
|
|
2411
|
+
// clock (isContinuationImminent). So does a fresh API retry notice: the
|
|
2412
|
+
// CLI is mid-request for a turn it has not opened yet.
|
|
2413
|
+
if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
|
|
2414
|
+
|| isApiRetryPending() || isStopFollowUpPending(now)) {
|
|
2415
|
+
// Not idle: the CLI may still open a turn for it (the ordinary
|
|
2416
|
+
// delivered-attach path), so the fold clock has not started.
|
|
2417
|
+
for (const entry of proc.pendingDelivered) entry.foldSince = 0;
|
|
2418
|
+
return;
|
|
2419
|
+
}
|
|
2420
|
+
const folded = [];
|
|
2421
|
+
proc.pendingDelivered = proc.pendingDelivered.filter((entry) => {
|
|
2422
|
+
// A non-steered entry (pushed with no turn active) is a genuine queued
|
|
2423
|
+
// turn or a real drop — that stays the watchdog's to resolve.
|
|
2424
|
+
if (!entry.steered) return true;
|
|
2425
|
+
if (!entry.foldSince) { entry.foldSince = now; return true; }
|
|
2426
|
+
if (now - entry.foldSince < steeredFoldGraceMs) return true;
|
|
2427
|
+
folded.push(entry);
|
|
2428
|
+
// Moved, not dropped: the row stays in the heartbeat's owned set until
|
|
2429
|
+
// its marker is committed (see publishSettleMarker).
|
|
2430
|
+
claimSettling(entry.ctx.message, entry.stoppedWithTurn ? 'stopped' : 'folded');
|
|
2431
|
+
return false;
|
|
2432
|
+
});
|
|
2433
|
+
if (!folded.length) return;
|
|
2434
|
+
// Settle sequentially, in push order: concurrent publishes could commit a
|
|
2435
|
+
// later steer's merge stub before an earlier one's, and the transcript
|
|
2436
|
+
// order is part of the multi-steer contract.
|
|
2437
|
+
void (async () => {
|
|
2438
|
+
for (const entry of folded) {
|
|
2439
|
+
dbg('steered message folded into the finished turn; settling as merged', entry.ctx.message?.id || '(no id)');
|
|
2440
|
+
try {
|
|
2441
|
+
await settleFoldedSteeredEntry(entry);
|
|
2442
|
+
} catch (error) {
|
|
2443
|
+
dbg('folded-steer settle failed', error?.message || String(error));
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
})();
|
|
2447
|
+
}
|
|
2448
|
+
|
|
2449
|
+
async function settleFoldedSteeredEntry(entry) {
|
|
2450
|
+
const ctx = entry.ctx;
|
|
2451
|
+
if (ctx.finalized) {
|
|
2452
|
+
releaseSettling(ctx.message?.id);
|
|
2453
|
+
return;
|
|
2454
|
+
}
|
|
2455
|
+
ctx.finalized = true;
|
|
2456
|
+
controlPoller?.stop?.(ctx.controlState);
|
|
2457
|
+
const model = ctx.state.responseModel || proc?.model || null;
|
|
2458
|
+
// No streamed text of its own — the answer lives on the turn it was folded
|
|
2459
|
+
// into, and kind='folded' renders it as a compact marker on its own
|
|
2460
|
+
// message (never 'absorbed': that says the reply continues through the
|
|
2461
|
+
// NEXT message, and mis-merged the next ordinary turn). A steer pushed
|
|
2462
|
+
// into a turn the user then stopped was never answered: kind='stopped'
|
|
2463
|
+
// says so (and the client offers to resend it) instead of claiming the
|
|
2464
|
+
// stopped reply handled it.
|
|
2465
|
+
const stopped = entry.stoppedWithTurn === true;
|
|
2466
|
+
const text = stopped ? STEER_STOPPED_TEXT : STEER_FOLDED_TEXT;
|
|
2467
|
+
try {
|
|
2468
|
+
await publishSettleMarker(ctx.message, {
|
|
2469
|
+
text,
|
|
2470
|
+
model,
|
|
2471
|
+
kind: stopped ? 'stopped' : 'folded',
|
|
2472
|
+
variant: stopped ? 'stopped' : 'folded',
|
|
2473
|
+
});
|
|
2474
|
+
} finally {
|
|
2475
|
+
// The entry is already spliced out of pendingDelivered, so nothing else
|
|
2476
|
+
// can resolve this delivery — resolveDone must run even if the publish
|
|
2477
|
+
// threw, or handlePendingPayload's `await ctx.done` would hang forever.
|
|
2478
|
+
ctx.resolveDone?.(true);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
2483
|
+
|
|
2484
|
+
/**
|
|
2485
|
+
* Save the settle marker of a steered message the CLI already consumed —
|
|
2486
|
+
* at most once. The row stays in `settlingMessages`, and so in every
|
|
2487
|
+
* heartbeat, until the relay commits the marker: reporting it gone first
|
|
2488
|
+
* let owner-heartbeat recovery put the row back to pending (and a heartbeat
|
|
2489
|
+
* between splice and publish nulled its attempt, 409-ing the publish), and
|
|
2490
|
+
* the CLI ran the prompt twice. A marker that cannot be saved within the
|
|
2491
|
+
* retry budget ends as a terminal failure — never a requeue — and if even
|
|
2492
|
+
* that cannot be posted, the heartbeat hands the row to the relay to fail.
|
|
2493
|
+
* `onFirstAttempt` fires once the first publish attempt has returned, for
|
|
2494
|
+
* callers that must order other publishes behind it without waiting out
|
|
2495
|
+
* the retries.
|
|
2496
|
+
*/
|
|
2497
|
+
async function publishSettleMarker(message, { text, model, kind, variant = 'folded', onFirstAttempt = () => {} }) {
|
|
2498
|
+
const id = String(message?.id || '').trim();
|
|
2499
|
+
if (!id) {
|
|
2500
|
+
onFirstAttempt();
|
|
2501
|
+
return;
|
|
2502
|
+
}
|
|
2503
|
+
claimSettling(message, variant);
|
|
2504
|
+
let firstAttemptReported = false;
|
|
2505
|
+
const reportFirstAttempt = () => {
|
|
2506
|
+
if (firstAttemptReported) return;
|
|
2507
|
+
firstAttemptReported = true;
|
|
2508
|
+
onFirstAttempt();
|
|
2509
|
+
};
|
|
2510
|
+
// Drained once and reused by every attempt: a digest drained by a failed
|
|
2511
|
+
// attempt would otherwise be lost.
|
|
2512
|
+
const workflowRuns = publisher.drainWorkflowRuns();
|
|
2513
|
+
try {
|
|
2514
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
2515
|
+
const outcome = await publisher.publishResponse(message, { text, model, kind, workflowRuns });
|
|
2516
|
+
reportFirstAttempt();
|
|
2517
|
+
if (outcome !== 'failed') return;
|
|
2518
|
+
if (attempt >= settleRetryDelaysMs.length) break;
|
|
2519
|
+
await sleep(settleRetryDelaysMs[attempt]);
|
|
2520
|
+
}
|
|
2521
|
+
dbg('settle marker could not be saved; failing the row terminally', id);
|
|
2522
|
+
const terminalError = buildSteerSettleFailure(message, { variant, agentLabel: 'Claude' });
|
|
2523
|
+
const retryMs = Math.max(100, Number(settleRetryDelaysMs.at(-1)) || 0);
|
|
2524
|
+
for (let attempt = 0; attempt < Math.max(1, maxTerminalSettleAttempts); attempt += 1) {
|
|
2525
|
+
if (attempt) await sleep(retryMs);
|
|
2526
|
+
const outcome = await publisher.publishResponse(message, {
|
|
2527
|
+
text: terminalError.message,
|
|
2528
|
+
model,
|
|
2529
|
+
terminalError,
|
|
2530
|
+
requeueOnFailure: false,
|
|
2531
|
+
workflowRuns: null,
|
|
2532
|
+
});
|
|
2533
|
+
if (outcome !== 'failed') return;
|
|
2534
|
+
// Second channel: the requeue route fails a row outright when handed
|
|
2535
|
+
// a terminal error.
|
|
2536
|
+
const failedViaRequeue = await api('POST', '/api/requeue', {
|
|
2537
|
+
messageId: id,
|
|
2538
|
+
terminalError,
|
|
2539
|
+
...attemptFields(message),
|
|
2540
|
+
}).then(() => true, (error) => isStaleAttemptError(error));
|
|
2541
|
+
if (failedViaRequeue) return;
|
|
2542
|
+
}
|
|
2543
|
+
// Still owned (the lease keeps renewing) and reported as settleFailed:
|
|
2544
|
+
// the relay fails it from the next heartbeat that gets through.
|
|
2545
|
+
dbg('terminal settle could not be posted; handing it to the heartbeat', id);
|
|
2546
|
+
settleFailedTerminals.set(id, terminalError);
|
|
2547
|
+
} finally {
|
|
2548
|
+
reportFirstAttempt();
|
|
2549
|
+
releaseSettling(id);
|
|
2550
|
+
}
|
|
2551
|
+
}
|
|
2552
|
+
|
|
1870
2553
|
function reapStalePendingDelivered(now) {
|
|
1871
2554
|
if (!proc || !proc.pendingDelivered.length) return;
|
|
1872
2555
|
if (!(pendingDeliveredTimeoutMs > 0)) return; // 0 = watchdog disabled
|
|
1873
2556
|
// A silent cold boot (no init yet) is slow, not wedged: a CLI that never
|
|
1874
2557
|
// inits dies on its own and cleanupProcess rejects the contexts then.
|
|
1875
2558
|
if (!proc.sawInit) return;
|
|
1876
|
-
|
|
1877
|
-
|
|
2559
|
+
// Same gate as the fold reaper, for the same reason: a live background
|
|
2560
|
+
// task does not stop the CLI opening turns for pushed messages, so it
|
|
2561
|
+
// must not pin this clock either — a dropped entry under an hour-long
|
|
2562
|
+
// task would otherwise block the conversation for the hour instead of
|
|
2563
|
+
// failing over after the timeout. Just-settled tasks (continuation
|
|
2564
|
+
// imminent) and in-flight API retries still hold it.
|
|
2565
|
+
if (proc.activeCtx || proc.pendingControlRequests > 0 || isCompacting() || isContinuationImminent()
|
|
2566
|
+
|| isApiRetryPending() || isStopFollowUpPending(now)) {
|
|
2567
|
+
// A turn, control round-trip, or compaction may still attach these
|
|
1878
2568
|
// entries — the unattached clock starts over once the process actually
|
|
1879
2569
|
// goes quiet. A compaction is silent work, not idleness: the CLI replays
|
|
1880
2570
|
// the turn these entries belong to once it finishes.
|
|
@@ -1901,9 +2591,23 @@ export function createClaudeSessionRunner({
|
|
|
1901
2591
|
}
|
|
1902
2592
|
|
|
1903
2593
|
function evaluateLifecycle() {
|
|
2594
|
+
runLifecycle();
|
|
2595
|
+
// The reapers and the compaction staleness cap change what a delivery
|
|
2596
|
+
// would meet, and this tick is the backstop for every such change.
|
|
2597
|
+
syncDeliveryReadiness();
|
|
2598
|
+
}
|
|
2599
|
+
|
|
2600
|
+
function runLifecycle() {
|
|
1904
2601
|
if (!proc || proc.closing) return;
|
|
1905
2602
|
const now = Date.now();
|
|
2603
|
+
// The Stop guard lives exactly as long as the steers it protects.
|
|
2604
|
+
if (proc.stopFollowUp && !proc.suppressingTurn && !proc.pendingDelivered.some((entry) => entry.stoppedWithTurn)) {
|
|
2605
|
+
proc.stopFollowUp = null;
|
|
2606
|
+
}
|
|
1906
2607
|
reapSilentProvisionalAdoption(now);
|
|
2608
|
+
// Before the 5-minute watchdog: a folded steered entry settles as merged
|
|
2609
|
+
// within the short fold grace instead of failing over.
|
|
2610
|
+
reapFoldedSteeredEntries(now);
|
|
1907
2611
|
reapStalePendingDelivered(now);
|
|
1908
2612
|
if (hasLiveWork()) {
|
|
1909
2613
|
// Track how long background tasks alone have held the process, for the
|
|
@@ -1983,11 +2687,16 @@ export function createClaudeSessionRunner({
|
|
|
1983
2687
|
api('POST', '/api/background-tasks', { conversationId: sdkSessionId, tasks: [] }).catch(() => {});
|
|
1984
2688
|
}
|
|
1985
2689
|
}
|
|
2690
|
+
// Steers pushed into a turn the user stopped: if the Stop fell back to
|
|
2691
|
+
// killing the process (no interrupt support), they settle as 'stopped'
|
|
2692
|
+
// here rather than being left processing for recovery to re-run.
|
|
2693
|
+
const stoppedSteers = processRef.pendingDelivered.filter((entry) => entry.stoppedWithTurn && !entry.ctx.finalized);
|
|
1986
2694
|
const openContexts = [
|
|
1987
2695
|
...(processRef.activeCtx ? [processRef.activeCtx] : []),
|
|
1988
|
-
...processRef.pendingDelivered.map((entry) => entry.ctx),
|
|
2696
|
+
...processRef.pendingDelivered.filter((entry) => !stoppedSteers.includes(entry)).map((entry) => entry.ctx),
|
|
1989
2697
|
];
|
|
1990
2698
|
processRef.pendingDelivered = [];
|
|
2699
|
+
for (const entry of stoppedSteers) claimSettling(entry.ctx.message, 'stopped');
|
|
1991
2700
|
processRef.activeCtx = null;
|
|
1992
2701
|
if (proc === processRef) proc = null;
|
|
1993
2702
|
for (const ctx of openContexts) {
|
|
@@ -2012,6 +2721,12 @@ export function createClaudeSessionRunner({
|
|
|
2012
2721
|
model: processRef.model,
|
|
2013
2722
|
}).catch(() => {});
|
|
2014
2723
|
}
|
|
2724
|
+
for (const entry of stoppedSteers) {
|
|
2725
|
+
await settleFoldedSteeredEntry(entry).catch((error) => {
|
|
2726
|
+
dbg('stopped-steer settle failed', error?.message || String(error));
|
|
2727
|
+
});
|
|
2728
|
+
}
|
|
2729
|
+
syncDeliveryReadiness();
|
|
2015
2730
|
}
|
|
2016
2731
|
|
|
2017
2732
|
// ---------------------------------------------------------------------------
|
|
@@ -2062,6 +2777,7 @@ export function createClaudeSessionRunner({
|
|
|
2062
2777
|
// tool_use id → explicitly pinned model of a subagent spawn block; the
|
|
2063
2778
|
// task panel's model column resolves through this at publish time.
|
|
2064
2779
|
subagentSpawns: new Map(),
|
|
2780
|
+
subagentActivity: new Map(),
|
|
2065
2781
|
initModel: '',
|
|
2066
2782
|
// Set when the CLI reported a session-scoped refusal fallback; the next
|
|
2067
2783
|
// delivered turn repins the composer's model instead of trusting
|
|
@@ -2079,6 +2795,10 @@ export function createClaudeSessionRunner({
|
|
|
2079
2795
|
pendingActivities: [],
|
|
2080
2796
|
sawInit: false,
|
|
2081
2797
|
handoffSettling: null,
|
|
2798
|
+
// Stop guard (see maybeSuppressStopFollowUp): armed by a Stop that cut
|
|
2799
|
+
// off pushed steers; suppressingTurn while a follow-up turn is killed.
|
|
2800
|
+
stopFollowUp: null,
|
|
2801
|
+
suppressingTurn: false,
|
|
2082
2802
|
activeCtx: null,
|
|
2083
2803
|
pendingDelivered: [],
|
|
2084
2804
|
lastBoundary: null,
|
|
@@ -2086,6 +2806,12 @@ export function createClaudeSessionRunner({
|
|
|
2086
2806
|
lastEventAt: Date.now(),
|
|
2087
2807
|
heldForTasksSince: 0,
|
|
2088
2808
|
taskSettledAt: 0,
|
|
2809
|
+
// Bumped by every task-settle signal; the Stop guard compares it.
|
|
2810
|
+
taskSettleSeq: 0,
|
|
2811
|
+
// taskSettleSeq when the last turn ended: a settle since then is what
|
|
2812
|
+
// makes an unowed init inside a settings window a continuation.
|
|
2813
|
+
settleSeqAtTurnEnd: 0,
|
|
2814
|
+
lastApiRetryAt: 0,
|
|
2089
2815
|
tasksExpired: false,
|
|
2090
2816
|
tasksExpiredAt: 0,
|
|
2091
2817
|
aborted: false,
|
|
@@ -2120,6 +2846,7 @@ export function createClaudeSessionRunner({
|
|
|
2120
2846
|
// the human thinks, and an idle shutdown under it would reject the
|
|
2121
2847
|
// request with "Tool permission stream closed".
|
|
2122
2848
|
processRef.pendingControlRequests += 1;
|
|
2849
|
+
syncDeliveryReadiness();
|
|
2123
2850
|
try {
|
|
2124
2851
|
// A question from a background agent between turns has no active turn
|
|
2125
2852
|
// to attach to, and /api/relay-question requires a processing queue
|
|
@@ -2140,6 +2867,9 @@ export function createClaudeSessionRunner({
|
|
|
2140
2867
|
} finally {
|
|
2141
2868
|
processRef.pendingControlRequests -= 1;
|
|
2142
2869
|
processRef.lastEventAt = Date.now();
|
|
2870
|
+
// The card was answered (or the prompt resolved): a message held
|
|
2871
|
+
// behind it steers into the resumed turn within one round-trip.
|
|
2872
|
+
syncDeliveryReadiness();
|
|
2143
2873
|
}
|
|
2144
2874
|
};
|
|
2145
2875
|
|
|
@@ -2182,6 +2912,7 @@ export function createClaudeSessionRunner({
|
|
|
2182
2912
|
try {
|
|
2183
2913
|
for await (const sdkMessage of processRef.turn) {
|
|
2184
2914
|
await onSdkMessage(processRef, sdkMessage);
|
|
2915
|
+
syncDeliveryReadiness();
|
|
2185
2916
|
}
|
|
2186
2917
|
} catch (error) {
|
|
2187
2918
|
if (!processRef.aborted && !abortController.signal.aborted) streamError = error;
|
|
@@ -2196,6 +2927,11 @@ export function createClaudeSessionRunner({
|
|
|
2196
2927
|
return processRef;
|
|
2197
2928
|
}
|
|
2198
2929
|
|
|
2930
|
+
function expectSettingsReinit() {
|
|
2931
|
+
settingsReinitsOwed += 1;
|
|
2932
|
+
settingsWindowOpen = true;
|
|
2933
|
+
}
|
|
2934
|
+
|
|
2199
2935
|
async function adaptProcess(message) {
|
|
2200
2936
|
const relayMode = message.relayMode || 'agent';
|
|
2201
2937
|
const model = resolvePerTurnModel(message);
|
|
@@ -2221,6 +2957,7 @@ export function createClaudeSessionRunner({
|
|
|
2221
2957
|
|
|
2222
2958
|
const permissionMode = permissionModeForRelayMode(relayMode);
|
|
2223
2959
|
if (permissionMode !== proc.permissionMode) {
|
|
2960
|
+
expectSettingsReinit();
|
|
2224
2961
|
await Promise.resolve(proc.turn.setPermissionMode?.(permissionMode)).catch((error) => {
|
|
2225
2962
|
dbg('setPermissionMode failed', error?.message || String(error));
|
|
2226
2963
|
});
|
|
@@ -2239,6 +2976,7 @@ export function createClaudeSessionRunner({
|
|
|
2239
2976
|
// (model '') keeps whatever the CLI chose.
|
|
2240
2977
|
const repinAfterFallback = Boolean(proc.modelFallback) && Boolean(model);
|
|
2241
2978
|
if (model !== proc.model || repinAfterFallback) {
|
|
2979
|
+
expectSettingsReinit();
|
|
2242
2980
|
await Promise.resolve(proc.turn.setModel?.(model || undefined)).catch((error) => {
|
|
2243
2981
|
dbg('setModel failed', error?.message || String(error));
|
|
2244
2982
|
});
|
|
@@ -2250,6 +2988,7 @@ export function createClaudeSessionRunner({
|
|
|
2250
2988
|
// session-scoped ultracode/enableWorkflows flags (with the effort pinned
|
|
2251
2989
|
// to the xhigh the flag implies); every other change clears those flags
|
|
2252
2990
|
// alongside the new effortLevel.
|
|
2991
|
+
expectSettingsReinit();
|
|
2253
2992
|
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeUltracodeFlagSettings(effort))).catch((error) => {
|
|
2254
2993
|
dbg('applyFlagSettings effort failed', error?.message || String(error));
|
|
2255
2994
|
});
|
|
@@ -2260,6 +2999,7 @@ export function createClaudeSessionRunner({
|
|
|
2260
2999
|
// be pushed too or clearing the slider would never take effect.
|
|
2261
3000
|
const autoCompactWindow = normalizeAutoCompactWindow(getAutoCompactWindow());
|
|
2262
3001
|
if (autoCompactWindow !== proc.autoCompactWindow) {
|
|
3002
|
+
expectSettingsReinit();
|
|
2263
3003
|
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeAutoCompactFlagSettings(autoCompactWindow))).catch((error) => {
|
|
2264
3004
|
dbg('applyFlagSettings autoCompactWindow failed', error?.message || String(error));
|
|
2265
3005
|
});
|
|
@@ -2277,6 +3017,7 @@ export function createClaudeSessionRunner({
|
|
|
2277
3017
|
// Enabling applies live. The display MUST be re-sent afterwards, in
|
|
2278
3018
|
// this order: a session spawned with thinking off carries no display
|
|
2279
3019
|
// mode, so enabling without it yields visible-but-empty thinking.
|
|
3020
|
+
expectSettingsReinit();
|
|
2280
3021
|
await Promise.resolve(proc.turn.applyFlagSettings?.(claudeThinkingFlagSettings(true))).catch((error) => {
|
|
2281
3022
|
dbg('applyFlagSettings thinkingEnabled failed', error?.message || String(error));
|
|
2282
3023
|
});
|
|
@@ -2294,6 +3035,7 @@ export function createClaudeSessionRunner({
|
|
|
2294
3035
|
}
|
|
2295
3036
|
}
|
|
2296
3037
|
if (thinkingDisplay !== proc.thinkingDisplay || reassertDisplay) {
|
|
3038
|
+
expectSettingsReinit();
|
|
2297
3039
|
await applyThinkingDisplay(proc.turn, thinkingDisplay, { budget: proc.thinkingBudget, dbg });
|
|
2298
3040
|
proc.thinkingDisplay = thinkingDisplay;
|
|
2299
3041
|
}
|
|
@@ -2304,11 +3046,161 @@ export function createClaudeSessionRunner({
|
|
|
2304
3046
|
// ---------------------------------------------------------------------------
|
|
2305
3047
|
// Relay contract
|
|
2306
3048
|
|
|
3049
|
+
/**
|
|
3050
|
+
* Whether a new delivered message may be pushed into the live turn
|
|
3051
|
+
* (mid-turn steering) instead of waiting in the relay queue for the
|
|
3052
|
+
* result. The CLI dequeues a mid-turn push into the running turn; the
|
|
3053
|
+
* absorbed-steering handoff in resolveContext is the receiving end.
|
|
3054
|
+
* Deliberately narrow — every exclusion here guards an invariant that
|
|
3055
|
+
* already exists:
|
|
3056
|
+
* - a pending canUseTool round-trip (AskUserQuestion, plan approval)
|
|
3057
|
+
* holds steering entirely, so the bridge reply and a steered push can
|
|
3058
|
+
* never race in the CLI (a deliberate product decision, 2026-09-19);
|
|
3059
|
+
* - a compaction replays the turn when it finishes, and a push into that
|
|
3060
|
+
* window would land inside the replay;
|
|
3061
|
+
* - a provisional post-compaction adoption may still be unwound on the
|
|
3062
|
+
* premise that the CLI never consumed the row's prompt — steering
|
|
3063
|
+
* another message in would spend that premise for it;
|
|
3064
|
+
* - a NON-steered pending entry means a turn-opening delivery is still
|
|
3065
|
+
* mid-flight (pushed with no turn active, replay not yet arrived) — a
|
|
3066
|
+
* steer would race the very turn that entry is about to open.
|
|
3067
|
+
* The number of steered entries in flight is deliberately unbounded
|
|
3068
|
+
* (Claude Code parity — the CLI queues any number of mid-turn messages and
|
|
3069
|
+
* folds or replays them in push order; attach matches by expectedText,
|
|
3070
|
+
* oldest first, and the fold reaper settles every leftover as merged).
|
|
3071
|
+
*/
|
|
3072
|
+
function canAcceptSteering() {
|
|
3073
|
+
if (!proc || proc.closing || proc.aborted) return false;
|
|
3074
|
+
const ctx = proc.activeCtx;
|
|
3075
|
+
if (!ctx || ctx.finalized || ctx.discarded || ctx.interrupted) return false;
|
|
3076
|
+
if (ctx.adoptedFromCompaction) return false;
|
|
3077
|
+
if (proc.pendingDelivered.some((entry) => !entry.steered)) return false;
|
|
3078
|
+
if (proc.pendingControlRequests > 0) return false;
|
|
3079
|
+
if (isCompacting()) return false;
|
|
3080
|
+
return true;
|
|
3081
|
+
}
|
|
3082
|
+
|
|
3083
|
+
/**
|
|
3084
|
+
* The one steering gate. A delivery arriving now is held — handed back to
|
|
3085
|
+
* the queue instead of pushed — when a turn is live (a context, anything
|
|
3086
|
+
* pushed and not yet settled, or an open control round-trip) and that turn
|
|
3087
|
+
* cannot accept a steer. The socket link consults this for its readiness
|
|
3088
|
+
* too, idle or mid-delivery: a continuation holding a question card is not a
|
|
3089
|
+
* delivery, so the link's own in-flight bookkeeping cannot see it.
|
|
3090
|
+
*/
|
|
3091
|
+
function isDeliveryHeld() {
|
|
3092
|
+
if (admittingDeliveries > 0) return true;
|
|
3093
|
+
if (!proc || proc.closing || proc.aborted) return false;
|
|
3094
|
+
if (!isTurnActive() && !(proc.pendingControlRequests > 0)) return false;
|
|
3095
|
+
return !canAcceptSteering();
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
function syncDeliveryReadiness() {
|
|
3099
|
+
const ready = !isDeliveryHeld();
|
|
3100
|
+
if (ready === lastReportedDeliveryReady) return;
|
|
3101
|
+
lastReportedDeliveryReady = ready;
|
|
3102
|
+
try {
|
|
3103
|
+
onDeliveryReadinessChange(ready);
|
|
3104
|
+
} catch (error) {
|
|
3105
|
+
dbg('delivery readiness listener failed', error?.message || String(error));
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
3109
|
+
/**
|
|
3110
|
+
* Give a held delivery back to the queue with no retry penalty. The relay
|
|
3111
|
+
* re-delivers it once this worker signals ready again, i.e. when the hold
|
|
3112
|
+
* ends — the message then steers into the resumed turn. Pushing it into the
|
|
3113
|
+
* hold instead would bypass the question card or land inside a compaction's
|
|
3114
|
+
* replay.
|
|
3115
|
+
*/
|
|
3116
|
+
async function handBackHeldDelivery(message) {
|
|
3117
|
+
dbg('steering held; handing the delivery back to the queue', message.id || '(no id)');
|
|
3118
|
+
if (!message.id) return false;
|
|
3119
|
+
await api('POST', '/api/requeue', {
|
|
3120
|
+
messageId: message.id,
|
|
3121
|
+
class: 'steering-held',
|
|
3122
|
+
...attemptFields(message),
|
|
3123
|
+
}).catch((error) => {
|
|
3124
|
+
if (isStaleAttemptError(error)) {
|
|
3125
|
+
dbg('steering-held hand-back refused as stale_attempt', message.id);
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
3128
|
+
dbg('steering-held hand-back failed', message.id, error?.message || String(error));
|
|
3129
|
+
});
|
|
3130
|
+
return false;
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3133
|
+
/**
|
|
3134
|
+
* Composer-facing steering snapshot: whether a turn is live, whether a
|
|
3135
|
+
* message typed now would steer into it, and — when it would not — why.
|
|
3136
|
+
* Rides the worker heartbeat to the relay's worker registry, so the client
|
|
3137
|
+
* can disable the Steer button during holds instead of silently queueing.
|
|
3138
|
+
* holdReason is a coarse enum, not a diagnostic: 'question' (a canUseTool
|
|
3139
|
+
* round-trip — question card / plan approval — is open), 'compaction',
|
|
3140
|
+
* 'adoption' (provisional post-compaction adoption), 'delivery' (a
|
|
3141
|
+
* turn-opening delivery is still mid-flight).
|
|
3142
|
+
*/
|
|
3143
|
+
function steeringState() {
|
|
3144
|
+
// A closing/aborted process is about to die — nothing about its last turn
|
|
3145
|
+
// should hold the composer of the worker that replaces it.
|
|
3146
|
+
if (!proc || proc.closing || proc.aborted) {
|
|
3147
|
+
return { turnActive: false, canSteer: false, holdReason: null, messageId: null, supported: true };
|
|
3148
|
+
}
|
|
3149
|
+
const ctx = proc.activeCtx || null;
|
|
3150
|
+
const ctxLive = Boolean(ctx && !ctx.finalized && !ctx.discarded && !ctx.interrupted);
|
|
3151
|
+
// A turn-opening delivery in flight (pushed with no active ctx, replay not
|
|
3152
|
+
// yet arrived) reads as an active turn to the user — the row is already
|
|
3153
|
+
// `processing` — so it must report as one here, with a 'delivery' hold.
|
|
3154
|
+
const openingDelivery = proc.pendingDelivered.find((entry) => !entry.steered) || null;
|
|
3155
|
+
const turnActive = ctxLive || Boolean(openingDelivery);
|
|
3156
|
+
const canSteer = canAcceptSteering();
|
|
3157
|
+
let holdReason = null;
|
|
3158
|
+
if (turnActive && !canSteer) {
|
|
3159
|
+
if (proc.pendingControlRequests > 0) holdReason = 'question';
|
|
3160
|
+
else if (isCompacting()) holdReason = 'compaction';
|
|
3161
|
+
else if (ctxLive && ctx.adoptedFromCompaction) holdReason = 'adoption';
|
|
3162
|
+
else if (openingDelivery) holdReason = 'delivery';
|
|
3163
|
+
else holdReason = 'other';
|
|
3164
|
+
}
|
|
3165
|
+
// The turn this snapshot describes, so a client can discard a stale hold
|
|
3166
|
+
// that belongs to a previous turn.
|
|
3167
|
+
const messageId = String(
|
|
3168
|
+
(ctxLive ? ctx.message?.id : '') || openingDelivery?.ctx?.message?.id || '',
|
|
3169
|
+
).trim() || null;
|
|
3170
|
+
// `supported` is the worker-advertised opt-in the client's composer reads
|
|
3171
|
+
// (the same flag the Copilot SDK worker publishes) — its only steering
|
|
3172
|
+
// gate, so every snapshot carries it, idle ones included.
|
|
3173
|
+
return { turnActive, canSteer, holdReason, messageId, supported: true };
|
|
3174
|
+
}
|
|
3175
|
+
|
|
2307
3176
|
async function handlePendingPayload(pending) {
|
|
2308
3177
|
const message = pending?.message || null;
|
|
2309
3178
|
if (!message) return false;
|
|
3179
|
+
// An older relay (the worker updated on disk before the relay restarted)
|
|
3180
|
+
// treats the hand-back as a failed delivery — retry, backoff, worker
|
|
3181
|
+
// marked errored — so against one the message is pushed the legacy way.
|
|
3182
|
+
if (isDeliveryHeld() && canHandBackHeldDelivery() !== false) return handBackHeldDelivery(message);
|
|
3183
|
+
// A turn-opening delivery awaits (adaptProcess, a draining process, a
|
|
3184
|
+
// spawn) before it pushes; until then it counts as admitting, so nothing
|
|
3185
|
+
// else is let in behind it. A steer pushes synchronously and needs none.
|
|
3186
|
+
let admitting = !(proc && !proc.closing && !proc.aborted && proc.activeCtx);
|
|
3187
|
+
if (admitting) admittingDeliveries += 1;
|
|
3188
|
+
const endAdmission = () => {
|
|
3189
|
+
if (!admitting) return;
|
|
3190
|
+
admitting = false;
|
|
3191
|
+
admittingDeliveries -= 1;
|
|
3192
|
+
syncDeliveryReadiness();
|
|
3193
|
+
};
|
|
3194
|
+
syncDeliveryReadiness();
|
|
2310
3195
|
try {
|
|
2311
|
-
|
|
3196
|
+
// A steered delivery (a turn is live) must not run the settings diffs:
|
|
3197
|
+
// setModel/effort/mode against a mid-flight turn would disturb the very
|
|
3198
|
+
// turn the message is steering into, and the mode-change recycle is
|
|
3199
|
+
// refused under live work anyway. The next between-turns delivery
|
|
3200
|
+
// re-runs adaptProcess with the then-current settings, so nothing is
|
|
3201
|
+
// lost — the steered message's own model/mode intent applies from the
|
|
3202
|
+
// next turn on, same as Claude Code.
|
|
3203
|
+
if (proc && !proc.closing && !proc.aborted && !proc.activeCtx) {
|
|
2312
3204
|
await adaptProcess(message);
|
|
2313
3205
|
}
|
|
2314
3206
|
if (!proc || proc.closing || proc.aborted) {
|
|
@@ -2338,9 +3230,38 @@ export function createClaudeSessionRunner({
|
|
|
2338
3230
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
2339
3231
|
const procRef = proc;
|
|
2340
3232
|
const ctx = createDeliveredContext(message);
|
|
2341
|
-
|
|
3233
|
+
// A message pushed while a turn is already active is a steered
|
|
3234
|
+
// delivery. The CLI folds it into that running turn and answers
|
|
3235
|
+
// everything with ONE result on the turn it was already running —
|
|
3236
|
+
// it does NOT necessarily replay the steered text as a turn-opening
|
|
3237
|
+
// user message, so the absorbed-steering handoff in resolveContext
|
|
3238
|
+
// (which needs that replay) may never fire. Mark the entry so the
|
|
3239
|
+
// fold reaper can settle it as merged once the turn it joined ends,
|
|
3240
|
+
// instead of leaving its queue row stuck `processing` until the
|
|
3241
|
+
// 5-minute watchdog fails it with a bogus "resend" error.
|
|
3242
|
+
const steered = Boolean(procRef.activeCtx);
|
|
3243
|
+
// A steer carries a hidden note that it adds to the running request
|
|
3244
|
+
// rather than replacing it (shared/steer-note.mjs). expectedText stays
|
|
3245
|
+
// the user's own text: the replay signatures strip the note before
|
|
3246
|
+
// any comparison, so matching works whether or not the CLI echoes it.
|
|
3247
|
+
// Not into a continuation: there the running work is the CLI's own
|
|
3248
|
+
// follow-up to a background task, not a request of the user's.
|
|
3249
|
+
// Whatever is left starting with "/" — a "/x" delivered between turns,
|
|
3250
|
+
// on a first turn, or into a continuation — gets the slash-command
|
|
3251
|
+
// guard, or the CLI would run it as a command instead of handing the
|
|
3252
|
+
// model the text the user sent (shared/slash-command-guard.mjs). A
|
|
3253
|
+
// noted steer no longer starts with "/" and is pushed as noted. The
|
|
3254
|
+
// replay signatures strip the guard too, so expectedText stays the
|
|
3255
|
+
// user's own text here as well.
|
|
3256
|
+
const pushedContent = withSlashCommandGuardContent(steered && procRef.activeCtx?.kind !== 'continuation'
|
|
3257
|
+
? withSteerNoteContent(content)
|
|
3258
|
+
: content);
|
|
3259
|
+
procRef.pendingDelivered.push({ ctx, expectedText: contentText(content), pushedAt: Date.now(), steered });
|
|
3260
|
+
// Registered: from here a later init takes the delivered-row branch.
|
|
3261
|
+
settingsReinitsOwed = 0;
|
|
3262
|
+
settingsWindowOpen = false;
|
|
2342
3263
|
try {
|
|
2343
|
-
procRef.turn.pushUserMessage(
|
|
3264
|
+
procRef.turn.pushUserMessage(pushedContent);
|
|
2344
3265
|
} catch (pushError) {
|
|
2345
3266
|
// The stream ended under us (process wound down between the
|
|
2346
3267
|
// liveness check and the push): retire this context and respawn.
|
|
@@ -2353,14 +3274,25 @@ export function createClaudeSessionRunner({
|
|
|
2353
3274
|
spawnProcess(message);
|
|
2354
3275
|
continue;
|
|
2355
3276
|
}
|
|
3277
|
+
// Pushed: the pending entry now holds the gate on its own.
|
|
3278
|
+
endAdmission();
|
|
3279
|
+
// A genuinely new message reached the CLI (only possible against an
|
|
3280
|
+
// older relay): the next turn may be its, so the Stop guard must not
|
|
3281
|
+
// kill it.
|
|
3282
|
+
if (procRef.stopFollowUp) procRef.stopFollowUp = null;
|
|
2356
3283
|
return await ctx.done;
|
|
2357
3284
|
}
|
|
2358
3285
|
throw new Error('claude session process closed while accepting the message');
|
|
2359
3286
|
} catch (error) {
|
|
3287
|
+
endAdmission();
|
|
3288
|
+
settingsReinitsOwed = 0;
|
|
3289
|
+
settingsWindowOpen = false;
|
|
2360
3290
|
const errorText = String(error?.message || error || 'unknown error');
|
|
2361
3291
|
dbg('claude turn failed', message.id, errorText);
|
|
2362
3292
|
await publisher.publishTurnException({ message, errorText });
|
|
2363
3293
|
return true;
|
|
3294
|
+
} finally {
|
|
3295
|
+
endAdmission();
|
|
2364
3296
|
}
|
|
2365
3297
|
}
|
|
2366
3298
|
|
|
@@ -2410,8 +3342,13 @@ export function createClaudeSessionRunner({
|
|
|
2410
3342
|
|
|
2411
3343
|
return {
|
|
2412
3344
|
handlePendingPayload,
|
|
3345
|
+
canAcceptSteering,
|
|
3346
|
+
isDeliveryHeld,
|
|
3347
|
+
steeringState,
|
|
2413
3348
|
getActiveQueueMessageId,
|
|
2414
3349
|
getActiveQueueMessageIds,
|
|
3350
|
+
getSettleFailed,
|
|
3351
|
+
acknowledgeSettleFailed,
|
|
2415
3352
|
isTurnActive,
|
|
2416
3353
|
stopBackgroundTask,
|
|
2417
3354
|
stopBackgroundTaskByToolUseId,
|