@scotthuang/agent-knock-knock 0.12.0 → 0.12.1

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.1 - 2026-08-11
4
+
5
+ ### Fixed
6
+
7
+ - Allow a genuinely virgin Codex TUI to accept its first AKK send by pinning the exact process incarnation before input, then atomically refining the Session and Turn to the newly materialized native UUID, rollout, and request evidence after the single Enter.
8
+ - Recover both post-Enter virgin-binding crash windows without replaying terminal text or Enter, while preserving the existing ownership, binding-generation, process, pane, cwd, rollout, and request-hash fences.
9
+ - Preserve tmux `pane_current_path` when extended eight-field pane output is collapsed to whitespace or underscores, rejecting ambiguous or truncated records instead of appending the server socket and pane ID to the workspace.
10
+ - Preserve Codex composer ANSI styling through Herdr's visible screen buffer so an empty dim placeholder is not mistaken for authored draft text, while leaving ordinary monitor and status reads on Herdr's agent-aware detection buffer.
11
+
3
12
  ## 0.12.0 - 2026-08-10
4
13
 
5
14
  ### Added
@@ -9907,21 +9907,22 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9907
9907
  runtime: preSendRuntime
9908
9908
  });
9909
9909
  assertSafeTerminalSend(executor.kind, status);
9910
- if (executor.kind === "codex" &&
9911
- (pendingManagedNativeBinding || allowedPreMaterializationIdentity)) {
9912
- const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
9913
- const foreground = createRuntimeTerminalAgentRegistry(options)
9914
- .require("codex")
9915
- .observeThreadLifecycle?.({
9916
- operation: { kind: "new_thread" },
9917
- phase: "before",
9918
- screen: status.screen.excerpt ?? ""
9919
- });
9920
- if (!expectedForegroundId ||
9921
- foreground?.status !== "observed" ||
9922
- foreground.nativeThreadId !== expectedForegroundId) {
9923
- throw new Error("Codex foreground thread changed after the managed /status proof; " +
9924
- "refresh list before sending");
9910
+ if (executor.kind === "codex" && needsPostSendNativeBinding) {
9911
+ if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
9912
+ const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
9913
+ const foreground = createRuntimeTerminalAgentRegistry(options)
9914
+ .require("codex")
9915
+ .observeThreadLifecycle?.({
9916
+ operation: { kind: "new_thread" },
9917
+ phase: "before",
9918
+ screen: status.screen.excerpt ?? ""
9919
+ });
9920
+ if (!expectedForegroundId ||
9921
+ foreground?.status !== "observed" ||
9922
+ foreground.nativeThreadId !== expectedForegroundId) {
9923
+ throw new Error("Codex foreground thread changed after the managed /status proof; " +
9924
+ "refresh list before sending");
9925
+ }
9925
9926
  }
9926
9927
  await assertCodexComposerReadyForAutomatedInput({
9927
9928
  options,
@@ -9936,6 +9937,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
9936
9937
  options,
9937
9938
  currentIdentity: currentNativeIdentity,
9938
9939
  expectedNativeThreadId: expectedManagedNativeThreadId,
9940
+ boundProcessUuid: stringValue(sendTakeover?.terminal_agent_process_uuid),
9941
+ boundProcessBirth: stringValue(sendTakeover?.terminal_agent_process_birth),
9939
9942
  allowedPreMaterializationIdentity,
9940
9943
  needsPostSendNativeBinding
9941
9944
  });
@@ -10332,6 +10335,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10332
10335
  if (!enterDispatchedAt) {
10333
10336
  throw new Error("terminal bridge returned without an enter_dispatched receipt");
10334
10337
  }
10338
+ if (codexRolloutAcceptanceAnchor?.version === 2 &&
10339
+ cliEnv().AKK_TEST_EXIT_AFTER_VIRGIN_ENTER_DISPATCHED === "1") {
10340
+ cliExit(86);
10341
+ }
10335
10342
  let submittedBase = stagedConversation;
10336
10343
  if (needsPostSendNativeBinding) {
10337
10344
  let boundIdentity;
@@ -10353,6 +10360,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10353
10360
  }
10354
10361
  if (boundIdentity) {
10355
10362
  try {
10363
+ if (codexRolloutAcceptanceAnchor &&
10364
+ (boundIdentity.processUuid !==
10365
+ codexRolloutAcceptanceAnchor.process_uuid ||
10366
+ boundIdentity.processBirth !==
10367
+ codexRolloutAcceptanceAnchor.process_birth)) {
10368
+ throw new Error("Codex process incarnation changed while its native " +
10369
+ "thread materialized");
10370
+ }
10356
10371
  const expectedNativeThreadId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
10357
10372
  if (expectedNativeThreadId &&
10358
10373
  boundIdentity.sessionId !== expectedNativeThreadId) {
@@ -10378,6 +10393,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
10378
10393
  terminalControl,
10379
10394
  identity: boundIdentity
10380
10395
  });
10396
+ if (codexRolloutAcceptanceAnchor?.version === 2 &&
10397
+ cliEnv().AKK_TEST_EXIT_AFTER_VIRGIN_SESSION_BINDING === "1") {
10398
+ cliExit(86);
10399
+ }
10381
10400
  // A virgin terminal Turn and its Session start with the same
10382
10401
  // provisional binding id/generation before Codex has materialized a
10383
10402
  // native thread. Persist the observed identity as a monotonic
@@ -11050,7 +11069,7 @@ function syntheticTerminalAcceptanceEvidence({ options, executor, conversation,
11050
11069
  .digest("hex")
11051
11070
  };
11052
11071
  }
11053
- function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
11072
+ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, boundProcessUuid, boundProcessBirth, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
11054
11073
  if (allowsSyntheticTerminalAcceptance(options)) {
11055
11074
  return undefined;
11056
11075
  }
@@ -11058,25 +11077,258 @@ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expecte
11058
11077
  ? expectedNativeThreadId
11059
11078
  : currentIdentity?.sessionId;
11060
11079
  const processUuid = currentIdentity?.processUuid ??
11061
- allowedPreMaterializationIdentity?.processUuid;
11080
+ allowedPreMaterializationIdentity?.processUuid ??
11081
+ boundProcessUuid;
11062
11082
  const processBirth = currentIdentity?.processBirth ??
11063
- allowedPreMaterializationIdentity?.processBirth;
11064
- if (!nativeThreadId || !processUuid || !processBirth) {
11065
- throw new Error("Codex native acceptance anchor requires an exact thread and process incarnation");
11083
+ allowedPreMaterializationIdentity?.processBirth ??
11084
+ boundProcessBirth;
11085
+ if (!processUuid || !processBirth) {
11086
+ throw new Error("Codex native acceptance anchor requires an exact process incarnation");
11087
+ }
11088
+ if (!nativeThreadId && !needsPostSendNativeBinding) {
11089
+ throw new Error("Codex native acceptance anchor requires an exact thread");
11066
11090
  }
11067
- const base = { nativeThreadId, processUuid, processBirth };
11068
11091
  if (!needsPostSendNativeBinding && currentIdentity?.rollout) {
11069
11092
  return captureCodexRolloutAcceptanceAnchor({
11070
- ...base,
11093
+ nativeThreadId: nativeThreadId,
11094
+ processUuid,
11095
+ processBirth,
11096
+ now: cliNow(),
11071
11097
  mode: "existing",
11072
11098
  rollout: currentIdentity.rollout
11073
11099
  });
11074
11100
  }
11075
- return captureCodexRolloutAcceptanceAnchor({
11076
- ...base,
11077
- mode: "pre_materialization",
11078
- expectedEmptyNativeSession: true
11079
- });
11101
+ return nativeThreadId
11102
+ ? captureCodexRolloutAcceptanceAnchor({
11103
+ nativeThreadId,
11104
+ processUuid,
11105
+ processBirth,
11106
+ now: cliNow(),
11107
+ mode: "pre_materialization",
11108
+ expectedEmptyNativeSession: true
11109
+ })
11110
+ : captureCodexRolloutAcceptanceAnchor({
11111
+ processUuid,
11112
+ processBirth,
11113
+ now: cliNow(),
11114
+ mode: "pre_materialization",
11115
+ expectedEmptyNativeSession: true
11116
+ });
11117
+ }
11118
+ function exactNativeRolloutMatches(left, right) {
11119
+ return Boolean(isCompleteNativeRollout(left) &&
11120
+ right &&
11121
+ left.fd === right.fd &&
11122
+ left.device === right.device &&
11123
+ left.inode === right.inode &&
11124
+ left.path === right.path);
11125
+ }
11126
+ /**
11127
+ * Finish the one monotonic binding transaction that a process crash may split
11128
+ * after a virgin Codex Turn has durably dispatched Enter. This is deliberately
11129
+ * scoped to the v2 post-submission anchor: legacy/v1 Turns retain their normal
11130
+ * binding fences and never gain a new recovery path.
11131
+ */
11132
+ async function recoverVirginCodexPostSubmissionBinding({ options, conversation, statePath, logPath, terminalLockHeld = false }) {
11133
+ const initialTakeover = isRecord(conversation.native_session_takeover)
11134
+ ? conversation.native_session_takeover
11135
+ : undefined;
11136
+ const initialAnchor = isRecord(initialTakeover?.codex_rollout_acceptance_anchor)
11137
+ ? initialTakeover.codex_rollout_acceptance_anchor
11138
+ : undefined;
11139
+ const initialSubmission = terminalBridgeSubmission(conversation);
11140
+ const initialTerminalControl = terminalControlFromTakeover(initialTakeover);
11141
+ if (executorForConversation(conversation).kind !== "codex" ||
11142
+ initialAnchor?.version !== 2 ||
11143
+ initialAnchor?.native_thread_binding !== "post_submission" ||
11144
+ !initialTerminalControl ||
11145
+ !["enter_dispatched", "agent_accepted"].includes(String(initialSubmission?.status ?? ""))) {
11146
+ return { conversation, state: "not_applicable" };
11147
+ }
11148
+ const storeDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
11149
+ const releaseTerminalLock = terminalLockHeld
11150
+ ? () => { }
11151
+ : acquireTerminalBridgeSendLock(storeDir, initialTerminalControl, { timeoutMs: 30000 });
11152
+ try {
11153
+ return await withStoreWriterLeaseAsync(storeDir, async () => {
11154
+ const releaseStateLock = acquireFileLock(`${statePath}.lock`);
11155
+ try {
11156
+ const current = loadState(statePath);
11157
+ const takeover = isRecord(current.native_session_takeover)
11158
+ ? current.native_session_takeover
11159
+ : undefined;
11160
+ const anchor = isRecord(takeover?.codex_rollout_acceptance_anchor)
11161
+ ? takeover.codex_rollout_acceptance_anchor
11162
+ : undefined;
11163
+ const submission = terminalBridgeSubmission(current);
11164
+ const terminalControl = terminalControlFromTakeover(takeover);
11165
+ const messageId = stringValue(submission?.message_id);
11166
+ const requestHash = stringValue(takeover?.terminal_bridge_request_hash);
11167
+ const requestText = String(takeover?.terminal_bridge_request_text ?? current.user_request ?? "");
11168
+ if (executorForConversation(current).kind !== "codex" ||
11169
+ anchor?.version !== 2 ||
11170
+ anchor?.native_thread_binding !== "post_submission" ||
11171
+ !terminalControl ||
11172
+ !terminalControlsShareIncarnation(terminalControl, initialTerminalControl) ||
11173
+ !messageId ||
11174
+ messageId !== stringValue(takeover?.terminal_bridge_message_id) ||
11175
+ !["enter_dispatched", "agent_accepted"].includes(String(submission?.status ?? "")) ||
11176
+ !requestHash ||
11177
+ requestHash !== terminalBridgeRequestFingerprint(requestText)) {
11178
+ return { conversation: current, state: "not_applicable" };
11179
+ }
11180
+ const bindingId = stringValue(current.terminal_binding_id);
11181
+ const bindingGeneration = Number(current.terminal_binding_generation);
11182
+ const session = loadManagedSession(storeDir, sessionIdForConversation(current));
11183
+ const binding = session.binding;
11184
+ const pid = Number(takeover?.terminal_agent_pid);
11185
+ if (!bindingId ||
11186
+ !Number.isSafeInteger(bindingGeneration) ||
11187
+ !Number.isSafeInteger(pid) ||
11188
+ pid <= 1 ||
11189
+ session.agent !== "codex" ||
11190
+ session.status !== "bound" ||
11191
+ !binding ||
11192
+ binding.binding_id !== bindingId ||
11193
+ binding.generation !== bindingGeneration ||
11194
+ binding.native_process.pid !== pid ||
11195
+ !terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
11196
+ throw new Error("virgin Codex post-submission binding changed before recovery");
11197
+ }
11198
+ const anchorProcessUuid = stringValue(anchor.process_uuid);
11199
+ const anchorProcessBirth = stringValue(anchor.process_birth);
11200
+ const turnProcessUuid = stringValue(takeover?.terminal_agent_process_uuid);
11201
+ const turnProcessBirth = stringValue(takeover?.terminal_agent_process_birth);
11202
+ if (!anchorProcessUuid ||
11203
+ !anchorProcessBirth ||
11204
+ turnProcessUuid !== anchorProcessUuid ||
11205
+ turnProcessBirth !== anchorProcessBirth ||
11206
+ binding.native_process.process_uuid !== anchorProcessUuid ||
11207
+ binding.native_process.process_birth !== anchorProcessBirth) {
11208
+ throw new Error("virgin Codex process incarnation changed before binding recovery");
11209
+ }
11210
+ const turnNativeThreadId = stringValue(current.native_thread_id) ??
11211
+ stringValue(takeover?.terminal_agent_session_id);
11212
+ const sessionNativeThreadId = binding.native_thread_id;
11213
+ if (turnNativeThreadId &&
11214
+ sessionNativeThreadId &&
11215
+ turnNativeThreadId !== sessionNativeThreadId) {
11216
+ throw new Error("virgin Codex Session and Turn disagree before binding recovery");
11217
+ }
11218
+ if (turnNativeThreadId && !isCompleteNativeRollout(takeover?.terminal_agent_rollout)) {
11219
+ throw new Error("virgin Codex Turn has a partial recovered native identity");
11220
+ }
11221
+ if (sessionNativeThreadId && !binding.native_process.rollout) {
11222
+ throw new Error("virgin Codex Session has a partial recovered native identity");
11223
+ }
11224
+ if (turnNativeThreadId && sessionNativeThreadId) {
11225
+ assertTurnBindingCurrent(current, "recover virgin Codex binding for");
11226
+ return { conversation: current, state: "already_bound" };
11227
+ }
11228
+ const preferredSessionId = turnNativeThreadId ?? sessionNativeThreadId;
11229
+ const identity = await resolveCurrentNativeAgentSessionIdentity({
11230
+ options,
11231
+ agent: "codex",
11232
+ pid,
11233
+ cwd: terminalControl.currentPath,
11234
+ preferredSessionId
11235
+ });
11236
+ if (!identity) {
11237
+ return { conversation: current, state: "pending" };
11238
+ }
11239
+ if (!isExactNativeThreadId(identity.sessionId) ||
11240
+ identity.processUuid !== anchorProcessUuid ||
11241
+ identity.processBirth !== anchorProcessBirth ||
11242
+ !isCompleteNativeRollout(identity.rollout) ||
11243
+ (preferredSessionId && identity.sessionId !== preferredSessionId) ||
11244
+ (turnNativeThreadId &&
11245
+ !exactNativeRolloutMatches(takeover?.terminal_agent_rollout, identity.rollout)) ||
11246
+ (sessionNativeThreadId &&
11247
+ !exactNativeRolloutMatches(binding.native_process.rollout, identity.rollout))) {
11248
+ throw new Error("virgin Codex native identity changed before binding recovery");
11249
+ }
11250
+ // When neither side was committed, only the exact native acceptance
11251
+ // record may tell us that this newly materialized UUID belongs to the
11252
+ // dispatched request. If either side was already committed before the
11253
+ // crash, that durable CAS plus a fresh exact identity is sufficient to
11254
+ // finish the other side while acceptance is still being written.
11255
+ if (!turnNativeThreadId && !sessionNativeThreadId) {
11256
+ const acceptance = detectCodexRolloutAcceptance({
11257
+ anchor: anchor,
11258
+ currentIdentity: identity,
11259
+ requestHash
11260
+ });
11261
+ if (!acceptance) {
11262
+ return { conversation: current, state: "pending" };
11263
+ }
11264
+ }
11265
+ await assertNativeThreadHasExclusiveOwnership({
11266
+ options,
11267
+ agent: "codex",
11268
+ currentPid: pid,
11269
+ nativeThreadId: identity.sessionId,
11270
+ storeDir,
11271
+ terminalControl,
11272
+ excludedManagedSessionId: session.session_id
11273
+ });
11274
+ if (!sessionNativeThreadId) {
11275
+ const persistedSession = persistManagedSessionNativeIdentity({
11276
+ conversation: current,
11277
+ terminalControl,
11278
+ identity
11279
+ });
11280
+ if (persistedSession?.binding?.native_thread_id !==
11281
+ identity.sessionId ||
11282
+ persistedSession.binding.native_process.process_uuid !==
11283
+ identity.processUuid ||
11284
+ persistedSession.binding.native_process.process_birth !==
11285
+ identity.processBirth ||
11286
+ !exactNativeRolloutMatches(persistedSession.binding.native_process.rollout, identity.rollout)) {
11287
+ throw new Error("virgin Codex Session identity was not durably committed during recovery");
11288
+ }
11289
+ }
11290
+ const recoveredAt = cliNow().toISOString();
11291
+ const recoveredConversation = turnNativeThreadId
11292
+ ? current
11293
+ : {
11294
+ ...withNativeAgentSessionIdentity(current, identity),
11295
+ updated_at: recoveredAt
11296
+ };
11297
+ if (!turnNativeThreadId) {
11298
+ saveState(statePath, recoveredConversation);
11299
+ }
11300
+ assertNativeAgentIdentityForTurn({
11301
+ conversation: recoveredConversation,
11302
+ currentIdentity: identity,
11303
+ operation: "recover virgin Codex binding for"
11304
+ });
11305
+ try {
11306
+ appendEvent(logPath, {
11307
+ ts: recoveredAt,
11308
+ conversation_id: recoveredConversation.conversation_id,
11309
+ event: "virgin_codex_post_submission_binding_recovered",
11310
+ message_id: messageId,
11311
+ native_thread_id: identity.sessionId,
11312
+ terminal_control: terminalControl
11313
+ });
11314
+ }
11315
+ catch (error) {
11316
+ runtimeLog("warn", "virgin_codex_binding_recovery_event_failed", {
11317
+ conversation_id: recoveredConversation.conversation_id,
11318
+ terminal_target: terminalControl.target,
11319
+ error: error instanceof Error ? error.message : String(error)
11320
+ });
11321
+ }
11322
+ return { conversation: recoveredConversation, state: "recovered" };
11323
+ }
11324
+ finally {
11325
+ releaseStateLock();
11326
+ }
11327
+ });
11328
+ }
11329
+ finally {
11330
+ releaseTerminalLock();
11331
+ }
11080
11332
  }
11081
11333
  async function detectTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl }) {
11082
11334
  const nativeTakeover = isRecord(conversation.native_session_takeover)
@@ -11187,12 +11439,57 @@ async function pollTerminalSubmissionAcceptance({ options, executor, conversatio
11187
11439
  return { outcome: "pending_acceptance" };
11188
11440
  }
11189
11441
  async function reconcileTerminalAcceptanceInMonitor({ options, conversation, statePath, logPath, terminalControl, executor, terminalBridge }) {
11190
- const evidence = await detectTerminalSubmissionAcceptance({
11442
+ let bindingRecovery = await recoverVirginCodexPostSubmissionBinding({
11443
+ options,
11444
+ conversation,
11445
+ statePath,
11446
+ logPath,
11447
+ terminalLockHeld: true
11448
+ });
11449
+ conversation = bindingRecovery.conversation;
11450
+ let evidence = await detectTerminalSubmissionAcceptance({
11191
11451
  options,
11192
11452
  executor: executor.kind,
11193
11453
  conversation,
11194
11454
  terminalControl
11195
11455
  });
11456
+ const recoveredAnchor = isRecord(conversation.native_session_takeover) &&
11457
+ isRecord(conversation.native_session_takeover.codex_rollout_acceptance_anchor)
11458
+ ? conversation.native_session_takeover.codex_rollout_acceptance_anchor
11459
+ : undefined;
11460
+ if (evidence &&
11461
+ recoveredAnchor?.version === 2 &&
11462
+ bindingRecovery.state === "not_applicable") {
11463
+ throw new Error("virgin Codex acceptance appeared without a recoverable Session/Turn binding");
11464
+ }
11465
+ if (evidence &&
11466
+ recoveredAnchor?.version === 2 &&
11467
+ bindingRecovery.state === "pending") {
11468
+ // The session_meta and exact user record can land between the first
11469
+ // recovery probe and this acceptance probe. Never persist agent_accepted
11470
+ // until the same evidence has also closed the Session/Turn binding CAS.
11471
+ bindingRecovery = await recoverVirginCodexPostSubmissionBinding({
11472
+ options,
11473
+ conversation,
11474
+ statePath,
11475
+ logPath,
11476
+ terminalLockHeld: true
11477
+ });
11478
+ if (bindingRecovery.state !== "recovered" &&
11479
+ bindingRecovery.state !== "already_bound") {
11480
+ return { outcome: "pending" };
11481
+ }
11482
+ conversation = bindingRecovery.conversation;
11483
+ evidence = await detectTerminalSubmissionAcceptance({
11484
+ options,
11485
+ executor: executor.kind,
11486
+ conversation,
11487
+ terminalControl
11488
+ });
11489
+ if (!evidence) {
11490
+ return { outcome: "pending" };
11491
+ }
11492
+ }
11196
11493
  const submission = terminalBridgeSubmission(conversation);
11197
11494
  const nativeTakeover = isRecord(conversation.native_session_takeover)
11198
11495
  ? conversation.native_session_takeover
@@ -12331,12 +12628,18 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
12331
12628
  ignored += 1;
12332
12629
  continue;
12333
12630
  }
12334
- const initialConversation = await migrateLegacyTerminalAgentIdentity({
12631
+ let initialConversation = await migrateLegacyTerminalAgentIdentity({
12335
12632
  conversation: loadState(statePath),
12336
12633
  statePath,
12337
12634
  logPath,
12338
12635
  options
12339
12636
  });
12637
+ initialConversation = (await recoverVirginCodexPostSubmissionBinding({
12638
+ options,
12639
+ conversation: initialConversation,
12640
+ statePath,
12641
+ logPath
12642
+ })).conversation;
12340
12643
  assertTurnBindingCurrent(initialConversation, "reconcile monitor for");
12341
12644
  const initialEligibility = terminalBridgeReconciliationEligibility(initialConversation);
12342
12645
  if (!initialEligibility.eligible) {