@scotthuang/agent-knock-knock 0.10.3 → 0.11.0

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,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.11.0 - 2026-08-07
4
+
5
+ ### Added
6
+
7
+ - Add snapshot-bound native Resume navigation with deterministic numbers, collision-safe display-only short IDs, opaque five-minute handles, and a `previous` / `刚才那个` shortcut derived from the current Session's latest committed lifecycle transition.
8
+
9
+ ### Security
10
+
11
+ - Resolve every Resume shortcut back to the complete UUID and exact evidence tuple, then fail closed if the terminal action generation, process, workspace, binding, ordered candidate snapshot, ownership, or TTL changed before native input.
12
+
13
+ ## 0.10.4 - 2026-08-07
14
+
15
+ ### Fixed
16
+
17
+ - Recognize exact Codex multiline drafts across TUI-painted visual wraps before dispatching Enter once, while preserving authored newlines, indentation, repeated spaces, stable-capture checks, and fail-closed content drift detection.
18
+
3
19
  ## 0.10.3 - 2026-08-07
4
20
 
5
21
  ### Added
package/README.md CHANGED
@@ -214,7 +214,7 @@ The core command surface is intentionally small:
214
214
  /akk threads <exact-terminal-id>
215
215
  /akk new-thread <exact-terminal-id>
216
216
  /akk clear-thread <exact-terminal-id>
217
- /akk resume-thread <exact-terminal-id> [native-thread-uuid]
217
+ /akk resume-thread <exact-terminal-id> [uuid|previous|number|@short-id|snapshot-handle]
218
218
  /akk status [only|latest|codex|claude|@short-ref]
219
219
  /akk respond <turn-selector>: <answer>
220
220
  /akk cancel <turn-selector>
@@ -224,13 +224,13 @@ The core command surface is intentionally small:
224
224
 
225
225
  Selectors fail closed: `only` works only with one actionable target, `latest` requires a unique newest target, and `codex` or `claude` must identify exactly one eligible pane. These names and `@short-ref` are human-facing resolution inputs; a natural-language tool call may preserve one explicitly named by the user, but must not infer one. Managed JSON actions contain the authoritative full `session_id` or `turn_id`. For first attach, an unmanaged raw-terminal row's send action may instead contain its own prefilled `selector`; its advertised raw controls may contain that row's prefilled `conversation_id`. Neither compatibility selector may be guessed, copied from another row, or passed in an authoritative ID field. Before every terminal operation, AKK revalidates the expected agent PID and tmux pane identity, then confirms that the process and pane working directories still match; every send also revalidates the idle prompt immediately before typing.
226
226
 
227
- To change native context, first copy the full `terminal_id` from `/akk list`; lifecycle commands do not accept `@short-ref` or loose agent selectors. `/akk threads <exact-terminal-id>` lists exact, same-workspace resume candidates. `/akk resume-thread <exact-terminal-id>` without a UUID shows the same candidates and asks you to choose; with a complete returned UUID it performs the transition. `/akk new-thread` and its human alias `/akk clear-thread` start a clean context. For these slash forms, AKK reads a fresh lifecycle snapshot and immediately supplies its compare-and-swap binding token internally, so you do not copy the token yourself. AKK does not poll bindings in the background: if a recorded owner process exits, the next lifecycle listing can classify that sole historical binding as resumable, and the resume mutation compare-and-swap detaches it before touching the terminal. Live, stale-token, unsupported, busy, ambiguous, active-elsewhere, or unverifiable transitions fail closed. Do not send `/clear`, `/new`, `/resume`, `/status`, Codex `/fork`, `/side`, or `/btw`, Claude `/branch`, or any other first-line native slash command as an ordinary task or answer; use an advertised AKK action, express the request in natural language, or enter an unsupported native command manually in tmux.
227
+ To change native context, first copy the full `terminal_id` from `/akk list`; lifecycle commands do not accept an ordinary-send `@short-ref` or loose agent selector. `/akk threads <exact-terminal-id>` lists exact, same-workspace candidates with a deterministic number, a collision-safe display-only `@short-id`, an opaque snapshot handle, and the complete UUID. `/akk resume-thread <exact-terminal-id>` without a selection shows that list. A complete UUID remains compatible. A number or short ID resolves only against the latest list displayed in the same OpenClaw session, while an opaque handle names its exact snapshot; all expire after five minutes and fail after terminal, process, workspace, binding, candidate-set, or relevant action changes. None is ever passed to Codex or Claude Code as native identity: AKK resolves the saved tuple back to its full UUID and fresh evidence tokens first. `previous` (or `刚才那个`) is advertised only when the current Session's latest committed lifecycle transition identifies exactly one currently verified resumable source; it never guesses from title, recency, or static lineage. `/akk new-thread` and its human alias `/akk clear-thread` start a clean context. AKK does not poll bindings in the background: if a recorded owner process exits, the next lifecycle listing can classify that sole historical binding as resumable, and the resume mutation compare-and-swap detaches it before touching the terminal. Live, stale, expired, unsupported, busy, ambiguous, active-elsewhere, or unverifiable transitions fail closed. Do not send `/clear`, `/new`, `/resume`, `/status`, Codex `/fork`, `/side`, or `/btw`, Claude `/branch`, or any other first-line native slash command as an ordinary task or answer; use an advertised AKK action, express the request in natural language, or enter an unsupported native command manually in tmux.
228
228
 
