@scotthuang/agent-knock-knock 0.12.8 → 0.12.11

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 CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.11 - 2026-08-14
4
+
5
+ ### Fixed
6
+
7
+ - Allow managed close to retire a Turn when its exact bound Codex or Claude Code process is conclusively absent, without falling into the managed/raw close deadlock.
8
+ - Stop terminal-monitor relaunch loops for verified-dead accepted Turns, while preserving completion-first settlement when the exact rollout or transcript already contains a durable result.
9
+ - Recover verified-dead stall and close transitions idempotently across event/state/ledger crash windows without replaying terminal input.
10
+
11
+ ### Security
12
+
13
+ - Require a complete process inventory, exact Session and Turn binding generation, canonical terminal endpoint, process incarnation, dispatch ledger, and append-only native-acceptance receipts before dead-process cleanup; malformed, partial, conflicting, or unavailable evidence remains fail-closed.
14
+ - Distinguish durable completion as present, absent, or unverifiable for both Codex rollouts and Claude transcripts. Unverifiable evidence may stop orphan-monitor churn but cannot resolve the dispatch, renew the Turn, or trigger terminal input.
15
+
16
+ ### Changed
17
+
18
+ - Keep action contract 16 and Store writer protocol 5 unchanged; no new public action or Store authority is introduced.
19
+
20
+ ## 0.12.10 - 2026-08-13
21
+
22
+ ### Fixed
23
+
24
+ - Scope Codex and Claude Code approval fingerprints to one adapter-isolated, exact unredacted prompt region, so unrelated terminal output can continue scrolling across review, authorization, and dispatch reservation without invalidating the same visible request.
25
+ - Normalize managed approval locking to terminal, Store writer, then Turn state order, eliminating the writer/state inversion that could amplify approval delays under concurrent monitor activity.
26
+ - Support the complete Codex 0.147 command, edit, permission, and MCP approval menus, including multi-choice decline/cancel rows and the permission deny shortcut.
27
+
28
+ ### Changed
29
+
30
+ - Publish action contract 16. Whole-screen digests and excerpts are diagnostic only; v15 approval fingerprints and terminal-scoped approval tokens require a fresh `list` or `status` after upgrade.
31
+ - Store writer protocol remains 5.
32
+
33
+ ### Security
34
+
35
+ - Revalidate the exact prompt-region digest with terminal, process, action, request, policy, and working-directory authority before approval input; missing, incomplete, duplicated, reordered, spoofed, or changed prompt evidence sends zero keys.
36
+ - Keep raw prompt text local to the adapter and out of status, Store records, tokens, logs, and failure diagnostics; only its SHA-256 digest participates in authorization.
37
+
3
38
  ## 0.12.8 - 2026-08-13
4
39
 
5
40
  ### Fixed
package/README.md CHANGED
@@ -89,6 +89,8 @@ v15 generalizes the human-priority Codex path. It covers a status-card-only Sess
89
89
 
90
90
  v15 also preserves the separation between human-confirmed Codex approval and managed attribution. When `list` can prove one exact visible Codex approval prompt, it may advertise a terminal-scoped `approve` even when the foreground rollout UUID is temporarily unavailable. The authority may be either one exact current AKK dispatch owner or one exact bound managed Session whose dispatch owner is already released; the latter covers a task that the human entered directly in the pane. After the user reviews the current `status` fingerprint and explicitly confirms, AKK revalidates the Store/terminal/process snapshot, token, and approval fingerprint immediately before sending the exact key once. This action does not attribute the approval to a Turn, mutate Session identity, or create a durable approval receipt, so an uncertain transport result must not be retried blindly. It is never available to auto-approve. Known native-thread changes, unresolved dispatches, transitions, stale tokens, or changed prompts remain blocked.
91
91
 
