@scotthuang/agent-knock-knock 0.11.2 → 0.11.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/dist/src/cli.js CHANGED
@@ -19,12 +19,12 @@ import { appendEvent, assertStoreWriterCompatible, defaultStoreDir, ensureDir, e
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
21
  import { createNativeThreadResumeSnapshot, nativeThreadCandidateSnapshotFingerprint, nativeThreadResumeSnapshotRowsMatchCandidates, resolveNativeThreadResumeSelection, saveNativeThreadResumeSnapshot, sortNativeThreadCandidates, terminalActionFingerprint, verifiedPreviousResumeCandidate } from "./native-thread-resume-snapshot.js";
22
- import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
22
+ import { listManagedSessions, loadManagedSession, loadNativeThreadTransition, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
23
23
  import { StaticTerminalControlProvider, TmuxTerminalControlProvider, terminalPaneContainsProcess } from "./terminal-control-provider.js";
24
24
  import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
25
25
  import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
26
26
  import { parseProcessElapsedSeconds, StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
27
- import { TerminalAgentBridge } from "./terminal-agent-bridge.js";
27
+ import { TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
28
28
  import { evaluateApprovalPolicy } from "./approval-policy.js";
29
29
  import { evaluateDoctorCapabilities, runDoctorCapabilityProbes } from "./doctor-capabilities.js";
30
30
  import { runOpenClawChainDiagnostics } from "./openclaw-doctor.js";
@@ -124,7 +124,8 @@ const STORE_MUTATION_COMMANDS = new Set([
124
124
  "monitor",
125
125
  "new-thread",
126
126
  "clear-thread",
127
- "resume-thread"
127
+ "resume-thread",
128
+ "reconcile-binding"
128
129
  ]);
129
130
  class TurnBindingSupersededError extends Error {
130
131
  code = "AKK_TURN_BINDING_SUPERSEDED";
@@ -258,6 +259,9 @@ async function runCommand(commandName, options) {
258
259
  else if (commandName === "resume-thread") {
259
260
  await runResumeThread(options);
260
261
  }
262
+ else if (commandName === "reconcile-binding") {
263
+ await runReconcileBinding(options);
264
+ }
261
265
  else if (commandName === "respond") {
262
266
  await runRespond(options);
263
267
  }
@@ -2755,6 +2759,20 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2755
2759
  });
2756
2760
  }
2757
2761
  }
2762
+ const statusCardObservation = session.agent === "codex" &&
2763
+ typeof terminalState.screen_excerpt === "string"
2764
+ ? bridge.registry.require("codex").observeThreadLifecycle?.({
2765
+ operation: { kind: "new_thread" },
2766
+ phase: "before",
2767
+ screen: terminalState.screen_excerpt
2768
+ })
2769
+ : undefined;
2770
+ const statusCardNativeThreadId = statusCardObservation?.status === "observed" &&
2771
+ terminalState.activity_state === "idle" &&
2772
+ terminalState.approval_state.blocked !== true &&
2773
+ isExactNativeThreadId(statusCardObservation.nativeThreadId)
2774
+ ? statusCardObservation.nativeThreadId
2775
+ : undefined;
2758
2776
  const agentVersion = agentVersionForRunningProcess(session.agent, session.pid, options);
2759
2777
  const lifecycleCapability = bridge.registry.require(session.agent)
2760
2778
  .probeThreadLifecycle?.(agentVersion) ?? {
@@ -2790,6 +2808,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
2790
2808
  workspace: session.cwd,
2791
2809
  elapsed: session.elapsed,
2792
2810
  native_agent_session_id: nativeAgentIdentity?.sessionId,
2811
+ native_agent_status_card_session_id: statusCardNativeThreadId,
2793
2812
  native_agent_process_uuid: nativeProcessUuid,
2794
2813
  native_agent_process_birth: nativeProcessBirth,
2795
2814
  native_agent_rollout: nativeAgentIdentity?.rollout,
@@ -2863,6 +2882,16 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
2863
2882
  ? [...(sessionsByTerminal.get(terminalKey) ?? [])]
2864
2883
  : [];
2865
2884
  const matchingSessions = relatedSessions.filter((session) => managedSessionMatchesLiveTerminalEntry(session, terminal, storeDir));
2885
+ const conflictingBoundSessionClaims = relatedSessions.flatMap((session) => {
2886
+ const kind = managedBindingConflictKindForLiveTerminalEntry({
2887
+ storeDir,
2888
+ session,
2889
+ terminal
2890
+ });
2891
+ return kind && kind !== "stale_process_incarnation"
2892
+ ? [{ session, kind }]
2893
+ : [];
2894
+ });
2866
2895
  const unresolvedSessionClaims = relatedSessions.filter((session) => ["transitioning", "quarantined"].includes(session.status) &&
2867
2896
  managedSessionClaimsLiveTerminalEntry(session, terminal));
2868
2897
  const sessionAuthorityConflict = unresolvedSessionClaims.length > 0
@@ -2873,13 +2902,42 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
2873
2902
  transition_ids: unresolvedSessionClaims.map((session) => session.last_transition_id ?? null),
2874
2903
  recovery: "use only the lifecycle recovery action listed for this terminal"
2875
2904
  }
2876
- : matchingSessions.length > 1
2905
+ : conflictingBoundSessionClaims.length === 1
2877
2906
  ? {
2878
- reason: "multiple first-class managed Sessions claim the same live terminal binding",
2879
- session_ids: matchingSessions.map((session) => session.session_id),
2880
- recovery: "inspect Session state before performing a side effect"
2907
+ kind: conflictingBoundSessionClaims[0].kind,
2908
+ reason: conflictingBoundSessionClaims[0].kind === "provisional_orphan"
2909
+ ? "a failed raw attach left a bound Session without an authoritative native-thread identity"
2910
+ : conflictingBoundSessionClaims[0].kind ===
2911
+ "live_external_thread_change"
2912
+ ? "the live coding-agent thread changed outside AKK while its previous Session binding remained bound"
2913
+ : "the live terminal no longer matches a bound managed Session and the process relationship is unverifiable",
2914
+ session_ids: [
2915
+ conflictingBoundSessionClaims[0].session.session_id
2916
+ ],
2917
+ binding_ids: [
2918
+ conflictingBoundSessionClaims[0].session.binding?.binding_id ?? null
2919
+ ],
2920
+ session_revisions: [
2921
+ conflictingBoundSessionClaims[0].session.revision ?? null
2922
+ ],
2923
+ recovery: conflictingBoundSessionClaims[0].kind === "unverifiable"
2924
+ ? "inspect the terminal and Session identity; AKK cannot safely reconcile an unverifiable binding"
2925
+ : "use only the exact reconcile_binding action listed for this terminal"
2881
2926
  }
2882
- : undefined;
2927
+ : conflictingBoundSessionClaims.length > 1
2928
+ ? {
2929
+ kind: "ambiguous_bound_claims",
2930
+ reason: "multiple non-exact bound managed Sessions claim the same live terminal",
2931
+ session_ids: conflictingBoundSessionClaims.map(({ session }) => session.session_id),
2932
+ recovery: "inspect Session state; AKK will not reconcile ambiguous claims"
2933
+ }
2934
+ : matchingSessions.length > 1
2935
+ ? {
2936
+ reason: "multiple first-class managed Sessions claim the same live terminal binding",
2937
+ session_ids: matchingSessions.map((session) => session.session_id),
2938
+ recovery: "inspect Session state before performing a side effect"
2939
+ }
2940
+ : undefined;
2883
2941
  const authoritativeSession = matchingSessions[0];
2884
2942
  const discoveredOwnership = terminalControl
2885
2943
  ? terminalDispatchOwnership(terminalControl)
@@ -2918,6 +2976,40 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
2918
2976
  const sessionAwareRawActions = authoritativeSession
2919
2977
  ? actionsForManagedSessionBinding(rawActions, authoritativeSession)
2920
2978
  : rawActions;
2979
+ const soleBindingConflict = conflictingBoundSessionClaims.length === 1
2980
+ ? conflictingBoundSessionClaims[0]
2981
+ : undefined;
2982
+ const conflictingSessionRevision = Number(soleBindingConflict?.session.revision);
2983
+ const conflictingSessionTurns = soleBindingConflict
2984
+ ? managedTurnsForSession(storeDir, soleBindingConflict.session.session_id)
2985
+ : [];
2986
+ const expectedTerminalToken = stringValue(terminal.lifecycle_binding_token);
2987
+ const reconcileBindingAction = mutationsAllowed &&
2988
+ discoveredOwnership.state === "none" &&
2989
+ unresolvedSessionClaims.length === 0 &&
2990
+ matchingSessions.length === 0 &&
2991
+ soleBindingConflict &&
2992
+ soleBindingConflict.kind !== "unverifiable" &&
2993
+ Number.isSafeInteger(conflictingSessionRevision) &&
2994
+ conflictingSessionRevision > 0 &&
2995
+ expectedTerminalToken &&
2996
+ terminal.activity_state === "idle" &&
2997
+ !(isRecord(terminal.approval_state) &&
2998
+ terminal.approval_state.blocked === true) &&
2999
+ !conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
3000
+ !managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session)
3001
+ ? {
3002
+ tool: "agent_knock_knock_reconcile_binding",
3003
+ arguments: {
3004
+ terminal_id: stringValue(terminal.id),
3005
+ conflicting_session_id: soleBindingConflict.session.session_id,
3006
+ expected_session_revision: conflictingSessionRevision,
3007
+ expected_binding_token: managedSessionBindingToken(soleBindingConflict.session),
3008
+ expected_terminal_token: expectedTerminalToken
3009
+ },
3010
+ requires_user_intent: true
3011
+ }
3012
+ : undefined;
2921
3013
  const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
2922
3014
  if (ownership.state === "current" &&
2923
3015
  !allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
@@ -3004,7 +3096,12 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
3004
3096
  const availableActions = ownership.state === "current"
3005
3097
  ? currentTerminalActions(currentTurn)
3006
3098
  : ownership.state === "conflict"
3007
- ? safeTerminalActionsDuringConflict(sessionAwareRawActions)
3099
+ ? {
3100
+ ...safeTerminalActionsDuringConflict(sessionAwareRawActions),
3101
+ ...(reconcileBindingAction
3102
+ ? { reconcile_binding: reconcileBindingAction }
3103
+ : {})
3104
+ }
3008
3105
  : managedSessionId &&
3009
3106
  sessionBindingMatchesLiveTerminal &&
3010
3107
  isRecord(sessionAwareRawActions.send)
@@ -3128,6 +3225,13 @@ function managedSessionMatchesLiveTerminalEntry(session, terminal, storeDir) {
3128
3225
  return false;
3129
3226
  }
3130
3227
  const liveThreadId = stringValue(terminal.native_agent_session_id);
3228
+ const statusCardThreadId = stringValue(terminal.native_agent_status_card_session_id);
3229
+ if (isExactNativeThreadId(binding.native_thread_id) &&
3230
+ isExactNativeThreadId(statusCardThreadId) &&
3231
+ binding.native_thread_id.toLowerCase() !==
3232
+ statusCardThreadId.toLowerCase()) {
3233
+ return false;
3234
+ }
3131
3235
  if (!liveThreadId) {
3132
3236
  return Boolean(session.agent === "codex" &&
3133
3237
  binding.native_thread_id &&
@@ -3186,11 +3290,116 @@ function managedSessionClaimsLiveTerminalEntry(session, terminal) {
3186
3290
  : undefined;
3187
3291
  return Boolean(binding &&
3188
3292
  session.agent === terminal.agent &&
3189
- binding.terminal_id === stringValue(terminal.id) &&
3190
3293
  binding.native_process.pid === Number(terminal.pid) &&
3191
3294
  terminalControlSelectorKey(binding.terminal_control) ===
3192
- terminalControlSelectorKey(liveControl) &&
3193
- matchesConfiguredWorkspace(session.workspace, terminal.workspace ?? terminal.cwd));
3295
+ terminalControlSelectorKey(liveControl));
3296
+ }
3297
+ function listedTerminalProcessIncarnation(terminal) {
3298
+ const processUuid = stringValue(terminal.native_agent_process_uuid);
3299
+ const processBirth = stringValue(terminal.native_agent_process_birth);
3300
+ if (terminal.agent !== "codex" ||
3301
+ (processUuid && processBirth)) {
3302
+ return { processUuid, processBirth };
3303
+ }
3304
+ const pid = Number(terminal.pid);
3305
+ if (!Number.isSafeInteger(pid) || pid <= 1) {
3306
+ return { processUuid, processBirth };
3307
+ }
3308
+ try {
3309
+ const incarnation = codexProcessIncarnationForPid(pid);
3310
+ return {
3311
+ processUuid: processUuid ?? incarnation.processUuid,
3312
+ processBirth: processBirth ?? incarnation.processBirth
3313
+ };
3314
+ }
3315
+ catch {
3316
+ return { processUuid, processBirth };
3317
+ }
3318
+ }
3319
+ function managedBindingConflictKindForLiveTerminalEntry({ storeDir, session, terminal }) {
3320
+ const binding = session.binding;
3321
+ if (session.status !== "bound" ||
3322
+ !binding ||
3323
+ !managedSessionClaimsLiveTerminalEntry(session, terminal) ||
3324
+ managedSessionMatchesLiveTerminalEntry(session, terminal, storeDir)) {
3325
+ return undefined;
3326
+ }
3327
+ const livePid = Number(terminal.pid);
3328
+ const incarnation = listedTerminalProcessIncarnation(terminal);
3329
+ const relationship = processIncarnationRelationship({
3330
+ binding,
3331
+ livePid,
3332
+ liveProcessUuid: incarnation.processUuid,
3333
+ liveProcessBirth: incarnation.processBirth
3334
+ });
3335
+ if (relationship === "different") {
3336
+ return "stale_process_incarnation";
3337
+ }
3338
+ if (binding.terminal_id !== stringValue(terminal.id) ||
3339
+ !matchesConfiguredWorkspace(session.workspace, terminal.workspace ?? terminal.cwd)) {
3340
+ return "unverifiable";
3341
+ }
3342
+ const statusCardThreadId = stringValue(terminal.native_agent_status_card_session_id);
3343
+ const liveNativeThreadId = stringValue(terminal.native_agent_session_id);
3344
+ if (isExactNativeThreadId(binding.native_thread_id) &&
3345
+ isExactNativeThreadId(statusCardThreadId) &&
3346
+ binding.native_thread_id.toLowerCase() !==
3347
+ statusCardThreadId.toLowerCase()) {
3348
+ return relationship === "same" &&
3349
+ isExactNativeThreadId(liveNativeThreadId) &&
3350
+ liveNativeThreadId.toLowerCase() ===
3351
+ statusCardThreadId.toLowerCase()
3352
+ ? "live_external_thread_change"
3353
+ : "unverifiable";
3354
+ }
3355
+ if (session.lineage.created_by === "attach" &&
3356
+ !session.last_transition_id &&
3357
+ !binding.native_thread_id &&
3358
+ !binding.native_process.rollout &&
3359
+ managedTurnsForSession(storeDir, session.session_id).length === 0) {
3360
+ return "provisional_orphan";
3361
+ }
3362
+ if (relationship === "same" &&
3363
+ isExactNativeThreadId(binding.native_thread_id) &&
3364
+ isExactNativeThreadId(liveNativeThreadId) &&
3365
+ binding.native_thread_id.toLowerCase() !==
3366
+ liveNativeThreadId.toLowerCase()) {
3367
+ return "live_external_thread_change";
3368
+ }
3369
+ return "unverifiable";
3370
+ }
3371
+ function managedSessionHasUnresolvedNativeTransition(storeDir, session) {
3372
+ const root = nativeThreadTransitionsDir(storeDir);
3373
+ if (!fs.existsSync(root)) {
3374
+ return false;
3375
+ }
3376
+ let entries;
3377
+ try {
3378
+ entries = fs.readdirSync(root, { withFileTypes: true });
3379
+ }
3380
+ catch {
3381
+ return true;
3382
+ }
3383
+ for (const entry of entries) {
3384
+ if (!entry.isDirectory()) {
3385
+ continue;
3386
+ }
3387
+ let transition;
3388
+ try {
3389
+ transition = loadNativeThreadTransition(storeDir, entry.name);
3390
+ }
3391
+ catch {
3392
+ return true;
3393
+ }
3394
+ if (transition.source_session_id !== session.session_id &&
3395
+ transition.target_session_id !== session.session_id) {
3396
+ continue;
3397
+ }
3398
+ if (!["committed", "aborted"].includes(transition.status)) {
3399
+ return true;
3400
+ }
3401
+ }
3402
+ return false;
3194
3403
  }
3195
3404
  function terminalKeyForManagedConversation(conversation) {
3196
3405
  return terminalControlSelectorKey(terminalControlFromTakeover(isRecord(conversation.native_session_takeover)
@@ -3577,7 +3786,10 @@ async function listStateForTerminal(agent, terminalControl, options, bridge = cr
3577
3786
  },
3578
3787
  activity_state: status.activity_state,
3579
3788
  activity_reason: status.activity_reason,
3580
- capability_limitation: status.capability_limitation
3789
+ capability_limitation: status.capability_limitation,
3790
+ // Internal projection evidence; terminalControlledListEntry selects all
3791
+ // public fields explicitly and never exposes the pane excerpt itself.
3792
+ screen_excerpt: status.screen.excerpt
3581
3793
  };
3582
3794
  }
3583
3795
  catch (error) {
@@ -3651,11 +3863,12 @@ function managedListApprovalState(conversation) {
3651
3863
  }
3652
3864
  function listActionContracts() {
3653
3865
  return {
3654
- version: 5,
3866
+ version: 6,
3655
3867
  instructions: [
3656
3868
  "Treat terminals[] as the primary resource and use only actions present in available_actions.",
3657
3869
  "An existing managed session's ordinary send targets session_id and creates a new turn. A turn id is never an ordinary send target.",
3658
3870
  "Read-only native-thread listing targets an exact terminal_id. Native-thread new/resume mutations also use the listed expected_binding_token and never create a Turn.",
3871
+ "A binding conflict may be detached only through its exact reconcile_binding action, which is snapshot-bound to the Session revision, binding, and live terminal identity and never adopts a replacement native thread.",
3659
3872
  "List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
3660
3873
  "For first attach only, use a discovery selector explicitly named by the user or the selector prefilled by that unmanaged raw-terminal row's available send action; never infer, guess, or reuse one.",
3661
3874
  "Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
@@ -3734,6 +3947,21 @@ function listActionContracts() {
3734
3947
  requires_user_intent: true,
3735
3948
  candidate_source: "list_resumable_threads"
3736
3949
  },
3950
+ reconcile_binding: {
3951
+ tool: "agent_knock_knock_reconcile_binding",
3952
+ target_argument: "terminal_id",
3953
+ required: [
3954
+ "terminal_id",
3955
+ "conflicting_session_id",
3956
+ "expected_session_revision",
3957
+ "expected_binding_token",
3958
+ "expected_terminal_token"
3959
+ ],
3960
+ creates_turn: false,
3961
+ sends_terminal_input: false,
3962
+ requires_user_intent: true,
3963
+ effect: "Detach one exactly listed conflicting Session binding without adopting the live replacement thread."
3964
+ },
3737
3965
  respond: {
3738
3966
  tool: "agent_knock_knock_respond",
3739
3967
  target_argument: "turn_id",
@@ -4266,6 +4494,83 @@ function codexProcessIncarnationForPid(pid) {
4266
4494
  evidence: "codex_process_birth"
4267
4495
  };
4268
4496
  }
4497
+ function processIncarnationRelationship({ binding, livePid, liveProcessUuid, liveProcessBirth }) {
4498
+ if (binding.native_process.pid !== livePid) {
4499
+ return "different";
4500
+ }
4501
+ const comparisons = [];
4502
+ if (binding.native_process.process_uuid && liveProcessUuid) {
4503
+ comparisons.push(binding.native_process.process_uuid === liveProcessUuid);
4504
+ }
4505
+ if (binding.native_process.process_birth && liveProcessBirth) {
4506
+ comparisons.push(binding.native_process.process_birth === liveProcessBirth);
4507
+ }
4508
+ if (comparisons.length === 0) {
4509
+ return "unverifiable";
4510
+ }
4511
+ if (comparisons.every(Boolean)) {
4512
+ return "same";
4513
+ }
4514
+ if (comparisons.every((value) => !value)) {
4515
+ return "different";
4516
+ }
4517
+ return "unverifiable";
4518
+ }
4519
+ function resolvedTerminalProcessIncarnation(terminal, identity) {
4520
+ if (terminal.agent !== "codex" ||
4521
+ (identity?.processUuid && identity.processBirth)) {
4522
+ return {
4523
+ processUuid: identity?.processUuid,
4524
+ processBirth: identity?.processBirth
4525
+ };
4526
+ }
4527
+ try {
4528
+ const incarnation = codexProcessIncarnationForPid(terminal.pid);
4529
+ return {
4530
+ processUuid: identity?.processUuid ?? incarnation.processUuid,
4531
+ processBirth: identity?.processBirth ?? incarnation.processBirth
4532
+ };
4533
+ }
4534
+ catch {
4535
+ return {
4536
+ processUuid: identity?.processUuid,
4537
+ processBirth: identity?.processBirth
4538
+ };
4539
+ }
4540
+ }
4541
+ function managedSessionOwnerIsConclusivelyInactive({ session, terminal, identity }) {
4542
+ const binding = session.binding;
4543
+ if (!binding) {
4544
+ return false;
4545
+ }
4546
+ if (binding.native_process.pid !== terminal.pid) {
4547
+ if (!isProcessAlive(binding.native_process.pid)) {
4548
+ return true;
4549
+ }
4550
+ if (session.agent !== "codex") {
4551
+ return false;
4552
+ }
4553
+ try {
4554
+ const ownerIncarnation = codexProcessIncarnationForPid(binding.native_process.pid);
4555
+ return processIncarnationRelationship({
4556
+ binding,
4557
+ livePid: binding.native_process.pid,
4558
+ liveProcessUuid: ownerIncarnation.processUuid,
4559
+ liveProcessBirth: ownerIncarnation.processBirth
4560
+ }) === "different";
4561
+ }
4562
+ catch {
4563
+ return false;
4564
+ }
4565
+ }
4566
+ const incarnation = resolvedTerminalProcessIncarnation(terminal, identity);
4567
+ return processIncarnationRelationship({
4568
+ binding,
4569
+ livePid: terminal.pid,
4570
+ liveProcessUuid: incarnation.processUuid,
4571
+ liveProcessBirth: incarnation.processBirth
4572
+ }) === "different";
4573
+ }
4269
4574
  function isCodexStatusCardEvidence(evidence) {
4270
4575
  return evidence.split("+").includes("codex_status_card");
4271
4576
  }
@@ -4749,9 +5054,15 @@ function boundManagedSessionForTerminal({ storeDir, terminal, identity }) {
4749
5054
  }
4750
5055
  const conflicting = sessions.filter((session) => session.status === "bound" &&
4751
5056
  session.binding &&
5057
+ session.agent === terminal.agent &&
4752
5058
  terminalControlSelectorKey(session.binding.terminal_control) ===
4753
5059
  terminalControlSelectorKey(terminal.terminalControl) &&
4754
5060
  session.binding.native_process.pid === terminal.pid &&
5061
+ !managedSessionOwnerIsConclusivelyInactive({
5062
+ session,
5063
+ terminal,
5064
+ identity
5065
+ }) &&
4755
5066
  !exact.includes(session));
4756
5067
  if (conflicting.length > 0) {
4757
5068
  throw new Error(`terminal ${terminal.terminalControl.target} changed native thread outside AKK; ` +
@@ -4759,6 +5070,49 @@ function boundManagedSessionForTerminal({ storeDir, terminal, identity }) {
4759
5070
  }
4760
5071
  return exact[0];
4761
5072
  }
5073
+ function managedBindingConflictKindForResolvedTerminal({ storeDir, session, terminal, identity }) {
5074
+ const binding = session.binding;
5075
+ if (session.status !== "bound" ||
5076
+ !binding ||
5077
+ session.agent !== terminal.agent ||
5078
+ binding.terminal_id !== terminal.conversationId ||
5079
+ binding.native_process.pid !== terminal.pid ||
5080
+ terminalControlSelectorKey(binding.terminal_control) !==
5081
+ terminalControlSelectorKey(terminal.terminalControl) ||
5082
+ !matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath) ||
5083
+ bindingMatchesLiveTerminal(session, terminal, identity, storeDir)) {
5084
+ return undefined;
5085
+ }
5086
+ if (managedSessionOwnerIsConclusivelyInactive({
5087
+ session,
5088
+ terminal,
5089
+ identity
5090
+ })) {
5091
+ return "stale_process_incarnation";
5092
+ }
5093
+ if (session.lineage.created_by === "attach" &&
5094
+ !session.last_transition_id &&
5095
+ !binding.native_thread_id &&
5096
+ !binding.native_process.rollout &&
5097
+ managedTurnsForSession(storeDir, session.session_id).length === 0) {
5098
+ return "provisional_orphan";
5099
+ }
5100
+ const incarnation = resolvedTerminalProcessIncarnation(terminal, identity);
5101
+ const relationship = processIncarnationRelationship({
5102
+ binding,
5103
+ livePid: terminal.pid,
5104
+ liveProcessUuid: incarnation.processUuid,
5105
+ liveProcessBirth: incarnation.processBirth
5106
+ });
5107
+ if (relationship === "same" &&
5108
+ isExactNativeThreadId(binding.native_thread_id) &&
5109
+ isExactNativeThreadId(identity?.sessionId) &&
5110
+ binding.native_thread_id.toLowerCase() !==
5111
+ identity.sessionId.toLowerCase()) {
5112
+ return "live_external_thread_change";
5113
+ }
5114
+ return "unverifiable";
5115
+ }
4762
5116
  function soleBoundManagedSessionClaimForTerminal(storeDir, terminal) {
4763
5117
  const claims = listManagedSessions(storeDir).filter((session) => session.status === "bound" &&
4764
5118
  session.binding &&
@@ -4767,7 +5121,11 @@ function soleBoundManagedSessionClaimForTerminal(storeDir, terminal) {
4767
5121
  session.binding.native_process.pid === terminal.pid &&
4768
5122
  terminalControlSelectorKey(session.binding.terminal_control) ===
4769
5123
  terminalControlSelectorKey(terminal.terminalControl) &&
4770
- matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath));
5124
+ matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath) &&
5125
+ !managedSessionOwnerIsConclusivelyInactive({
5126
+ session,
5127
+ terminal
5128
+ }));
4771
5129
  if (claims.length > 1) {
4772
5130
  throw new Error(`terminal ${terminal.terminalControl.target} has multiple bound managed Session claims`);
4773
5131
  }
@@ -4775,15 +5133,18 @@ function soleBoundManagedSessionClaimForTerminal(storeDir, terminal) {
4775
5133
  }
4776
5134
  function createBoundManagedSession({ sessionId, terminal, identity, nativeThreadId = identity?.sessionId, evidence = identity?.evidence ?? "native_thread_boundary", generation = 1, lineage, now = new Date() }) {
4777
5135
  const workspace = terminal.terminalControl.currentPath ?? process.cwd();
5136
+ const codexIncarnation = terminal.agent === "codex" && !identity
5137
+ ? codexProcessIncarnationForPid(terminal.pid)
5138
+ : undefined;
4778
5139
  const binding = terminalBindingFrom({
4779
5140
  terminalId: terminal.conversationId,
4780
5141
  terminalControl: terminal.terminalControl,
4781
5142
  pid: terminal.pid,
4782
5143
  nativeThreadId,
4783
- processUuid: identity?.processUuid,
4784
- processBirth: identity?.processBirth,
5144
+ processUuid: identity?.processUuid ?? codexIncarnation?.processUuid,
5145
+ processBirth: identity?.processBirth ?? codexIncarnation?.processBirth,
4785
5146
  rollout: identity?.rollout,
4786
- evidence,
5147
+ evidence: identity?.evidence ?? codexIncarnation?.evidence ?? evidence,
4787
5148
  generation,
4788
5149
  now
4789
5150
  });
@@ -4865,8 +5226,11 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
4865
5226
  });
4866
5227
  const previousPid = existing.binding.native_process.pid;
4867
5228
  if (existing.status === "bound" &&
4868
- (previousPid === terminal.pid ||
4869
- isProcessAlive(previousPid))) {
5229
+ !managedSessionOwnerIsConclusivelyInactive({
5230
+ session: existing,
5231
+ terminal,
5232
+ identity
5233
+ })) {
4870
5234
  throw new Error(`managed Session ${existing.session_id} is still bound to process ${previousPid}`);
4871
5235
  }
4872
5236
  const now = new Date();
@@ -5237,7 +5601,11 @@ async function resumableThreadCandidates({ options, terminal, currentIdentity })
5237
5601
  managedSessionBindingInactive: managed.length === 1 &&
5238
5602
  managed[0].status === "bound" &&
5239
5603
  Boolean(managed[0].binding) &&
5240
- !isProcessAlive(managed[0].binding?.native_process.pid),
5604
+ managedSessionOwnerIsConclusivelyInactive({
5605
+ session: managed[0],
5606
+ terminal,
5607
+ identity: currentIdentity
5608
+ }),
5241
5609
  managedSessionWorkspaceMatches: managed.length === 1
5242
5610
  ? path.resolve(managed[0].workspace) === workspace
5243
5611
  : undefined,
@@ -5951,6 +6319,161 @@ async function runResumeThread(options) {
5951
6319
  selectionSnapshot: selection.snapshot
5952
6320
  });
5953
6321
  }
6322
+ async function runReconcileBinding(options) {
6323
+ const initiallyResolved = await resolveLifecycleTerminal(options);
6324
+ const storeDir = storeDirFromOptions(options);
6325
+ const conflictingSessionId = required(stringValue(options.conflictingSession ?? options.conflictingSessionId), "--conflicting-session is required");
6326
+ const expectedRevisionValue = required(stringValue(options.expectedSessionRevision ?? options.sessionRevision), "--expected-session-revision is required");
6327
+ if (!/^[1-9][0-9]*$/u.test(expectedRevisionValue)) {
6328
+ throw new Error("--expected-session-revision must be a positive integer");
6329
+ }
6330
+ const expectedSessionRevision = Number(expectedRevisionValue);
6331
+ if (!Number.isSafeInteger(expectedSessionRevision)) {
6332
+ throw new Error("--expected-session-revision must be a positive safe integer");
6333
+ }
6334
+ const expectedBindingToken = required(stringValue(options.expectedBindingToken), "--expected-binding-token is required");
6335
+ const expectedTerminalToken = required(stringValue(options.expectedTerminalToken), "--expected-terminal-token is required");
6336
+ const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDir, initiallyResolved.terminalControl), { timeoutMs: 30000 });
6337
+ try {
6338
+ return await withStoreWriterLeaseAsync(storeDir, async () => {
6339
+ const terminal = await resolveLifecycleTerminal(options);
6340
+ if (terminal.pid !== initiallyResolved.pid ||
6341
+ terminal.conversationId !== initiallyResolved.conversationId ||
6342
+ terminalControlSelectorKey(terminal.terminalControl) !==
6343
+ terminalControlSelectorKey(initiallyResolved.terminalControl)) {
6344
+ throw new Error("terminal identity changed while waiting to reconcile its binding; refresh AKK list");
6345
+ }
6346
+ await recoverLifecycleFenceBeforeMutation({ options, terminal });
6347
+ const dispatchOwnership = terminalDispatchOwnership(terminal.terminalControl);
6348
+ if (dispatchOwnership.state !== "none") {
6349
+ throw new Error("the terminal acquired an unresolved dispatch after the binding conflict was listed; refresh AKK list");
6350
+ }
6351
+ const session = loadManagedSession(storeDir, conflictingSessionId);
6352
+ if (session.revision !== expectedSessionRevision ||
6353
+ managedSessionBindingToken(session) !== expectedBindingToken) {
6354
+ throw new Error("managed Session binding changed after it was listed; refresh AKK list");
6355
+ }
6356
+ const binding = session.binding;
6357
+ if (session.status !== "bound" ||
6358
+ !binding ||
6359
+ session.agent !== terminal.agent ||
6360
+ binding.terminal_id !== terminal.conversationId ||
6361
+ binding.native_process.pid !== terminal.pid ||
6362
+ terminalControlSelectorKey(binding.terminal_control) !==
6363
+ terminalControlSelectorKey(terminal.terminalControl) ||
6364
+ !matchesConfiguredWorkspace(session.workspace, terminal.terminalControl.currentPath)) {
6365
+ throw new Error("the listed managed Session no longer claims this exact terminal");
6366
+ }
6367
+ const identity = await resolveCurrentNativeAgentSessionIdentity({
6368
+ options,
6369
+ agent: terminal.agent,
6370
+ pid: terminal.pid,
6371
+ cwd: terminal.terminalControl.currentPath
6372
+ });
6373
+ const terminalToken = lifecycleBindingToken({
6374
+ terminal,
6375
+ identity
6376
+ });
6377
+ if (terminalToken !== expectedTerminalToken) {
6378
+ throw new Error("live terminal identity changed after the conflict was listed; refresh AKK list");
6379
+ }
6380
+ const bridge = createTerminalAgentBridge(options);
6381
+ if (managedSessionHasUnresolvedNativeTransition(storeDir, session)) {
6382
+ throw new Error(`managed Session ${session.session_id} has an unresolved native-thread transition`);
6383
+ }
6384
+ const blockers = managedTurnsForSession(storeDir, session.session_id).filter((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
6385
+ if (blockers.length > 0) {
6386
+ throw new Error(`managed Session ${session.session_id} still has unresolved Turn ` +
6387
+ `${turnIdForConversation(blockers[0])} (${blockers[0].status})`);
6388
+ }
6389
+ const finalTerminal = await resolveLifecycleTerminal(options);
6390
+ if (finalTerminal.pid !== terminal.pid ||
6391
+ finalTerminal.conversationId !== terminal.conversationId ||
6392
+ terminalControlSelectorKey(finalTerminal.terminalControl) !==
6393
+ terminalControlSelectorKey(terminal.terminalControl)) {
6394
+ throw new Error("terminal identity changed during binding reconciliation; refresh AKK list");
6395
+ }
6396
+ const finalStatus = await bridge.status(finalTerminal.agent, finalTerminal.terminalControl, {
6397
+ runtime: terminalRuntimeForLiveIdentity({
6398
+ terminal: finalTerminal,
6399
+ physicalOnly: true
6400
+ })
6401
+ });
6402
+ assertTerminalLifecycleReady({
6403
+ options,
6404
+ terminal: finalTerminal,
6405
+ terminalStatus: finalStatus
6406
+ });
6407
+ const finalIdentity = await resolveCurrentNativeAgentSessionIdentity({
6408
+ options,
6409
+ agent: finalTerminal.agent,
6410
+ pid: finalTerminal.pid,
6411
+ cwd: finalTerminal.terminalControl.currentPath
6412
+ });
6413
+ if (lifecycleBindingToken({
6414
+ terminal: finalTerminal,
6415
+ identity: finalIdentity
6416
+ }) !== expectedTerminalToken) {
6417
+ throw new Error("live terminal identity changed during binding reconciliation; refresh AKK list");
6418
+ }
6419
+ const finalSession = loadManagedSession(storeDir, conflictingSessionId);
6420
+ if (finalSession.revision !== expectedSessionRevision ||
6421
+ managedSessionBindingToken(finalSession) !== expectedBindingToken) {
6422
+ throw new Error("managed Session binding changed during reconciliation; refresh AKK list");
6423
+ }
6424
+ const conflictKind = managedBindingConflictKindForResolvedTerminal({
6425
+ storeDir,
6426
+ session: finalSession,
6427
+ terminal: finalTerminal,
6428
+ identity: finalIdentity
6429
+ });
6430
+ if (![
6431
+ "provisional_orphan",
6432
+ "live_external_thread_change"
6433
+ ].includes(String(conflictKind))) {
6434
+ throw new Error(conflictKind === "stale_process_incarnation"
6435
+ ? "the stale process incarnation no longer requires explicit reconciliation; refresh AKK list"
6436
+ : conflictKind === undefined
6437
+ ? "the managed Session now exactly matches the live terminal; no reconciliation is needed"
6438
+ : "the managed binding conflict is unverifiable and cannot be detached automatically");
6439
+ }
6440
+ const reconciledAt = new Date().toISOString();
6441
+ const detached = saveManagedSession(storeDir, {
6442
+ ...finalSession,
6443
+ status: "detached",
6444
+ detached_at: reconciledAt,
6445
+ updated_at: reconciledAt
6446
+ }, {
6447
+ expectedRevision: expectedSessionRevision
6448
+ });
6449
+ runtimeLog("info", "managed_binding_reconciled", {
6450
+ terminal_id: terminal.conversationId,
6451
+ terminal_target: terminal.terminalControl.target,
6452
+ session_id: detached.session_id,
6453
+ binding_id: detached.binding?.binding_id,
6454
+ previous_revision: expectedSessionRevision,
6455
+ revision: detached.revision,
6456
+ conflict_kind: conflictKind,
6457
+ terminal_input_sent: false
6458
+ });
6459
+ printJson({
6460
+ status: "reconciled",
6461
+ outcome: "detached_conflicting_binding",
6462
+ conflict_kind: conflictKind,
6463
+ terminal_id: terminal.conversationId,
6464
+ session_id: detached.session_id,
6465
+ binding_id: detached.binding?.binding_id,
6466
+ session_revision: detached.revision,
6467
+ terminal_input_sent: false,
6468
+ turn_created: false,
6469
+ refresh_required: true
6470
+ });
6471
+ });
6472
+ }
6473
+ finally {
6474
+ releaseTerminalLock();
6475
+ }
6476
+ }
5954
6477
  function assertResumeSnapshotMatchesTerminal(snapshot, terminal) {
5955
6478
  const workspace = path.resolve(terminal.terminalControl.currentPath ?? process.cwd());
5956
6479
  if (snapshot.terminal_id !== terminal.conversationId ||
@@ -6173,8 +6696,11 @@ async function runNativeThreadTransition(options, operation) {
6173
6696
  `${turnIdForConversation(targetBlockers[0])}`);
6174
6697
  }
6175
6698
  if (targetSession?.status === "bound") {
6176
- const stalePid = targetSession.binding?.native_process.pid;
6177
- if (!stalePid || isProcessAlive(stalePid)) {
6699
+ if (!managedSessionOwnerIsConclusivelyInactive({
6700
+ session: targetSession,
6701
+ terminal,
6702
+ identity: beforeIdentity
6703
+ })) {
6178
6704
  throw new Error(`target Session ${candidate.managed_session_id} is still bound to a live or unverifiable process`);
6179
6705
  }
6180
6706
  const detachedAt = new Date().toISOString();
@@ -7286,6 +7812,7 @@ async function runSend(options) {
7286
7812
  storeDir: rawStoreDir
7287
7813
  });
7288
7814
  }
7815
+ let pendingRawAttachSessionCreate;
7289
7816
  if (!managedSession) {
7290
7817
  if (currentNativeIdentity) {
7291
7818
  await assertNativeThreadHasExclusiveOwnership({
@@ -7303,7 +7830,13 @@ async function runSend(options) {
7303
7830
  identity: currentNativeIdentity,
7304
7831
  lineage: { created_by: "attach" }
7305
7832
  });
7306
- managedSession = saveManagedSession(rawStoreDir, managedSession, { expectedRevision: null });
7833
+ if (terminalConversation.agent === "codex" &&
7834
+ !currentNativeIdentity) {
7835
+ pendingRawAttachSessionCreate = managedSession;
7836
+ }
7837
+ else {
7838
+ managedSession = saveManagedSession(rawStoreDir, managedSession, { expectedRevision: null });
7839
+ }
7307
7840
  }
7308
7841
  const logicalNativeIdentity = logicalIdentityForManagedSession({
7309
7842
  storeDir: rawStoreDir,
@@ -7378,6 +7911,31 @@ async function runSend(options) {
7378
7911
  storeWriterLeaseHeld: true,
7379
7912
  recordMessageAfterSend: true,
7380
7913
  recordRawAttachmentAfterSend: reusableTurn === undefined,
7914
+ onTerminalPreflightVerified: pendingRawAttachSessionCreate
7915
+ ? () => {
7916
+ const createdSession = saveManagedSession(rawStoreDir, pendingRawAttachSessionCreate, { expectedRevision: null });
7917
+ managedSession = createdSession;
7918
+ pendingRawAttachSessionCreate = undefined;
7919
+ return () => {
7920
+ const current = loadManagedSession(rawStoreDir, createdSession.session_id);
7921
+ if (current.status !== "bound" ||
7922
+ current.revision !== createdSession.revision ||
7923
+ managedSessionBindingToken(current) !==
7924
+ managedSessionBindingToken(createdSession)) {
7925
+ throw new Error(`new raw-attach Session ${createdSession.session_id} changed before pre-transport rollback`);
7926
+ }
7927
+ const detachedAt = new Date().toISOString();
7928
+ managedSession = saveManagedSession(rawStoreDir, {
7929
+ ...current,
7930
+ status: "detached",
7931
+ detached_at: detachedAt,
7932
+ updated_at: detachedAt
7933
+ }, {
7934
+ expectedRevision: current.revision
7935
+ });
7936
+ };
7937
+ }
7938
+ : undefined,
7381
7939
  allowedPreMaterializationIdentity,
7382
7940
  allowedAdditionalIdentities
7383
7941
  });
@@ -8306,7 +8864,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
8306
8864
  releaseTerminalLock();
8307
8865
  }
8308
8866
  }
8309
- async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, allowedPreMaterializationIdentity = undefined, allowedAdditionalIdentities = [], continuingTurnResponse = false }) {
8867
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, terminalSendLockHeld = false, terminalStateLockHeld = false, storeWriterLeaseHeld = false, recordMessageAfterSend = false, recordRawAttachmentAfterSend = false, onTerminalPreflightVerified = undefined, allowedPreMaterializationIdentity = undefined, allowedAdditionalIdentities = [], continuingTurnResponse = false }) {
8310
8868
  const bridge = terminalBridgeEnabled(conversation);
8311
8869
  if (!terminalSendLockHeld) {
8312
8870
  const releaseTerminalLock = acquireFileLock(terminalBridgeSendLockPath(storeDirFromOptions(options), terminalControl), { timeoutMs: 30000 });
@@ -8325,6 +8883,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8325
8883
  storeWriterLeaseHeld,
8326
8884
  recordMessageAfterSend,
8327
8885
  recordRawAttachmentAfterSend,
8886
+ onTerminalPreflightVerified,
8328
8887
  allowedPreMaterializationIdentity,
8329
8888
  allowedAdditionalIdentities,
8330
8889
  continuingTurnResponse
@@ -8364,6 +8923,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8364
8923
  storeWriterLeaseHeld,
8365
8924
  recordMessageAfterSend,
8366
8925
  recordRawAttachmentAfterSend,
8926
+ onTerminalPreflightVerified,
8367
8927
  allowedPreMaterializationIdentity,
8368
8928
  allowedAdditionalIdentities,
8369
8929
  continuingTurnResponse
@@ -8389,6 +8949,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8389
8949
  storeWriterLeaseHeld: true,
8390
8950
  recordMessageAfterSend,
8391
8951
  recordRawAttachmentAfterSend,
8952
+ onTerminalPreflightVerified,
8392
8953
  allowedPreMaterializationIdentity,
8393
8954
  allowedAdditionalIdentities,
8394
8955
  continuingTurnResponse
@@ -8623,6 +9184,31 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8623
9184
  catch (error) {
8624
9185
  throw new Error(`refusing to send to ${executor.display_name} without a verified idle terminal: ${error instanceof Error ? error.message : String(error)}`);
8625
9186
  }
9187
+ // A newly discovered raw terminal may not have an authoritative Session
9188
+ // yet. Commit that Session only after every pre-input terminal and native
9189
+ // acceptance check has passed, but before the Turn or dispatch ledger can
9190
+ // become durable. This prevents a failed virgin attach from leaving a
9191
+ // zero-identity `bound` Session that fences every later control action.
9192
+ let rollbackPreTransportAttach = onTerminalPreflightVerified?.();
9193
+ const rollbackRawAttachBeforeTransport = () => {
9194
+ if (!rollbackPreTransportAttach) {
9195
+ return true;
9196
+ }
9197
+ const rollback = rollbackPreTransportAttach;
9198
+ rollbackPreTransportAttach = undefined;
9199
+ try {
9200
+ rollback();
9201
+ return true;
9202
+ }
9203
+ catch (error) {
9204
+ runtimeLog("error", "raw_attach_pre_transport_rollback_failed", {
9205
+ conversation_id: conversation.conversation_id,
9206
+ terminal_target: terminalControl.target,
9207
+ error: error instanceof Error ? error.message : String(error)
9208
+ });
9209
+ return false;
9210
+ }
9211
+ };
8626
9212
  const bridgeConversation = bridge
8627
9213
  ? withTerminalBridgeState({
8628
9214
  conversation: nextConversation,
@@ -8646,33 +9232,53 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8646
9232
  status: "prepared",
8647
9233
  preparedAt: bridgeStartedAt
8648
9234
  });
8649
- saveTerminalBridgeDispatchLedger(terminalControl, {
8650
- ...terminalBindingLedgerFields(preparedConversation),
8651
- status: "prepared",
8652
- generation_id: message.id,
8653
- conversation_id: preparedConversation.conversation_id,
8654
- session_id: sessionIdForConversation(preparedConversation),
8655
- turn_id: turnIdForConversation(preparedConversation),
8656
- message_id: message.id,
8657
- message_type: message.type,
8658
- request_hash: terminalRequestHash,
8659
- prepared_at: bridgeStartedAt,
8660
- dispatcher_pid: process.pid,
8661
- state_path: statePath,
8662
- event_log_path: logPath,
8663
- callback_expected: Boolean(preparedConversation.gateway_method),
8664
- previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
8665
- stringValue(previousDispatchLedger?.message_id)
8666
- });
9235
+ try {
9236
+ saveTerminalBridgeDispatchLedger(terminalControl, {
9237
+ ...terminalBindingLedgerFields(preparedConversation),
9238
+ status: "prepared",
9239
+ generation_id: message.id,
9240
+ conversation_id: preparedConversation.conversation_id,
9241
+ session_id: sessionIdForConversation(preparedConversation),
9242
+ turn_id: turnIdForConversation(preparedConversation),
9243
+ message_id: message.id,
9244
+ message_type: message.type,
9245
+ request_hash: terminalRequestHash,
9246
+ prepared_at: bridgeStartedAt,
9247
+ dispatcher_pid: process.pid,
9248
+ state_path: statePath,
9249
+ event_log_path: logPath,
9250
+ callback_expected: Boolean(preparedConversation.gateway_method),
9251
+ previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
9252
+ stringValue(previousDispatchLedger?.message_id)
9253
+ });
9254
+ }
9255
+ catch (error) {
9256
+ try {
9257
+ restoreTerminalBridgeDispatchLedger({
9258
+ terminalControl,
9259
+ previousLedger: previousDispatchLedger,
9260
+ reason: "prepared ledger persistence failed before tmux input"
9261
+ });
9262
+ }
9263
+ finally {
9264
+ rollbackRawAttachBeforeTransport();
9265
+ }
9266
+ throw error;
9267
+ }
8667
9268
  try {
8668
9269
  saveState(statePath, preparedConversation);
8669
9270
  }
8670
9271
  catch (error) {
8671
- restoreTerminalBridgeDispatchLedger({
8672
- terminalControl,
8673
- previousLedger: previousDispatchLedger,
8674
- reason: "prepared state persistence failed before tmux input"
8675
- });
9272
+ try {
9273
+ restoreTerminalBridgeDispatchLedger({
9274
+ terminalControl,
9275
+ previousLedger: previousDispatchLedger,
9276
+ reason: "prepared state persistence failed before tmux input"
9277
+ });
9278
+ }
9279
+ finally {
9280
+ rollbackRawAttachBeforeTransport();
9281
+ }
8676
9282
  throw error;
8677
9283
  }
8678
9284
  let bridgeMonitor;
@@ -8745,6 +9351,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8745
9351
  : String(ledgerError)
8746
9352
  });
8747
9353
  }
9354
+ const rawAttachRolledBack = rollbackRawAttachBeforeTransport();
9355
+ const durableAbortCanBeRetryable = dispatchLedgerRestored && rawAttachRolledBack;
8748
9356
  const failureBase = recordRawAttachmentAfterSend
8749
9357
  ? {
8750
9358
  ...preparedConversation,
@@ -8769,7 +9377,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8769
9377
  preparedAt: bridgeStartedAt,
8770
9378
  abortedAt,
8771
9379
  error: errorMessage,
8772
- safeToRetry: dispatchLedgerRestored
9380
+ safeToRetry: durableAbortCanBeRetryable
8773
9381
  });
8774
9382
  let abortedStatePersisted = false;
8775
9383
  try {
@@ -8788,7 +9396,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8788
9396
  : String(persistenceError)
8789
9397
  });