229
229
  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. 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.
230
230
 
231
231
  Use only an `available_actions` entry returned in that snapshot, begin with its prefilled authoritative arguments, and supply every `missing_required` field. A managed Session's `send` uses its prefilled `session_id` and creates a new Turn. For first attach only, 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`. A raw terminal may be controlled only through the exact status, approval, cancellation, or orphan-close action that its own row advertises with a prefilled `conversation_id`; never construct or guess one. For an ordinary send, add only `request`—`timeoutSeconds` is unsupported, and monitoring limits should be omitted unless the user explicitly asks to change them. AKK revalidates availability before every side effect.
232
232
 
233
- The top-level v5 `action_contracts` adds `list_resumable_threads`, `new_thread`, and `resume_thread`. The terminal row advertises `list_resumable_threads` and, when currently safe, `new_thread`. Listing is read-only, takes only the full `terminal_id`, and returns a fresh `expected_binding_token` plus candidate rows; each `resumable=true` candidate row advertises its own `resume_thread` action. The `new_thread` and `resume_thread` mutations require that fresh token, and resume additionally requires the candidate's complete `native_thread_id` and opaque `candidate_token`. Never construct, guess, truncate, combine across snapshots, or reuse those values after another terminal action. A lifecycle result contains Session and native-thread identities but no `turn_id` because no work was sent.
233
+ The top-level v5 `action_contracts` adds `list_resumable_threads`, `new_thread`, and `resume_thread`. The terminal row advertises `list_resumable_threads` and, when currently safe, `new_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`. Never construct, guess, truncate, combine across snapshots, or reuse those values after another terminal action. A lifecycle result contains Session and native-thread identities but no `turn_id` because no work was sent.
234
234
 
235
235
  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.
236
236
 
package/dist/src/cli.js CHANGED
@@ -18,6 +18,7 @@ import { formatTranscript, readNdjsonLog } from "./transcript.js";
18
18
  import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, ensureStoreWritable, inspectStoreCompatibility, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, StoreLockTimeoutError, statePathForConversationId, withStoreWriterLease, withStoreWriterLeaseAsync } from "./store.js";
19
19
  import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
20
20
  import { classifyCodexLifecyclePostcondition, evaluateResumeCandidateAvailability, hasStrongCodexLifecycleIdentity, isFreshCodexPostProbeScreen } from "./native-thread-lifecycle-policy.js";
21
+ import { createNativeThreadResumeSnapshot, nativeThreadCandidateSnapshotFingerprint, nativeThreadResumeSnapshotRowsMatchCandidates, resolveNativeThreadResumeSelection, saveNativeThreadResumeSnapshot, sortNativeThreadCandidates, terminalActionFingerprint, verifiedPreviousResumeCandidate } from "./native-thread-resume-snapshot.js";
21
22
  import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
22
23
  import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
23
24
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
@@ -5219,7 +5220,7 @@ async function resumableThreadCandidates({ options, terminal, currentIdentity })
5219
5220
  });
5220
5221
  }
5221
5222
  }
5222
- return [...deduplicated.values()]
5223
+ return sortNativeThreadCandidates([...deduplicated.values()]
5223
5224
  .map((candidate) => {
5224
5225
  const nativeThreadId = candidate.native_thread_id;
5225
5226
  const managed = managedByNativeId.get(nativeThreadId) ?? [];
@@ -5255,8 +5256,7 @@ async function resumableThreadCandidates({ options, terminal, currentIdentity })
5255
5256
  ? new Date(Number(candidate.updated_at_ms)).toISOString()
5256
5257
  : undefined
5257
5258
  };
5258
- })
5259
- .sort((left, right) => Number(right.updated_at_ms ?? 0) - Number(left.updated_at_ms ?? 0));
5259
+ }));
5260
5260
  }