92
+ v16 makes that approval fingerprint prompt-scoped. The authority hashes the adapter-isolated, exact unredacted approval region together with the terminal/process identity, decision keys and label, prompt kind, working directory, reason/detail, and any request or policy evidence. The whole-screen digest and redacted excerpt remain diagnostic only: test output or other scrollback outside the approval region may change before execution, after authorization, or after the at-most-once dispatch reservation without invalidating the same reviewed prompt. A change inside the region—including the command or an otherwise identically redacted secret, choices, highlighted option, prompt kind, or request identity—or a change in the bound process, working directory, or request evidence still rejects with zero approval keys. If the adapter cannot isolate one complete bounded prompt region, approval fails closed. The raw prompt region is never returned, persisted, or logged. Fingerprints and terminal-scoped approval tokens issued under v15's whole-screen authority are intentionally stale; refresh `list` and `status` before approving after upgrade.
93
+
92
94
  Human-friendly selectors such as `only`, `codex`, `claude`, a terminal ID, or `@short-ref` remain a discovery layer. A natural-language tool call may preserve one only when the user explicitly named it; otherwise use the exact selector returned by `AKK list`, or omit it and require a unique eligible pane. When strict continuation is safe, a managed row pre-fills its authoritative `session_id`; a rollout-backed Codex row instead uses the exact terminal selector and `expected_terminal_token` for follow-current continuation. Merely observing a mismatch or an unbound rollout inventory never mutates the Store. AKK advertises the fenced send only when it can prove a single source claim, an exact live terminal/process incarnation, an idle empty composer, a stable complete rollout inventory, and no unresolved Turn, transition, dispatch, or approval. A foreground ambiguity inside that complete inventory may be resolved after the real request is accepted; incomplete, stale, or unverifiable evidence remains blocked. The same terminal row may advertise status, manual approval, cancellation, or orphan-close with its own prefilled `conversation_id` compatibility selector and, where required, a fresh token. Never infer, guess, or reuse compatibility selectors or tokens.
93
95
 
94
96
  An exact human switch while the old Session still has one active Turn is a decision point, not an automatic redirect. In that case the terminal row may expose `handoff_decision` instead of a follow-current send. Its nested `choices.take_over_current.action` is the only authoritative supersede action: after explicit user confirmation, invoke its complete `agent_knock_knock_close` arguments unchanged (`turn_id`, `reason="superseded_by_human_context_switch"`, and `expected_handoff_token`). The snapshot-bound close records the old Turn's disposition and sends no terminal input. Then list again and use only the newly advertised follow-current send; never chain the old token into a send. Choosing `keep_source` changes no AKK state: restore the old native thread in the Codex or Claude TUI, then refresh the list. A completed/changed Turn or another human thread switch makes the decision token stale and requires a fresh decision.
@@ -242,13 +244,13 @@ To ask AKK itself to change native context, first copy the full `terminal_id` fr
242
244
 
243
245
  To request a native Codex status card or Claude Status panel, first run `agent_knock_knock_list` and use only that terminal row's advertised `native_inspect` action. The structured tool schema is closed to `inspection="status"`; callers cannot provide `/status` or another slash command as text. AKK serializes the inspection with terminal mutations, revalidates the fresh token and exact terminal identity, and returns only after it proves one fresh bounded status result and an idle postcondition. Codex status probes additionally require an exact viewport of at least 80 columns to preserve the full Session UUID, cross the versioned paste-settle boundary, and dispatch Enter exactly once. An initially narrow or unknown viewport fails before text input with a widen/zoom diagnostic; post-injection viewport or composer drift fails closed before Enter and leaves the draft for manual inspection. Codex `/status` and that viewport requirement apply only to operations that must prove the UUID before terminal input. An otherwise eligible terminal-scoped ordinary task can send once and bind from exact native acceptance afterward, so it does not run `/status` or fail merely because the pane is narrow. For Claude, the inspection safely dismisses the exact modal once. It never turns ordinary `send` or `respond` into a slash-command escape hatch.
244
246
 