8790
9398
  }
8791
- const safeToRetry = dispatchLedgerRestored && abortedStatePersisted;
9399
+ const safeToRetry = durableAbortCanBeRetryable && abortedStatePersisted;
8792
9400
  try {
8793
9401
  appendEvent(logPath, {
8794
9402
  ts: abortedAt,
@@ -8830,7 +9438,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8830
9438
  error: errorMessage,
8831
9439
  safe_to_retry: safeToRetry,
8832
9440
  dispatch_ledger_restored: dispatchLedgerRestored,
8833
- aborted_state_persisted: abortedStatePersisted
9441
+ aborted_state_persisted: abortedStatePersisted,
9442
+ raw_attach_rolled_back: rawAttachRolledBack
8834
9443
  });
8835
9444
  printJson({
8836
9445
  session_id: sessionIdForConversation(abortedConversation),
@@ -8851,7 +9460,9 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8851
9460
  ? "AKK failed before touching tmux; this terminal submission was not sent and may be retried."
8852
9461
  : !dispatchLedgerRestored
8853
9462
  ? "AKK failed before tmux input but could not restore the terminal dispatch ledger; inspect and close the conversation before retrying."
8854
- : "AKK failed before tmux input but could not persist the aborted receipt; inspect the conversation before retrying.",
9463
+ : !rawAttachRolledBack
9464
+ ? "AKK failed before tmux input but could not detach the provisional raw-attach Session; inspect its exact binding before retrying."
9465
+ : "AKK failed before tmux input but could not persist the aborted receipt; inspect the conversation before retrying.",
8855
9466
  openclaw_next_action: {
8856
9467
  action: safeToRetry ? "retry" : "inspect",
8857
9468
  conversation_id: abortedConversation.conversation_id,
@@ -8863,7 +9474,9 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
8863
9474
  ? "The failure occurred before any tmux input."
8864
9475
  : !dispatchLedgerRestored
8865
9476
  ? "The terminal ledger could not be restored automatically."
8866
- : "The aborted receipt could not be made durable."
9477
+ : !rawAttachRolledBack
9478
+ ? "The provisional raw-attach Session could not be detached automatically."
9479
+ : "The aborted receipt could not be made durable."
8867
9480
  }
8868
9481
  });
8869
9482
  return;
@@ -9262,6 +9875,149 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9262
9875
  }