5261
5261
  function encodeThreadCandidateToken(token) {
5262
5262
  return Buffer.from(JSON.stringify(token), "utf8").toString("base64url");
@@ -5506,37 +5506,113 @@ async function runListResumableThreads(options) {
5506
5506
  terminal,
5507
5507
  currentIdentity: snapshot.identity
5508
5508
  });
5509
+ const storeDir = storeDirFromOptions(options);
5510
+ const workspace = path.resolve(terminal.terminalControl.currentPath ?? process.cwd());
5511
+ const selectionScope = stringValue(options.selectionScope) ?? "cli:unscoped";
5512
+ const resumeSnapshot = createNativeThreadResumeSnapshot({
5513
+ storeDir,
5514
+ selectionScope,
5515
+ terminalId: terminal.conversationId,
5516
+ agent: terminal.agent,
5517
+ workspace,
5518
+ terminalControl: terminal.terminalControl,
5519
+ currentSessionId: snapshot.session?.session_id,
5520
+ currentNativeThreadId: snapshot.identity?.sessionId ??
5521
+ snapshot.session?.binding?.native_thread_id,
5522
+ expectedBindingToken: snapshot.bindingToken,
5523
+ terminalActionFingerprint: terminalActionFingerprint(loadTerminalBridgeDispatchLedger(terminal.terminalControl)),
5524
+ candidates
5525
+ });
5526
+ saveNativeThreadResumeSnapshot(terminalBridgeRuntimeDir(), storeDir, resumeSnapshot);
5527
+ const snapshotRows = new Map(resumeSnapshot.rows.map((row) => [row.native_thread_id, row]));
5528
+ const resumeAction = (candidate) => ({
5529
+ tool: "agent_knock_knock_resume_thread",
5530
+ arguments: {
5531
+ terminal_id: terminal.conversationId,
5532
+ native_thread_id: candidate.native_thread_id,
5533
+ expected_binding_token: snapshot.bindingToken,
5534
+ ...(candidate.candidate_token
5535
+ ? { candidate_token: candidate.candidate_token }
5536
+ : {})
5537
+ },
5538
+ requires_user_intent: true
5539
+ });
5540
+ const previousCandidate = previousCommittedResumeCandidate({
5541
+ storeDir,
5542
+ terminal,
5543
+ currentSession: snapshot.session,
5544
+ candidates
5545
+ });
5546
+ const previousSnapshotRow = previousCandidate
5547
+ ? snapshotRows.get(previousCandidate.native_thread_id)
5548
+ : undefined;
5509
5549
  printJson({
5510
5550
  terminal_id: terminal.conversationId,
5511
5551
  agent: terminal.agent,
5512
- workspace: terminal.terminalControl.currentPath,
5552
+ workspace,
5513
5553
  current_session_id: snapshot.session?.session_id ?? null,
5514
5554
  current_native_thread_id: snapshot.identity?.sessionId ??
5515
5555
  snapshot.session?.binding?.native_thread_id ??
5516
5556
  null,
5517
5557
  expected_binding_token: snapshot.bindingToken,
5518
5558
  capability: snapshot.capabilities,
5559
+ selection_snapshot: {
5560
+ schema: resumeSnapshot.schema,
5561
+ version: resumeSnapshot.version,
5562
+ snapshot_id: resumeSnapshot.snapshot_id,
5563
+ created_at: resumeSnapshot.created_at,
5564
+ expires_at: resumeSnapshot.expires_at,
5565
+ scope: "exact selection snapshot, scope, and terminal",
5566
+ display_only: true
5567
+ },
5568
+ ...(previousCandidate && previousSnapshotRow
5569
+ ? {
5570
+ previous: {
5571
+ keyword: "previous",
5572
+ native_thread_id: previousCandidate.native_thread_id,
5573
+ selection_number: previousSnapshotRow.selection_number,
5574
+ short_id: previousSnapshotRow.short_id,
5575
+ selection_handle: previousSnapshotRow.selection_handle,
5576
+ available_actions: {
5577
+ resume_thread: resumeAction(previousCandidate)
5578
+ }
5579
+ }
5580
+ }
5581
+ : {}),
5519
5582
  threads: candidates.map((candidate) => ({
5520
5583
  ...candidate,
5584
+ selection_number: snapshotRows.get(candidate.native_thread_id)?.selection_number,
5585
+ short_id: snapshotRows.get(candidate.native_thread_id)?.short_id,
5586
+ selection_handle: snapshotRows.get(candidate.native_thread_id)?.selection_handle,
5587
+ selection_scope: "current_snapshot",
5521
5588
  available_actions: candidate.resumable
5522
5589
  ? {
5523
- resume_thread: {
5524
- tool: "agent_knock_knock_resume_thread",
5525
- arguments: {
5526
- terminal_id: terminal.conversationId,
5527
- native_thread_id: candidate.native_thread_id,
5528
- expected_binding_token: snapshot.bindingToken,
5529
- ...(candidate.candidate_token
5530
- ? { candidate_token: candidate.candidate_token }
5531
- : {})
5532
- },
5533
- requires_user_intent: true
5534
- }
5590
+ resume_thread: resumeAction(candidate)
5535
5591
  }
5536
5592
  : {}
5537
5593
  }))
5538
5594
  });