245
- The top-level v15 `action_contracts` documents this closed inspection alongside human-priority current-pane send/approval, lifecycle, and Turn actions. `available_actions` remains the authority for ordinary current actions. Two deliberate nested exceptions require explicit user confirmation: an active human-handoff conflict may expose the snapshot-bound `handoff_decision.choices.take_over_current.action`, while a collateral terminal-wide unresolved Turn may appear in `blocking_turns[]` with its exact Store-only `recovery_action`. An active handoff source Turn is never generically closable through `blocking_turns`; it remains governed only by the snapshot-bound handoff decision. Copy only the complete listed action, then refresh the list before doing anything else.
247
+ The top-level v16 `action_contracts` documents this closed inspection alongside human-priority current-pane send/approval, lifecycle, and Turn actions. `available_actions` remains the authority for ordinary current actions. Two deliberate nested exceptions require explicit user confirmation: an active human-handoff conflict may expose the snapshot-bound `handoff_decision.choices.take_over_current.action`, while a collateral terminal-wide unresolved Turn may appear in `blocking_turns[]` with its exact Store-only `recovery_action`. An active handoff source Turn is never generically closable through `blocking_turns`; it remains governed only by the snapshot-bound handoff decision. Copy only the complete listed action, then refresh the list before doing anything else.
246
248
 
247
249
  For natural-language tool use, `agent_knock_knock_list` is terminal-first. Each live pane appears exactly once in `terminals[]`; `process_state` reports whether its coding-agent process is alive and `activity_state` reports the parsed screen state. `managed.session_id` identifies the continuing AKK session, `managed.current_turn` is its optional active Turn, and `managed.recent_turn` is retained history; retained Turns do not occupy the terminal. A human-driven native-thread mismatch remains honestly classified as `management_state="conflict"`; its `handoff_state` is `external_handoff_adoptable` only when the row advertises the fenced follow-current `send`, otherwise it is `external_handoff_blocked`. Listing never performs the adoption. Pass `all=true` to include older entries in `managed.history`. By default, `unavailable_managed_turns[]` contains attention-needed records whose pane cannot be presented as a live terminal; `all=true` also includes retained unavailable history.
248
250
 
249
251
  Use only an `available_actions` entry returned in that snapshot, begin with its prefilled authoritative arguments, and supply every `missing_required` field. The only additional action sources are a terminal row's nested `handoff_decision.choices.take_over_current.action` and an exact `blocking_turns[].recovery_action`; both require explicit user confirmation and must be copied whole. When advertised, a managed Session's strict `send` uses its prefilled `session_id` and creates a new Turn only in that Session's native context. A terminal-scoped follow-current `send` instead carries the selected row's exact full `selector` and `expected_terminal_token`; preserve both and add only `request`. This is the human-priority path when the pane is exact but its foreground Codex UUID is not yet attributable. Legacy first attach may still use a discovery selector explicitly named by the user or the unmanaged raw-terminal row's prefilled `selector`; do not infer or reuse one. `respond` is available only while a Turn is `waiting_for_openclaw`; it uses `turn_id` and keeps the answer inside that Turn. Managed status, approval, cancellation, renewal, callback retry, and close also use the exact `turn_id`. Native inspection instead uses the exact terminal row's `terminal_id`, closed `inspection`, and snapshot-bound `expected_binding_token`; do not substitute AKK status or ordinary send. A terminal-scoped manual Codex approval may use the exact listed `conversation_id` plus `expected_terminal_token` and the latest status fingerprint; it never authorizes auto-approve or changes managed identity. Other raw controls may be used only through the exact action their row advertises. `timeoutSeconds` is unsupported, and monitoring limits should be omitted unless the user explicitly asks to change them. AKK revalidates availability before every side effect.
250
252
 