9263
9876
  }
9264
9877
  catch (error) {
9878
+ if (!textInjectedAt &&
9879
+ error instanceof TerminalInputNotStartedError) {
9880
+ const abortedAt = new Date().toISOString();
9881
+ const errorMessage = error.message;
9882
+ let dispatchLedgerRestored = true;
9883
+ try {
9884
+ restoreTerminalBridgeDispatchLedger({
9885
+ terminalControl,
9886
+ previousLedger: previousDispatchLedger,
9887
+ reason: "terminal transport was proved not to have started"
9888
+ });
9889
+ }
9890
+ catch (ledgerError) {
9891
+ dispatchLedgerRestored = false;
9892
+ runtimeLog("error", "terminal_dispatch_ledger_restore_failed", {
9893
+ conversation_id: conversation.conversation_id,
9894
+ terminal_target: terminalControl.target,
9895
+ error: ledgerError instanceof Error
9896
+ ? ledgerError.message
9897
+ : String(ledgerError)
9898
+ });
9899
+ }
9900
+ const rawAttachRolledBack = rollbackRawAttachBeforeTransport();
9901
+ const durableAbortCanBeRetryable = dispatchLedgerRestored && rawAttachRolledBack;
9902
+ const failureBase = recordRawAttachmentAfterSend
9903
+ ? {
9904
+ ...preparedConversation,
9905
+ status: "failed",
9906
+ failed_at: abortedAt,
9907
+ failure_reason: "terminal transport failed before terminal input"
9908
+ }
9909
+ : {
9910
+ ...preparedConversation,
9911
+ status: conversation.status,
9912
+ ...(conversation.idle_since
9913
+ ? { idle_since: conversation.idle_since }
9914
+ : {})
9915
+ };
9916
+ const abortedConversation = withTerminalBridgeSubmission({
9917
+ conversation: failureBase,
9918
+ messageId: message.id,
9919
+ messageType: message.type,
9920
+ messageBody: String(message.body),
9921
+ requestText: terminalPayload,
9922
+ status: "aborted",
9923
+ preparedAt: bridgeStartedAt,
9924
+ abortedAt,
9925
+ error: errorMessage,
9926
+ safeToRetry: durableAbortCanBeRetryable
9927
+ });
9928
+ let abortedStatePersisted = false;
9929
+ try {
9930
+ saveState(statePath, abortedConversation);
9931
+ abortedStatePersisted = true;
9932
+ }
9933
+ catch (persistenceError) {
9934
+ runtimeLog("error", "terminal_message_submit_aborted_persist_failed", {
9935
+ conversation_id: abortedConversation.conversation_id,
9936
+ terminal_target: terminalControl.target,
9937
+ error: persistenceError instanceof Error
9938
+ ? persistenceError.message
9939
+ : String(persistenceError)
9940
+ });
9941
+ }
9942
+ const safeToRetry = durableAbortCanBeRetryable && abortedStatePersisted;
9943
+ const reportedConversation = safeToRetry
9944
+ ? abortedConversation
9945
+ : withTerminalBridgeSubmission({
9946
+ conversation: abortedConversation,
9947
+ messageId: message.id,
9948
+ messageType: message.type,
9949
+ messageBody: String(message.body),
9950
+ requestText: terminalPayload,
9951
+ status: "aborted",
9952
+ preparedAt: bridgeStartedAt,
9953
+ abortedAt,
9954
+ error: errorMessage,
9955
+ safeToRetry: false
9956
+ });
9957
+ try {
9958
+ appendEvent(logPath, {
9959
+ ts: abortedAt,
9960
+ conversation_id: abortedConversation.conversation_id,
9961
+ event: "terminal_message_submit_aborted",
9962
+ message_id: message.id,
9963
+ executor,
9964
+ terminal_control: terminalControl,
9965
+ error: textSummary(errorMessage),
9966
+ safe_to_retry: safeToRetry,
9967
+ terminal_input_started: false
9968
+ });
9969
+ }
9970
+ catch (persistenceError) {
9971
+ runtimeLog("error", "terminal_message_submit_aborted_event_failed", {
9972
+ conversation_id: abortedConversation.conversation_id,
9973
+ terminal_target: terminalControl.target,
9974
+ error: persistenceError instanceof Error
9975
+ ? persistenceError.message
9976
+ : String(persistenceError)
9977
+ });
9978
+ }
9979
+ runtimeLog("error", "terminal_message_submit_aborted", {
9980
+ conversation_id: abortedConversation.conversation_id,
9981
+ terminal_target: terminalControl.target,
9982
+ error: errorMessage,
9983
+ safe_to_retry: safeToRetry,
9984
+ terminal_input_started: false,
9985
+ dispatch_ledger_restored: dispatchLedgerRestored,
9986
+ aborted_state_persisted: abortedStatePersisted,
9987
+ raw_attach_rolled_back: rawAttachRolledBack
9988
+ });
9989
+ printJson({
9990
+ session_id: sessionIdForConversation(reportedConversation),
9991
+ turn_id: turnIdForConversation(reportedConversation),
9992
+ conversation: reportedConversation,
9993
+ message,
9994
+ delivered: false,
9995
+ status: "submission_aborted",
9996
+ submission_outcome: "aborted",
9997
+ background: true,
9998
+ callback_expected: false,
9999
+ terminal_control: terminalControl,
10000
+ monitor_pid: bridgeMonitor?.pid ?? null,
10001
+ executor,
10002
+ safe_to_retry: safeToRetry,
10003
+ do_not_retry: !safeToRetry,
10004
+ reason: safeToRetry
10005
+ ? "AKK proved that terminal input never started; this submission may be retried."
10006
+ : "AKK proved terminal input never started but could not make every abort receipt and Session rollback durable; inspect before retrying.",
10007
+ openclaw_next_action: {
10008
+ action: safeToRetry ? "retry" : "inspect",
10009
+ conversation_id: reportedConversation.conversation_id,
10010
+ session_id: sessionIdForConversation(reportedConversation),
10011
+ turn_id: turnIdForConversation(reportedConversation),
10012
+ safe_to_retry: safeToRetry,
10013
+ do_not_retry: !safeToRetry,
10014
+ reason: safeToRetry
10015
+ ? "The terminal transport failed before any input operation succeeded."
10016
+ : "The pre-input failure could not be fully reconciled in durable state."
10017
+ }
10018
+ });
10019
+ return;
10020
+ }
9265
10021
  const uncertainAt = new Date().toISOString();
9266
10022
  const errorMessage = error instanceof Error ? error.message : String(error);
9267
10023
  const failureBase = stagedConversation;
@@ -18358,6 +19114,7 @@ function usage() {
18358
19114
  agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
18359
19115
  agent-knock-knock resume-thread --terminal <exact-terminal-id> --native-thread <uuid> --expected-binding-token <token> --candidate-token <token>
18360
19116
  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>
19117
+ agent-knock-knock reconcile-binding --terminal <exact-terminal-id> --conflicting-session <session-id> --expected-session-revision <n> --expected-binding-token <token> --expected-terminal-token <token>
18361
19118
  agent-knock-knock respond --turn <turn-id|selector> --message <text> [--conversation <selector>]
18362
19119
  agent-knock-knock approve [--turn <turn-id|selector>] [--conversation <selector>] --expected-approval-fingerprint <fingerprint>
18363
19120
  agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]