5539
5595
  }
5596
+ function previousCommittedResumeCandidate({ storeDir, terminal, currentSession, candidates }) {
5597
+ if (!currentSession?.last_transition_id) {
5598
+ return undefined;
5599
+ }
5600
+ let transition;
5601
+ try {
5602
+ transition = loadNativeThreadTransition(storeDir, currentSession.last_transition_id);
5603
+ }
5604
+ catch {
5605
+ return undefined;
5606
+ }
5607
+ return verifiedPreviousResumeCandidate({
5608
+ terminalId: terminal.conversationId,
5609
+ agent: terminal.agent,
5610
+ workspace: terminal.terminalControl.currentPath ?? process.cwd(),
5611
+ currentSession,
5612
+ transition,
5613
+ candidates
5614
+ });
5615
+ }
5540
5616
  async function waitForVerifiedThreadTransition({ options, terminal, beforeIdentity, physicalBeforeIdentity, allowedCompanionIdentity, allowedAdditionalIdentities = [], operation, plan, initialScreenDigest }) {
5541
5617
  const bridge = createTerminalAgentBridge(options);
5542
5618
  let probeSent = false;
@@ -5812,15 +5888,96 @@ async function runNewThread(options) {
5812
5888
  });
5813
5889
  }
5814
5890
  async function runResumeThread(options) {
5815
- const nativeThreadId = required(stringValue(options.nativeThread ?? options.nativeThreadId), "--native-thread is required");
5816
- if (!isExactNativeThreadId(nativeThreadId)) {
5817
- throw new Error("--native-thread must be a complete native thread UUID");
5891
+ const exactNativeThreadId = stringValue(options.nativeThread ?? options.nativeThreadId);
5892
+ const hasSnapshotSelection = Boolean(options.selectionSnapshot ||
5893
+ options.selectionNumber ||
5894
+ options.selectionShortId ||
5895
+ options.selectionHandle);
5896
+ if (exactNativeThreadId && hasSnapshotSelection) {
5897
+ throw new Error("--native-thread cannot be combined with a snapshot-bound selection");
5898
+ }
5899
+ if (exactNativeThreadId) {
5900
+ if (!isExactNativeThreadId(exactNativeThreadId)) {
5901
+ throw new Error("--native-thread must be a complete native thread UUID");
5902
+ }
5903
+ return runNativeThreadTransition(options, {
5904
+ kind: "resume_thread",
5905
+ nativeThreadId: exactNativeThreadId.toLowerCase()
5906
+ });
5818
5907
  }
5819
- return runNativeThreadTransition(options, {
5908
+ if (!hasSnapshotSelection) {
5909
+ throw new Error("--native-thread or a snapshot-bound resume selection is required");
5910
+ }
5911
+ const terminal = await resolveLifecycleTerminal(options);
5912
+ const storeDir = storeDirFromOptions(options);
5913
+ const selectionScope = required(stringValue(options.selectionScope), "--selection-scope is required for snapshot-bound resume");
5914
+ const rawSelectionNumber = options.selectionNumber;
5915
+ if (rawSelectionNumber !== undefined &&
5916
+ (typeof rawSelectionNumber !== "string" ||
5917
+ !/^[1-9][0-9]*$/u.test(rawSelectionNumber))) {
5918
+ throw new Error("--selection-number must be a positive integer");
5919
+ }
5920
+ const selectionNumber = rawSelectionNumber === undefined
5921
+ ? undefined
5922
+ : Number(rawSelectionNumber);
5923
+ if (selectionNumber !== undefined &&
5924
+ !Number.isSafeInteger(selectionNumber)) {
5925
+ throw new Error("--selection-number must be a positive safe integer");
5926
+ }
5927
+ const selection = resolveNativeThreadResumeSelection({
5928
+ runtimeDir: terminalBridgeRuntimeDir(),
5929
+ storeDir,
5930
+ terminalId: terminal.conversationId,
5931
+ selectionScope,
5932
+ snapshotId: stringValue(options.selectionSnapshot),
5933
+ selectionNumber,
5934
+ shortId: stringValue(options.selectionShortId),
5935
+ selectionHandle: stringValue(options.selectionHandle)
5936
+ });
5937
+ assertResumeSnapshotMatchesTerminal(selection.snapshot, terminal);
5938
+ assertResumeSnapshotActionFingerprint(selection.snapshot, terminal);
5939
+ const selectedOptions = {
5940
+ ...options,
5941
+ expectedBindingToken: selection.snapshot.expected_binding_token,
5942
+ candidateToken: selection.row.candidate_token
5943
+ };
5944
+ return runNativeThreadTransition(selectedOptions, {
5820
5945
  kind: "resume_thread",
5821
- nativeThreadId: nativeThreadId.toLowerCase()
5946
+ nativeThreadId: selection.row.native_thread_id.toLowerCase(),
5947
+ selectionSnapshot: selection.snapshot
5822
5948
  });
5823
5949
  }
5950
+ function assertResumeSnapshotMatchesTerminal(snapshot, terminal) {
5951
+ const workspace = path.resolve(terminal.terminalControl.currentPath ?? process.cwd());
5952
+ if (snapshot.terminal_id !== terminal.conversationId ||
5953
+ snapshot.agent !== terminal.agent ||
5954
+ path.resolve(snapshot.workspace) !== workspace ||
5955
+ snapshot.terminal_control.target !== terminal.terminalControl.target ||
5956
+ snapshot.terminal_control.socket_path !== terminal.terminalControl.socketPath ||
5957
+ snapshot.terminal_control.pane_pid !== terminal.terminalControl.panePid) {
5958
+ throw new Error("resume selection terminal, process, or workspace changed; run /akk threads again");
5959
+ }
5960
+ }
5961
+ function assertResumeSnapshotActionFingerprint(snapshot, terminal) {
5962
+ const current = terminalActionFingerprint(loadTerminalBridgeDispatchLedger(terminal.terminalControl));
5963
+ if (current !== snapshot.terminal_action_fingerprint) {
5964
+ throw new Error("terminal action history changed after the resume snapshot; run /akk threads again");
5965
+ }
5966
+ }
5967
+ function assertResumeSnapshotCandidates(snapshot, candidates) {
5968
+ if (nativeThreadCandidateSnapshotFingerprint(candidates) !==
5969
+ snapshot.candidate_snapshot_fingerprint) {
5970
+ throw new Error("resume candidates changed or reordered after the snapshot; run /akk threads again");
5971
+ }
5972
+ if (!nativeThreadResumeSnapshotRowsMatchCandidates(snapshot, candidates)) {
5973
+ throw new Error("resume selection rows no longer match the exact candidate snapshot; run /akk threads again");
5974
+ }
5975
+ }
5976
+ function assertResumeSnapshotNotExpired(snapshot) {
5977
+ if (Date.parse(snapshot.expires_at) <= Date.now()) {
5978
+ throw new Error("resume selection snapshot expired; run /akk threads again");
5979
+ }
5980
+ }
5824
5981
  async function runNativeThreadTransition(options, operation) {
5825
5982
  const initiallyResolved = await resolveLifecycleTerminal(options);
5826
5983
  const storeDir = storeDirFromOptions(options);
@@ -5833,7 +5990,18 @@ async function runNativeThreadTransition(options, operation) {
5833
5990
  terminalControlSelectorKey(initiallyResolved.terminalControl)) {
5834
5991
  throw new Error("terminal identity changed while waiting for lifecycle control; refresh list");
5835
5992
  }
5993
+ if (operation.kind === "resume_thread" && operation.selectionSnapshot) {
5994
+ assertResumeSnapshotNotExpired(operation.selectionSnapshot);
5995
+ assertResumeSnapshotMatchesTerminal(operation.selectionSnapshot, terminal);
5996
+ assertResumeSnapshotActionFingerprint(operation.selectionSnapshot, terminal);
5997
+ }
5836
5998
  await recoverLifecycleFenceBeforeMutation({ options, terminal });
5999
+ if (operation.kind === "resume_thread" && operation.selectionSnapshot) {
6000
+ // Recovery may resolve or otherwise rewrite a durable dispatch fence.
6001
+ // A snapshot created before that mutation is stale even when the
6002
+ // binding and candidate files themselves did not change.
6003
+ assertResumeSnapshotActionFingerprint(operation.selectionSnapshot, terminal);
6004
+ }
5837
6005
  const snapshot = await currentLifecycleSnapshot(options, terminal, { materialize: true });
5838
6006
  const expectedToken = required(stringValue(options.expectedBindingToken), "--expected-binding-token is required");
5839
6007
  if (expectedToken !== snapshot.bindingToken) {
@@ -5850,6 +6018,14 @@ async function runNativeThreadTransition(options, operation) {
5850
6018
  snapshot.capabilities.resumeExact !== true) {
5851
6019
  throw new Error(snapshot.capabilities.reason);
5852
6020
  }
6021
+ if (operation.kind === "resume_thread" && operation.selectionSnapshot) {
6022
+ const guardedCandidates = await resumableThreadCandidates({
6023
+ options,
6024
+ terminal,
6025
+ currentIdentity: snapshot.identity
6026
+ });
6027
+ assertResumeSnapshotCandidates(operation.selectionSnapshot, guardedCandidates);
6028
+ }
5853
6029
  const plan = snapshot.adapter.planThreadLifecycle?.(operation, snapshot.capabilities);
5854
6030
  if (!plan) {
5855
6031
  throw new Error("the agent adapter did not produce a lifecycle plan");
@@ -5960,6 +6136,13 @@ async function runNativeThreadTransition(options, operation) {
5960
6136
  terminal,
5961
6137
  currentIdentity: beforeIdentity
5962
6138
  });
6139
+ if (operation.selectionSnapshot) {
6140
+ // The Codex identity probe and other readiness checks above are
6141
+ // asynchronous. Rebind the whole displayed snapshot, not only its
6142
+ // selected UUID, after those checks and before any lifecycle state
6143
+ // is persisted.
6144
+ assertResumeSnapshotCandidates(operation.selectionSnapshot, candidates);
6145
+ }
5963
6146
  const candidate = candidates.find((entry) => entry.native_thread_id === operation.nativeThreadId);
5964
6147
  if (!candidate) {
5965
6148
  throw new Error(`native thread ${operation.nativeThreadId} is not a verified same-workspace candidate`);
@@ -6169,6 +6352,9 @@ async function runNativeThreadTransition(options, operation) {
6169
6352
  terminalControl: terminal.terminalControl
6170
6353
  });
6171
6354
  }
6355
+ if (operation.kind === "resume_thread" && operation.selectionSnapshot) {
6356
+ assertResumeSnapshotNotExpired(operation.selectionSnapshot);
6357
+ }
6172
6358
  inputStarted = true;
6173
6359
  await bridge.send(terminal.agent, terminal.terminalControl, transitionStep.command, { runtime: beforeRuntime });
6174
6360
  const submittedAt = new Date().toISOString();
@@ -18165,8 +18351,9 @@ function usage() {
18165
18351
  agent-knock-knock send [--session <session-id|selector>] [--conversation <selector>] --message <text> [--type task] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
18166
18352
  agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
18167
18353
  agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
18168
- agent-knock-knock list-resumable-threads --terminal <exact-terminal-id>
18354
+ agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
18169
18355
  agent-knock-knock resume-thread --terminal <exact-terminal-id> --native-thread <uuid> --expected-binding-token <token> --candidate-token <token>
18356
+ agent-knock-knock resume-thread --terminal <exact-terminal-id> (--selection-handle <handle> | --selection-snapshot <id> (--selection-number <n> | --selection-short-id <@id>)) --selection-scope <opaque-scope>
18170
18357
  agent-knock-knock respond --turn <turn-id|selector> --message <text> [--conversation <selector>]
18171
18358
  agent-knock-knock approve [--turn <turn-id|selector>] [--conversation <selector>] --expected-approval-fingerprint <fingerprint>
18172
18359
  agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]