251
- The top-level v15 action contracts include `send`, manual `approve`, `native_inspect`, `list_resumable_threads`, `new_thread`, `resume_thread`, and the conflict-only `reconcile_binding` recovery action. `send` has two deliberately different managed scopes: `session_exact` uses `session_id` for strict context, while `terminal_follow_current` uses the exact terminal `selector` plus `expected_terminal_token` for follow-current current-pane context. Manual Codex approval likewise has a strict managed-Turn form and a separately advertised terminal-scoped form; only the latter carries the terminal token, and neither permits the model to enable automatic approval. A supported idle Codex or Claude Code terminal may advertise `native_inspect` with its exact terminal ID, the closed `status` inspection kind, and a fresh binding token. The terminal row also advertises `list_resumable_threads` and, when currently safe, `new_thread`. Thread listing is read-only with respect to Session/Turn state, takes only the full `terminal_id`, and returns a fresh `expected_binding_token` plus candidate rows; each `resumable=true` row retains its complete UUID and exact prefilled `resume_thread` action. If `previous` is present, use only its exact prefilled action for a natural-language “刚才那个” request. Numbers, short IDs, and handles are human display/navigation aids, never tool arguments or authoritative native identity. The `new_thread` and `resume_thread` mutations require the fresh token, and resume additionally requires the candidate's complete `native_thread_id` and opaque `candidate_token`. `reconcile_binding` remains a low-level compatibility/recovery action for a safely detachable conflict when no ordinary follow-current send is appropriate; it never adopts the replacement thread, sends terminal input, or creates a Turn. Never construct, guess, truncate, combine across snapshots, or reuse those values after another terminal action. Native inspection and lifecycle results contain no `turn_id` because no work was sent.
253
+ The top-level v16 action contracts include `send`, manual `approve`, `native_inspect`, `list_resumable_threads`, `new_thread`, `resume_thread`, and the conflict-only `reconcile_binding` recovery action. `send` has two deliberately different managed scopes: `session_exact` uses `session_id` for strict context, while `terminal_follow_current` uses the exact terminal `selector` plus `expected_terminal_token` for follow-current current-pane context. Manual Codex approval likewise has a strict managed-Turn form and a separately advertised terminal-scoped form; only the latter carries the terminal token, and neither permits the model to enable automatic approval. A supported idle Codex or Claude Code terminal may advertise `native_inspect` with its exact terminal ID, the closed `status` inspection kind, and a fresh binding token. The terminal row also advertises `list_resumable_threads` and, when currently safe, `new_thread`. Thread listing is read-only with respect to Session/Turn state, takes only the full `terminal_id`, and returns a fresh `expected_binding_token` plus candidate rows; each `resumable=true` row retains its complete UUID and exact prefilled `resume_thread` action. If `previous` is present, use only its exact prefilled action for a natural-language “刚才那个” request. Numbers, short IDs, and handles are human display/navigation aids, never tool arguments or authoritative native identity. The `new_thread` and `resume_thread` mutations require the fresh token, and resume additionally requires the candidate's complete `native_thread_id` and opaque `candidate_token`. `reconcile_binding` remains a low-level compatibility/recovery action for a safely detachable conflict when no ordinary follow-current send is appropriate; it never adopts the replacement thread, sends terminal input, or creates a Turn. Never construct, guess, truncate, combine across snapshots, or reuse those values after another terminal action. Native inspection and lifecycle results contain no `turn_id` because no work was sent.
252
254
 
253
255
  Workspace is not a routing boundary. AKK can list, inspect, and control verified panes across projects; when more than one target matches, use a selector to choose one explicitly.
254
256
 
@@ -1,6 +1,6 @@
1
1
  import type { ClaudeAgentRow } from "./claude-terminal-agent-adapter.js";
2
2
  import type { TerminalCompletionEvidence, TerminalDurableCompletionRequest, TerminalThreadLifecycleCandidate, TerminalThreadLifecycleCandidateProvider, TerminalThreadLifecycleCandidateRequest, TerminalThreadLifecycleCandidateToken, TerminalThreadLifecycleCandidateValidation, TerminalThreadFileToken } from "./terminal-agent-adapter.js";
3
- import type { TerminalSubmissionAcceptanceEvidence } from "./terminal-submission-acceptance.js";
3
+ import { type TerminalSubmissionAcceptanceEvidence } from "./terminal-submission-acceptance.js";
4
4
  export interface ClaudeTranscriptAnchor {
5
5
  schema_version: 1;
6
6
  session_id: string;
@@ -27,6 +27,18 @@ export interface DetectClaudeTranscriptCompletionOptions {
27
27
  agentRows: readonly ClaudeAgentRow[];
28
28
  maxTurnBytes?: number;
29
29
  }
30
+ export interface ObserveClaudeDeadProcessCompletionOptions extends DetectClaudeTranscriptCompletionOptions {
31
+ acceptanceEvidence: unknown;
32
+ }
33
+ export type ClaudeDeadProcessCompletionObservation = {
34
+ status: "present";
35
+ completion: TerminalCompletionEvidence;
36
+ } | {
37
+ status: "absent";
38
+ } | {
39
+ status: "unverifiable";
40
+ reason: string;
41
+ };
30
42
  export interface ClaudeHistoricalSessionSummary {
31
43
  id: string;
32
44
  cwd: string;
@@ -95,6 +107,14 @@ export declare function captureClaudeTranscriptAnchor(options: CaptureClaudeTran
95
107
  * background-work, and chain ambiguity.
96
108
  */
97
109
  export declare function detectClaudeTranscriptCompletion(request: TerminalDurableCompletionRequest, options: DetectClaudeTranscriptCompletionOptions): TerminalCompletionEvidence | undefined;
110
+ /**
111
+ * Observes completion after the exact bound Claude process is independently
112
+ * proven dead. Unlike the ordinary live monitor detector, this API separates
113
+ * a stable, fully inspected transcript with no completion from a transcript
114
+ * that cannot be inspected or tied back to the durable native-acceptance
115
+ * receipt. Callers may treat only `absent` as authority for orphan cleanup.
116
+ */
117
+ export declare function observeClaudeDeadProcessTranscriptCompletion(request: TerminalDurableCompletionRequest, options: ObserveClaudeDeadProcessCompletionOptions): ClaudeDeadProcessCompletionObservation;
98
118
  /**
99
119
  * Detects native acceptance as soon as Claude appends the unique root user
100
120
  * row matching the managed request after the immutable pre-send byte anchor.
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { claudeLifecycleBehaviorProfile, claudeLifecycleSourceVersionSupported, DEFAULT_CLAUDE_LIFECYCLE_VERSION, supportedClaudeLifecycleVersions } from "./claude-lifecycle-compatibility.js";
6
6
  import { redactString } from "./runtime-log.js";
7
+ import { validateTerminalSubmissionAcceptanceEvidence } from "./terminal-submission-acceptance.js";
7
8
  const CLAUDE_TRANSCRIPT_ANCHOR_VERSION = 1;
8
9
  const CLAUDE_TRANSCRIPT_MAX_TURN_BYTES = 64 * 1024 * 1024;
9
10
  const CLAUDE_SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
@@ -440,6 +441,46 @@ export function detectClaudeTranscriptCompletion(request, options) {
440
441
  }
441
442
  return completionFromRecords(snapshot);
442
443
  }
444
+ /**
445
+ * Observes completion after the exact bound Claude process is independently
446
+ * proven dead. Unlike the ordinary live monitor detector, this API separates
447
+ * a stable, fully inspected transcript with no completion from a transcript
448
+ * that cannot be inspected or tied back to the durable native-acceptance
449
+ * receipt. Callers may treat only `absent` as authority for orphan cleanup.
450
+ */
451
+ export function observeClaudeDeadProcessTranscriptCompletion(request, options) {
452
+ try {
453
+ const snapshot = readClaudeTranscriptTurnSnapshot(request, options, "idle", "verified_dead_process");
454
+ if (!snapshot) {
455
+ return {
456
+ status: "unverifiable",
457
+ reason: "Claude transcript could not be read with exact dead-process authority"
458
+ };
459
+ }
460
+ const observedAcceptance = acceptanceEvidenceFromSnapshot(snapshot);
461
+ if (!observedAcceptance) {
462
+ throw new Error("the exact accepted Claude prompt is absent from the anchored transcript");
463
+ }
464
+ const persistedAcceptance = validateTerminalSubmissionAcceptanceEvidence(options.acceptanceEvidence, {
465
+ source: "claude_transcript",
466
+ nativeThreadId: snapshot.sessionId,
467
+ requestHash: snapshot.expectedRequestHash
468
+ });
469
+ assertSameClaudeTranscriptAcceptance(persistedAcceptance, observedAcceptance, snapshot);
470
+ const completion = completionFromRecords(snapshot, {
471
+ requireVerifiableCompletionSignal: true
472
+ });
473
+ return completion
474
+ ? { status: "present", completion }
475
+ : { status: "absent" };
476
+ }
477
+ catch (error) {
478
+ return {
479
+ status: "unverifiable",
480
+ reason: error instanceof Error ? error.message : String(error)
481
+ };
482
+ }
483
+ }
443
484
  /**
444
485
  * Detects native acceptance as soon as Claude appends the unique root user
445
486
  * row matching the managed request after the immutable pre-send byte anchor.
@@ -451,6 +492,9 @@ export function detectClaudeTranscriptAcceptance(request, options) {
451
492
  if (!snapshot) {
452
493
  return undefined;
453
494
  }
495
+ return acceptanceEvidenceFromSnapshot(snapshot);
496
+ }
497
+ function acceptanceEvidenceFromSnapshot(snapshot) {
454
498
  const prompt = matchingManagedPrompt(snapshot);
455
499
  if (!prompt) {
456
500
  return undefined;
@@ -483,6 +527,35 @@ export function detectClaudeTranscriptAcceptance(request, options) {
483
527
  evidenceFingerprint: sha256Hex(JSON.stringify(evidenceBase))
484
528
  };
485
529
  }
530
+ function assertSameClaudeTranscriptAcceptance(persisted, observed, snapshot) {
531
+ const persistedMetadata = isRecord(persisted.metadata)
532
+ ? persisted.metadata
533
+ : undefined;
534
+ const observedMetadata = isRecord(observed.metadata)
535
+ ? observed.metadata
536
+ : undefined;
537
+ const persistedEndOffset = nonNegativeInteger(persistedMetadata?.observed_end_offset_bytes);
538
+ if (persisted.source !== "claude_transcript" ||
539
+ persisted.kind !== "native_user_turn" ||
540
+ persisted.acceptanceId !== observed.acceptanceId ||
541
+ persisted.acceptedAt !== observed.acceptedAt ||
542
+ persisted.anchorFingerprint !== observed.anchorFingerprint ||
543
+ !persistedMetadata ||
544
+ !observedMetadata ||
545
+ persistedMetadata.prompt_uuid !== observedMetadata.prompt_uuid ||
546
+ persistedMetadata.claude_version !== observedMetadata.claude_version ||
547
+ persistedMetadata.transcript_file_id !==
548
+ observedMetadata.transcript_file_id ||
549
+ persistedMetadata.anchor_offset_bytes !==
550
+ observedMetadata.anchor_offset_bytes ||
551
+ persistedMetadata.agent_started_at_ms !==
552
+ observedMetadata.agent_started_at_ms ||
553
+ persistedEndOffset === undefined ||
554
+ persistedEndOffset <= snapshot.anchor.offset_bytes ||
555
+ persistedEndOffset > snapshot.observedEndOffsetBytes) {
556
+ throw new Error("persisted Claude acceptance evidence does not match the exact anchored prompt");
557
+ }
558
+ }
486
559
  /**
487
560
  * Detects exactly one unresolved foreground Bash tool use for the current
488
561
  * AKK-managed Claude turn. It uses the same anchored, owner-private,
@@ -496,10 +569,16 @@ export function detectClaudeTranscriptPendingApproval(request, options) {
496
569
  }
497
570
  return pendingApprovalFromRecords(snapshot);
498
571
  }
499
- function readClaudeTranscriptTurnSnapshot(request, options, requiredAgentStatus) {
572
+ function readClaudeTranscriptTurnSnapshot(request, options, requiredAgentStatus, readMode = "live_monitor") {
573
+ const unavailable = (reason) => {
574
+ if (readMode === "verified_dead_process") {
575
+ throw new Error(reason);
576
+ }
577
+ return undefined;
578
+ };
500
579
  const anchor = transcriptAnchorFromContext(request.context);
501
580
  if (!anchor) {
502
- return undefined;
581
+ return unavailable("Claude transcript anchor is unavailable");
503
582
  }
504
583
  const sessionId = nonEmptyString(request.sessionId);
505
584
  const cwd = nonEmptyString(request.cwd);
@@ -516,7 +595,7 @@ function readClaudeTranscriptTurnSnapshot(request, options, requiredAgentStatus)
516
595
  expectedRequestHash !== requestTextHash ||
517
596
  startedAtMs === undefined ||
518
597
  capturedAtMs === undefined) {
519
- return undefined;
598
+ return unavailable("Claude transcript request metadata cannot be verified");
520
599
  }
521
600
  if (anchor.schema_version !== CLAUDE_TRANSCRIPT_ANCHOR_VERSION ||
522
601
  anchor.session_id !== sessionId ||
@@ -538,15 +617,15 @@ function readClaudeTranscriptTurnSnapshot(request, options, requiredAgentStatus)
538
617
  throw new Error("the Claude process session identity changed after the managed send");
539
618
  }
540
619
  if (requiredAgentStatus && agent.status !== requiredAgentStatus) {
541
- return undefined;
620
+ return unavailable(`the exact Claude process is ${agent.status}, not ${requiredAgentStatus}`);
542
621
  }
543
622
  const projectsRoot = projectsRootPath(path.resolve(options.claudeHome ?? defaultClaudeHome()));
544
623
  if (!isRealDirectory(projectsRoot)) {
545
- return undefined;
624
+ return unavailable("Claude transcript projects directory is unavailable");
546
625
  }
547
626
  const opened = openAnchoredTranscript(projectsRoot, anchor);
548
627
  if (!opened) {
549
- return undefined;
628
+ return unavailable("the anchored Claude transcript is unavailable");
550
629
  }
551
630
  try {
552
631
  if (anchor.file_existed &&
@@ -558,20 +637,24 @@ function readClaudeTranscriptTurnSnapshot(request, options, requiredAgentStatus)
558
637
  }
559
638
  const bytesToRead = opened.stat.size - anchor.offset_bytes;
560
639
  if (bytesToRead === 0) {
561
- return undefined;
640
+ return unavailable("the anchored Claude transcript contains no post-send records");
562
641
  }
563
642
  const maxTurnBytes = positiveInteger(options.maxTurnBytes) ??
564
643
  CLAUDE_TRANSCRIPT_MAX_TURN_BYTES;
565
644
  if (bytesToRead > maxTurnBytes) {
566
645
  throw new Error("Claude transcript turn exceeded the bounded local read limit");
567
646
  }
647
+ if (readMode === "verified_dead_process" &&
648
+ !fileEndsWithNewline(opened.fd, opened.stat.size)) {
649
+ throw new Error("Claude transcript ends with an incomplete JSONL record");
650
+ }
568
651
  const records = readCompleteJsonlRecords(opened.fd, anchor.offset_bytes, bytesToRead);
569
652
  const stableStat = fs.fstatSync(opened.fd);
570
653
  if (!sameStableTranscriptFile(opened.stat, stableStat)) {
571
- return undefined;
654
+ return unavailable("Claude transcript changed while dead-process completion was inspected");
572
655
  }
573
656
  if (records.length === 0) {
574
- return undefined;
657
+ return unavailable("Claude transcript contains no complete post-send records");
575
658
  }
576
659
  const fileIdentity = `${opened.stat.dev}:${opened.stat.ino}`;
577
660
  return {
@@ -618,7 +701,7 @@ function matchingManagedPrompt(snapshot) {
618
701
  assertSupportedRecord(prompt, snapshot.sessionId, snapshot.cwd);
619
702
  return prompt;
620
703
  }
621
- function completionFromRecords(snapshot) {
704
+ function completionFromRecords(snapshot, options = {}) {
622
705
  const { records, sessionId, transcriptFileId: fileId } = snapshot;
623
706
  const prompt = matchingManagedPrompt(snapshot);
624
707
  if (!prompt) {
@@ -636,7 +719,20 @@ function completionFromRecords(snapshot) {
636
719
  record.isSidechain !== true &&
637
720
  nonEmptyString(record.agentId) === undefined &&
638
721
  userPromptText(record) !== undefined);
722
+ if (options.requireVerifiableCompletionSignal === true &&
723
+ nextHumanPromptIndex >= 0) {
724
+ const nextHumanPrompt = records[nextHumanPromptIndex];
725
+ if (!nextHumanPrompt || uuidValue(nextHumanPrompt.uuid) === undefined) {
726
+ throw new Error("Claude transcript next human prompt has no stable UUID");
727
+ }
728
+ assertSupportedRecord(nextHumanPrompt, sessionId, snapshot.cwd);
729
+ }
639
730
  const turnRecords = records.slice(promptIndex, nextHumanPromptIndex < 0 ? records.length : nextHumanPromptIndex);
731
+ if (options.requireVerifiableCompletionSignal === true &&
732
+ turnRecords.some((record) => (["user", "assistant", "system"].includes(String(record.type)) ||
733
+ hasTurnCompletionSignal(record)) && uuidValue(record.uuid) === undefined)) {
734
+ throw new Error("Claude transcript turn contains a completion-relevant record without a stable UUID");
735
+ }
640
736
  const recordsByUuid = new Map();
641
737
  for (const record of turnRecords) {
642
738
  const uuid = uuidValue(record.uuid);
@@ -658,16 +754,23 @@ function completionFromRecords(snapshot) {
658
754
  assertSupportedRecord(record, sessionId);
659
755
  }
660
756
  assertSameClaudeVersion(...descendants);
757
+ const absentOrUnverifiable = () => {
758
+ if (options.requireVerifiableCompletionSignal === true &&
759
+ descendants.some(hasTurnCompletionSignal)) {
760
+ throw new Error("Claude transcript contains a completion signal without one complete verifiable completion chain");
761
+ }
762
+ return undefined;
763
+ };
661
764
  if (descendants.some((record) => record.isSidechain === true ||
662
765
  nonEmptyString(record.agentId) !== undefined ||
663
766
  hasUnresolvedBackgroundWork(record))) {
664
- return undefined;
767
+ return absentOrUnverifiable();
665
768
  }
666
769
  if (hasUnresolvedToolUse(descendants)) {
667
- return undefined;
770
+ return absentOrUnverifiable();
668
771
  }
669
772
  if (descendants.some(hasBlockingStopSummary)) {
670
- return undefined;
773
+ return absentOrUnverifiable();
671
774
  }
672
775
  const durations = descendantRecords(turnRecords, recordsByUuid, promptUuid, (record) => record.type === "system" && record.subtype === "turn_duration");
673
776
  if (durations.length > 1) {
@@ -682,7 +785,7 @@ function completionFromRecords(snapshot) {
682
785
  const lastDescendant = [...turnRecords].reverse().find((record) => uuidValue(record.uuid) !== undefined &&
683
786
  descendantChain(recordsByUuid, promptUuid, record) !== undefined);
684
787
  if (!failure || failure !== lastDescendant) {
685
- return undefined;
788
+ return absentOrUnverifiable();
686
789
  }
687
790
  assertSupportedRecord(failure, sessionId);
688
791
  assertSameClaudeVersion(prompt, failure);
@@ -711,7 +814,7 @@ function completionFromRecords(snapshot) {
711
814
  }
712
815
  const chain = descendantChain(recordsByUuid, promptUuid, duration);
713
816
  if (!chain) {
714
- return undefined;
817
+ return absentOrUnverifiable();
715
818
  }
716
819
  const finalAssistant = [...chain].reverse().find((record) => record.type === "assistant" &&
717
820
  isRecord(record.message) &&
@@ -731,6 +834,9 @@ function completionFromRecords(snapshot) {
731
834
  }
732
835
  const assistantText = assistantTextForMessage(turnRecords, recordsByUuid, promptUuid, finalAssistant);
733
836
  if (!assistantText) {
837
+ if (options.requireVerifiableCompletionSignal === true) {
838
+ throw new Error("Claude completion signal has no verifiable assistant text");
839
+ }
734
840
  return undefined;
735
841
  }
736
842
  const promptId = nonEmptyString(prompt.promptId);