@scotthuang/agent-knock-knock 0.12.0 → 0.12.2
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 +25 -0
- package/README.md +12 -9
- package/dist/src/cli-core.js +1614 -74
- package/dist/src/cli-core.js.map +1 -1
- package/dist/src/herdr-terminal-control-provider.js +8 -3
- package/dist/src/herdr-terminal-control-provider.js.map +1 -1
- package/dist/src/managed-session.d.ts +30 -2
- package/dist/src/managed-session.js +70 -6
- package/dist/src/managed-session.js.map +1 -1
- package/dist/src/openclaw-plugin.js +60 -11
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/session-store.d.ts +1 -0
- package/dist/src/session-store.js +32 -4
- package/dist/src/session-store.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +13 -0
- package/dist/src/terminal-agent-bridge.js +57 -6
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-control-provider.js +60 -16
- package/dist/src/terminal-control-provider.js.map +1 -1
- package/dist/src/terminal-submission-acceptance.d.ts +31 -6
- package/dist/src/terminal-submission-acceptance.js +93 -16
- package/dist/src/terminal-submission-acceptance.js.map +1 -1
- package/package.json +1 -1
- package/templates/openclaw-skills/agent-knock-knock/SKILL.md +12 -11
package/dist/src/cli-core.js
CHANGED
|
@@ -16,10 +16,10 @@ import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./exe
|
|
|
16
16
|
import { redactString, writeRuntimeLog } from "./runtime-log.js";
|
|
17
17
|
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
|
-
import { createManagedSessionId, createNativeThreadTransitionId, isExactNativeThreadId, legacyManagedSessionBindingToken, legacyUnmanagedTerminalBindingToken, managedSessionBindingToken, nativeThreadCommandFingerprint, terminalBindingFrom, unmanagedTerminalBindingToken } from "./managed-session.js";
|
|
19
|
+
import { createManagedSessionId, createNativeThreadTransitionId, humanObservedHandoffBindingToken, isExactNativeThreadId, legacyManagedSessionBindingToken, legacyUnmanagedTerminalBindingToken, 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, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
|
|
22
|
+
import { listNativeThreadTransitions, listManagedSessions, loadManagedSession, loadNativeThreadTransition, nativeThreadTransitionsDir, saveManagedSession, saveNativeThreadTransition, tryLoadManagedSession } from "./session-store.js";
|
|
23
23
|
import { createTerminalControlProviderRegistry as createProviderRegistry, StaticTerminalControlProvider, TmuxTerminalControlProvider } from "./terminal-control-provider.js";
|
|
24
24
|
import { HerdrTerminalControlProvider } from "./herdr-terminal-control-provider.js";
|
|
25
25
|
import { parseTerminalConversationId } from "./terminal-agent-adapter.js";
|
|
@@ -3220,11 +3220,55 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3220
3220
|
const soleBindingConflict = conflictingBoundSessionClaims.length === 1
|
|
3221
3221
|
? conflictingBoundSessionClaims[0]
|
|
3222
3222
|
: undefined;
|
|
3223
|
+
const externalHandoffDetected = conflictingBoundSessionClaims.some(({ kind }) => kind === "live_external_thread_change");
|
|
3223
3224
|
const conflictingSessionRevision = Number(soleBindingConflict?.session.revision);
|
|
3224
3225
|
const conflictingSessionTurns = soleBindingConflict
|
|
3225
3226
|
? managedTurnsForSession(storeDir, soleBindingConflict.session.session_id)
|
|
3226
3227
|
: [];
|
|
3227
3228
|
const expectedTerminalToken = stringValue(terminal.lifecycle_binding_token);
|
|
3229
|
+
const externalHandoffNativeThreadId = stringValue(terminal.native_agent_status_card_session_id) ?? stringValue(terminal.native_agent_session_id);
|
|
3230
|
+
const resolvedNativeThreadId = stringValue(terminal.native_agent_session_id);
|
|
3231
|
+
const externalHandoffTerminalToken = terminalControl &&
|
|
3232
|
+
externalHandoffNativeThreadId &&
|
|
3233
|
+
isExactNativeThreadId(externalHandoffNativeThreadId)
|
|
3234
|
+
? unmanagedTerminalBindingToken({
|
|
3235
|
+
terminalId: stringValue(terminal.id),
|
|
3236
|
+
terminalControl,
|
|
3237
|
+
agent: terminal.agent,
|
|
3238
|
+
pid: Number(terminal.pid),
|
|
3239
|
+
workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
|
|
3240
|
+
nativeThreadId: externalHandoffNativeThreadId,
|
|
3241
|
+
processUuid: stringValue(terminal.native_agent_process_uuid),
|
|
3242
|
+
processBirth: stringValue(terminal.native_agent_process_birth),
|
|
3243
|
+
rollout: resolvedNativeThreadId === externalHandoffNativeThreadId &&
|
|
3244
|
+
isRecord(terminal.native_agent_rollout)
|
|
3245
|
+
? terminal.native_agent_rollout
|
|
3246
|
+
: undefined
|
|
3247
|
+
})
|
|
3248
|
+
: undefined;
|
|
3249
|
+
const externalHandoffTarget = soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3250
|
+
externalHandoffNativeThreadId &&
|
|
3251
|
+
isExactNativeThreadId(externalHandoffNativeThreadId)
|
|
3252
|
+
? observedHandoffTargetResolution({
|
|
3253
|
+
storeDir,
|
|
3254
|
+
agent: terminal.agent,
|
|
3255
|
+
workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
|
|
3256
|
+
nativeThreadId: externalHandoffNativeThreadId.toLowerCase(),
|
|
3257
|
+
sourceSessionId: soleBindingConflict.session.session_id
|
|
3258
|
+
})
|
|
3259
|
+
: undefined;
|
|
3260
|
+
const externalHandoffSnapshotToken = externalHandoffTerminalToken &&
|
|
3261
|
+
soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3262
|
+
externalHandoffTarget?.status === "eligible"
|
|
3263
|
+
? humanObservedHandoffBindingToken({
|
|
3264
|
+
terminal_token: externalHandoffTerminalToken,
|
|
3265
|
+
source_session_id: soleBindingConflict.session.session_id,
|
|
3266
|
+
source_revision: managedSessionRevision(soleBindingConflict.session),
|
|
3267
|
+
source_binding_token: managedSessionBindingToken(soleBindingConflict.session),
|
|
3268
|
+
target: externalHandoffTarget.snapshot
|
|
3269
|
+
})
|
|
3270
|
+
: undefined;
|
|
3271
|
+
const blockingHandoffTurns = conflictingSessionTurns.filter((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
|
|
3228
3272
|
const reconcileBindingAction = mutationsAllowed &&
|
|
3229
3273
|
discoveredOwnership.state === "none" &&
|
|
3230
3274
|
unresolvedSessionClaims.length === 0 &&
|
|
@@ -3251,6 +3295,65 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3251
3295
|
requires_user_intent: true
|
|
3252
3296
|
}
|
|
3253
3297
|
: undefined;
|
|
3298
|
+
const externalHandoffAdoptable = Boolean(mutationsAllowed &&
|
|
3299
|
+
discoveredOwnership.state === "none" &&
|
|
3300
|
+
unresolvedSessionClaims.length === 0 &&
|
|
3301
|
+
soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3302
|
+
terminal.activity_state === "idle" &&
|
|
3303
|
+
!(isRecord(terminal.approval_state) &&
|
|
3304
|
+
terminal.approval_state.blocked === true) &&
|
|
3305
|
+
!conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
|
|
3306
|
+
!managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
|
|
3307
|
+
externalHandoffTarget?.status === "eligible" &&
|
|
3308
|
+
isRecord(rawActions.send) &&
|
|
3309
|
+
Boolean(externalHandoffSnapshotToken));
|
|
3310
|
+
const handoffDecisionTurn = mutationsAllowed &&
|
|
3311
|
+
soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3312
|
+
externalHandoffTarget?.status === "eligible" &&
|
|
3313
|
+
terminal.activity_state === "idle" &&
|
|
3314
|
+
!(isRecord(terminal.approval_state) &&
|
|
3315
|
+
terminal.approval_state.blocked === true) &&
|
|
3316
|
+
!managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
|
|
3317
|
+
blockingHandoffTurns.length === 1
|
|
3318
|
+
? blockingHandoffTurns[0]
|
|
3319
|
+
: undefined;
|
|
3320
|
+
const handoffDecisionToken = handoffDecisionTurn &&
|
|
3321
|
+
externalHandoffSnapshotToken &&
|
|
3322
|
+
terminalControl
|
|
3323
|
+
? activeTurnHandoffDecisionToken({
|
|
3324
|
+
handoffToken: externalHandoffSnapshotToken,
|
|
3325
|
+
turn: handoffDecisionTurn,
|
|
3326
|
+
ledger: loadTerminalBridgeDispatchLedger(terminalControl)
|
|
3327
|
+
})
|
|
3328
|
+
: undefined;
|
|
3329
|
+
const handoffDecision = handoffDecisionTurn &&
|
|
3330
|
+
handoffDecisionToken &&
|
|
3331
|
+
externalHandoffNativeThreadId
|
|
3332
|
+
? {
|
|
3333
|
+
kind: "active_turn_requires_decision",
|
|
3334
|
+
source_session_id: soleBindingConflict?.session.session_id,
|
|
3335
|
+
source_turn_id: turnIdForConversation(handoffDecisionTurn),
|
|
3336
|
+
live_native_thread_id: externalHandoffNativeThreadId,
|
|
3337
|
+
choices: {
|
|
3338
|
+
take_over_current: {
|
|
3339
|
+
action: {
|
|
3340
|
+
tool: "agent_knock_knock_close",
|
|
3341
|
+
arguments: {
|
|
3342
|
+
turn_id: turnIdForConversation(handoffDecisionTurn),
|
|
3343
|
+
reason: "superseded_by_human_context_switch",
|
|
3344
|
+
expected_handoff_token: handoffDecisionToken
|
|
3345
|
+
},
|
|
3346
|
+
requires_explicit_user_confirmation: true
|
|
3347
|
+
},
|
|
3348
|
+
after: "refresh list and use its follow-current send"
|
|
3349
|
+
},
|
|
3350
|
+
keep_source: {
|
|
3351
|
+
effect: "no Store or terminal mutation",
|
|
3352
|
+
after: "restore source native thread in the Codex/Claude TUI, then refresh list"
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
: undefined;
|
|
3254
3357
|
const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
|
|
3255
3358
|
if (ownership.state === "current" &&
|
|
3256
3359
|
!allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
|
|
@@ -3339,6 +3442,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3339
3442
|
: ownership.state === "conflict"
|
|
3340
3443
|
? {
|
|
3341
3444
|
...safeTerminalActionsDuringConflict(sessionAwareRawActions),
|
|
3445
|
+
...(externalHandoffAdoptable
|
|
3446
|
+
? {
|
|
3447
|
+
send: {
|
|
3448
|
+
...rawActions.send,
|
|
3449
|
+
arguments: {
|
|
3450
|
+
...(isRecord(rawActions.send.arguments)
|
|
3451
|
+
? rawActions.send.arguments
|
|
3452
|
+
: {}),
|
|
3453
|
+
expected_terminal_token: externalHandoffSnapshotToken
|
|
3454
|
+
}
|
|
3455
|
+
}
|
|
3456
|
+
}
|
|
3457
|
+
: {}),
|
|
3342
3458
|
...(reconcileBindingAction
|
|
3343
3459
|
? { reconcile_binding: reconcileBindingAction }
|
|
3344
3460
|
: {})
|
|
@@ -3377,6 +3493,14 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3377
3493
|
...(ownership.state === "conflict"
|
|
3378
3494
|
? { management_conflict: ownership.conflict }
|
|
3379
3495
|
: {}),
|
|
3496
|
+
...(externalHandoffDetected
|
|
3497
|
+
? {
|
|
3498
|
+
handoff_state: externalHandoffAdoptable
|
|
3499
|
+
? "external_handoff_adoptable"
|
|
3500
|
+
: "external_handoff_blocked"
|
|
3501
|
+
}
|
|
3502
|
+
: {}),
|
|
3503
|
+
...(handoffDecision ? { handoff_decision: handoffDecision } : {}),
|
|
3380
3504
|
managed: management,
|
|
3381
3505
|
available_actions: availableActions
|
|
3382
3506
|
};
|
|
@@ -3558,8 +3682,10 @@ function managedBindingConflictKindForLiveTerminalEntry({ storeDir, session, ter
|
|
|
3558
3682
|
statusCardThreadId.toLowerCase()) {
|
|
3559
3683
|
return relationship === "same" &&
|
|
3560
3684
|
isExactNativeThreadId(liveNativeThreadId) &&
|
|
3561
|
-
liveNativeThreadId.toLowerCase() ===
|
|
3562
|
-
statusCardThreadId.toLowerCase()
|
|
3685
|
+
(liveNativeThreadId.toLowerCase() ===
|
|
3686
|
+
statusCardThreadId.toLowerCase() ||
|
|
3687
|
+
liveNativeThreadId.toLowerCase() ===
|
|
3688
|
+
binding.native_thread_id.toLowerCase())
|
|
3563
3689
|
? "live_external_thread_change"
|
|
3564
3690
|
: "unverifiable";
|
|
3565
3691
|
}
|
|
@@ -4073,15 +4199,15 @@ function managedListApprovalState(conversation) {
|
|
|
4073
4199
|
}
|
|
4074
4200
|
function listActionContracts() {
|
|
4075
4201
|
return {
|
|
4076
|
-
version:
|
|
4202
|
+
version: 8,
|
|
4077
4203
|
instructions: [
|
|
4078
|
-
"Treat terminals[] as the primary resource and use only actions present in available_actions.",
|
|
4204
|
+
"Treat terminals[] as the primary resource and use only actions present in available_actions, except the snapshot-bound terminals[].handoff_decision.choices.take_over_current.action. That nested action requires explicit user confirmation; after it succeeds, refresh list before any follow-current send.",
|
|
4079
4205
|
"An existing managed session's ordinary send targets session_id and creates a new turn. A turn id is never an ordinary send target.",
|
|
4080
4206
|
"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.",
|
|
4081
4207
|
"Native inspection is a separate terminal action: use only its closed inspection enum and current exact terminal_id/token; AKK status does not execute a native slash command.",
|
|
4082
|
-
"A
|
|
4208
|
+
"A verified, idle human native-thread switch may expose a terminal-scoped send with expected_terminal_token; that action atomically adopts the live context before creating its Turn. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
|
|
4083
4209
|
"List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
|
|
4084
|
-
"
|
|
4210
|
+
"Use a terminal selector only when explicitly named by the user or prefilled by that terminal row's send action. A handoff action also carries expected_terminal_token; never infer, guess, or reuse either value.",
|
|
4085
4211
|
"Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
|
|
4086
4212
|
"Managed controls target turn_id. A raw terminal may be controlled only through its own list-prefilled conversation_id action; never construct, guess, or reuse that compatibility selector.",
|
|
4087
4213
|
"Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
|
|
@@ -4112,6 +4238,12 @@ function listActionContracts() {
|
|
|
4112
4238
|
available_actions: {
|
|
4113
4239
|
meaning: "currently_safe_actions",
|
|
4114
4240
|
authoritative_for_tool_calls: true
|
|
4241
|
+
},
|
|
4242
|
+
handoff_decision: {
|
|
4243
|
+
meaning: "an explicit human choice required before superseding an active source Turn",
|
|
4244
|
+
authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
|
|
4245
|
+
requires_explicit_user_confirmation: true,
|
|
4246
|
+
after_success: "refresh list before using a follow-current send action"
|
|
4115
4247
|
}
|
|
4116
4248
|
},
|
|
4117
4249
|
actions: {
|
|
@@ -4123,13 +4255,14 @@ function listActionContracts() {
|
|
|
4123
4255
|
required: ["request"],
|
|
4124
4256
|
optional: [
|
|
4125
4257
|
"selector",
|
|
4258
|
+
"expected_terminal_token",
|
|
4126
4259
|
"type",
|
|
4127
4260
|
"idleTimeoutMinutes",
|
|
4128
4261
|
"agentTimeoutMinutes",
|
|
4129
4262
|
"agentHardTimeoutMinutes"
|
|
4130
4263
|
],
|
|
4131
4264
|
unsupported: ["timeoutSeconds"],
|
|
4132
|
-
ordinary_use: "Create a new managed turn in the
|
|
4265
|
+
ordinary_use: "Create a new managed turn in the exact Session. A live terminal selector can attach an unmanaged pane or adopt one verified human-selected native context; an explicit session_id never follows the pane."
|
|
4133
4266
|
},
|
|
4134
4267
|
new_thread: {
|
|
4135
4268
|
tool: "agent_knock_knock_new_thread",
|
|
@@ -4240,9 +4373,11 @@ function listActionContracts() {
|
|
|
4240
4373
|
optional: [
|
|
4241
4374
|
"reason",
|
|
4242
4375
|
"expected_message_id",
|
|
4243
|
-
"expected_transition_id"
|
|
4376
|
+
"expected_transition_id",
|
|
4377
|
+
"expected_handoff_token"
|
|
4244
4378
|
],
|
|
4245
|
-
requires_explicit_user_confirmation: true
|
|
4379
|
+
requires_explicit_user_confirmation: true,
|
|
4380
|
+
handoff_scope: "expected_handoff_token is valid only by copying the complete nested action from terminals[].handoff_decision.choices.take_over_current; never construct, guess, or reuse it."
|
|
4246
4381
|
}
|
|
4247
4382
|
}
|
|
4248
4383
|
};
|
|
@@ -4413,11 +4548,18 @@ function availableListActions(entry, { conversation } = {}) {
|
|
|
4413
4548
|
return actions;
|
|
4414
4549
|
}
|
|
4415
4550
|
async function resolveConversationSelectorOption(commandName, options) {
|
|
4551
|
+
const sendOperation = commandName === "send";
|
|
4552
|
+
if (sendOperation && stringValue(options.expectedTerminalToken)) {
|
|
4553
|
+
// Selector resolution may replace an alias (or an omitted selector) with a
|
|
4554
|
+
// discovered full terminal id. Preserve the caller's actual authority so
|
|
4555
|
+
// the handoff token fence cannot mistake that convenience resolution for
|
|
4556
|
+
// an exact selector supplied by the caller.
|
|
4557
|
+
originalExpectedTerminalSelector.set(options, stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim());
|
|
4558
|
+
}
|
|
4416
4559
|
if (!SESSION_SELECTOR_COMMANDS.has(String(commandName ?? "")) ||
|
|
4417
4560
|
options.state) {
|
|
4418
4561
|
return;
|
|
4419
4562
|
}
|
|
4420
|
-
const sendOperation = commandName === "send";
|
|
4421
4563
|
const supplied = stringValue(sendOperation
|
|
4422
4564
|
? options.session ?? options.conversation ?? options.conversationId
|
|
4423
4565
|
: options.turn ?? options.conversation ?? options.conversationId)?.trim();
|
|
@@ -4859,7 +5001,10 @@ function codexKnownBeforeIdentityForManagedSession({ storeDir, session, requireN
|
|
|
4859
5001
|
}
|
|
4860
5002
|
const after = transition.after_binding;
|
|
4861
5003
|
if (transition.status !== "committed" ||
|
|
4862
|
-
(requireNewThread &&
|
|
5004
|
+
(requireNewThread && ![
|
|
5005
|
+
"new_thread",
|
|
5006
|
+
"adopt_external_thread"
|
|
5007
|
+
].includes(transition.operation)) ||
|
|
4863
5008
|
transition.target_session_id !== session.session_id ||
|
|
4864
5009
|
!after ||
|
|
4865
5010
|
after.binding_id !== binding.binding_id ||
|
|
@@ -4911,7 +5056,7 @@ function codexLingeringBeforeIdentityMatchesSession({ storeDir, session, identit
|
|
|
4911
5056
|
}
|
|
4912
5057
|
const after = transition.after_binding;
|
|
4913
5058
|
if (transition.status !== "committed" ||
|
|
4914
|
-
transition.operation
|
|
5059
|
+
!["new_thread", "adopt_external_thread"].includes(transition.operation) ||
|
|
4915
5060
|
transition.target_session_id !== session.session_id ||
|
|
4916
5061
|
!after ||
|
|
4917
5062
|
after.binding_id !== binding.binding_id ||
|
|
@@ -5506,6 +5651,552 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
|
|
|
5506
5651
|
updated_at: now.toISOString()
|
|
5507
5652
|
}, { expectedRevision: managedSessionRevision(existing) });
|
|
5508
5653
|
}
|
|
5654
|
+
const HUMAN_OBSERVED_HANDOFF_FINGERPRINT = nativeThreadCommandFingerprint("adopt_external_thread:human_observed:no_terminal_input:v1");
|
|
5655
|
+
function observedHandoffAuthorityToken({ terminal, identity, sourceSession, target }) {
|
|
5656
|
+
const exact = exactLifecycleProcessIdentity(terminal, identity);
|
|
5657
|
+
const terminalToken = unmanagedTerminalBindingToken({
|
|
5658
|
+
terminalId: terminal.conversationId,
|
|
5659
|
+
terminalControl: terminal.terminalControl,
|
|
5660
|
+
agent: terminal.agent,
|
|
5661
|
+
pid: terminal.pid,
|
|
5662
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
5663
|
+
nativeThreadId: exact.sessionId,
|
|
5664
|
+
processUuid: exact.processUuid,
|
|
5665
|
+
processBirth: exact.processBirth,
|
|
5666
|
+
rollout: exact.rollout
|
|
5667
|
+
});
|
|
5668
|
+
return humanObservedHandoffBindingToken({
|
|
5669
|
+
terminal_token: terminalToken,
|
|
5670
|
+
source_session_id: sourceSession.session_id,
|
|
5671
|
+
source_revision: managedSessionRevision(sourceSession),
|
|
5672
|
+
source_binding_token: managedSessionBindingToken(sourceSession),
|
|
5673
|
+
target
|
|
5674
|
+
});
|
|
5675
|
+
}
|
|
5676
|
+
function activeTurnHandoffDecisionToken({ handoffToken, turn, ledger }) {
|
|
5677
|
+
const takeover = isRecord(turn.native_session_takeover)
|
|
5678
|
+
? turn.native_session_takeover
|
|
5679
|
+
: undefined;
|
|
5680
|
+
const submission = terminalBridgeSubmission(turn);
|
|
5681
|
+
return createHash("sha256")
|
|
5682
|
+
.update(JSON.stringify({
|
|
5683
|
+
version: 1,
|
|
5684
|
+
kind: "active_turn_human_handoff",
|
|
5685
|
+
handoff_token: handoffToken,
|
|
5686
|
+
session_id: sessionIdForConversation(turn),
|
|
5687
|
+
turn_id: turnIdForConversation(turn),
|
|
5688
|
+
turn_status: turn.status,
|
|
5689
|
+
turn_updated_at: turn.updated_at ?? null,
|
|
5690
|
+
current_message_id: stringValue(takeover?.terminal_bridge_message_id) ??
|
|
5691
|
+
stringValue(submission?.message_id) ??
|
|
5692
|
+
null,
|
|
5693
|
+
ledger_generation_id: stringValue(ledger?.generation_id) ?? null,
|
|
5694
|
+
ledger_message_id: stringValue(ledger?.message_id) ?? null,
|
|
5695
|
+
ledger_status: stringValue(ledger?.status) ?? null
|
|
5696
|
+
}))
|
|
5697
|
+
.digest("hex");
|
|
5698
|
+
}
|
|
5699
|
+
const originalExpectedTerminalSelector = new WeakMap();
|
|
5700
|
+
function assertExpectedHandoffTokenUsesExactTerminalSelector({ options, terminal }) {
|
|
5701
|
+
if (!stringValue(options.expectedTerminalToken)) {
|
|
5702
|
+
return;
|
|
5703
|
+
}
|
|
5704
|
+
const supplied = originalExpectedTerminalSelector.has(options)
|
|
5705
|
+
? originalExpectedTerminalSelector.get(options)
|
|
5706
|
+
: stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim();
|
|
5707
|
+
if (supplied !== terminal.conversationId) {
|
|
5708
|
+
throw new Error("--expected-terminal-token is valid only with the exact full terminal " +
|
|
5709
|
+
"conversation selector advertised by AKK list");
|
|
5710
|
+
}
|
|
5711
|
+
}
|
|
5712
|
+
async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity }) {
|
|
5713
|
+
const bridge = createTerminalAgentBridge(options);
|
|
5714
|
+
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
5715
|
+
assertSafeTerminalSend(terminal.agent, status);
|
|
5716
|
+
if (terminal.agent !== "codex") {
|
|
5717
|
+
return { identity: resolvedIdentity, status };
|
|
5718
|
+
}
|
|
5719
|
+
const sourceBinding = sourceSession.binding;
|
|
5720
|
+
const statusCard = terminal.adapter.observeThreadLifecycle?.({
|
|
5721
|
+
operation: { kind: "new_thread" },
|
|
5722
|
+
phase: "before",
|
|
5723
|
+
screen: status.screen.excerpt ?? ""
|
|
5724
|
+
});
|
|
5725
|
+
const statusCardId = statusCard?.status === "observed" &&
|
|
5726
|
+
isExactNativeThreadId(statusCard.nativeThreadId)
|
|
5727
|
+
? statusCard.nativeThreadId.toLowerCase()
|
|
5728
|
+
: undefined;
|
|
5729
|
+
const sourceId = sourceBinding?.native_thread_id?.toLowerCase();
|
|
5730
|
+
if (statusCardId && sourceId && statusCardId !== sourceId) {
|
|
5731
|
+
const processUuid = sourceBinding?.native_process.process_uuid;
|
|
5732
|
+
const processBirth = sourceBinding?.native_process.process_birth;
|
|
5733
|
+
if (!processUuid || !processBirth) {
|
|
5734
|
+
return { identity: undefined, status };
|
|
5735
|
+
}
|
|
5736
|
+
const resolvedMatchesStatus = resolvedIdentity?.sessionId.toLowerCase() === statusCardId;
|
|
5737
|
+
return {
|
|
5738
|
+
identity: {
|
|
5739
|
+
sessionId: statusCardId,
|
|
5740
|
+
processUuid,
|
|
5741
|
+
processBirth,
|
|
5742
|
+
rollout: resolvedMatchesStatus ? resolvedIdentity?.rollout : undefined,
|
|
5743
|
+
evidence: resolvedMatchesStatus
|
|
5744
|
+
? `${resolvedIdentity?.evidence ?? "native_thread_boundary"}+codex_status_card`
|
|
5745
|
+
: statusCard?.evidence ?? "codex_status_card"
|
|
5746
|
+
},
|
|
5747
|
+
status
|
|
5748
|
+
};
|
|
5749
|
+
}
|
|
5750
|
+
return { identity: resolvedIdentity, status };
|
|
5751
|
+
}
|
|
5752
|
+
function observedHandoffTargetResolution({ storeDir, agent, workspace, nativeThreadId, sourceSessionId }) {
|
|
5753
|
+
const matches = listManagedSessions(storeDir).filter((session) => session.session_id !== sourceSessionId &&
|
|
5754
|
+
session.agent === agent &&
|
|
5755
|
+
session.binding?.native_thread_id?.toLowerCase() === nativeThreadId &&
|
|
5756
|
+
path.resolve(session.workspace) === path.resolve(workspace));
|
|
5757
|
+
if (matches.length > 1) {
|
|
5758
|
+
return {
|
|
5759
|
+
status: "blocked",
|
|
5760
|
+
reason: `native thread ${nativeThreadId} is claimed by multiple managed Sessions`
|
|
5761
|
+
};
|
|
5762
|
+
}
|
|
5763
|
+
const target = matches[0];
|
|
5764
|
+
if (!target) {
|
|
5765
|
+
return { status: "eligible", snapshot: { state: "absent" } };
|
|
5766
|
+
}
|
|
5767
|
+
if (!target.binding ||
|
|
5768
|
+
target.status !== "detached" ||
|
|
5769
|
+
managedSessionHasUnresolvedNativeTransition(storeDir, target)) {
|
|
5770
|
+
return {
|
|
5771
|
+
status: "blocked",
|
|
5772
|
+
reason: `managed Session ${target.session_id} cannot be adopted from ` +
|
|
5773
|
+
`${target.status} state or while its lifecycle is unresolved`
|
|
5774
|
+
};
|
|
5775
|
+
}
|
|
5776
|
+
try {
|
|
5777
|
+
assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, target.session_id));
|
|
5778
|
+
}
|
|
5779
|
+
catch (error) {
|
|
5780
|
+
return {
|
|
5781
|
+
status: "blocked",
|
|
5782
|
+
reason: `managed Session ${target.session_id} has unresolved work: ` +
|
|
5783
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
5784
|
+
};
|
|
5785
|
+
}
|
|
5786
|
+
return {
|
|
5787
|
+
status: "eligible",
|
|
5788
|
+
session: target,
|
|
5789
|
+
snapshot: {
|
|
5790
|
+
state: "detached",
|
|
5791
|
+
session_id: target.session_id,
|
|
5792
|
+
revision: managedSessionRevision(target),
|
|
5793
|
+
status: "detached",
|
|
5794
|
+
binding_token: managedSessionBindingToken(target)
|
|
5795
|
+
}
|
|
5796
|
+
};
|
|
5797
|
+
}
|
|
5798
|
+
async function maybeAdoptObservedExternalThread({ options, terminal, sourceSession, resolvedIdentity, storeDir }) {
|
|
5799
|
+
if (!sourceSession?.binding) {
|
|
5800
|
+
return { identity: resolvedIdentity, adopted: false };
|
|
5801
|
+
}
|
|
5802
|
+
const observed = await observedExternalHandoffIdentity({
|
|
5803
|
+
options,
|
|
5804
|
+
terminal,
|
|
5805
|
+
sourceSession,
|
|
5806
|
+
resolvedIdentity
|
|
5807
|
+
});
|
|
5808
|
+
const identity = observed.identity;
|
|
5809
|
+
const conflictKind = managedBindingConflictKindForResolvedTerminal({
|
|
5810
|
+
storeDir,
|
|
5811
|
+
session: sourceSession,
|
|
5812
|
+
terminal,
|
|
5813
|
+
identity
|
|
5814
|
+
});
|
|
5815
|
+
if (conflictKind !== "live_external_thread_change") {
|
|
5816
|
+
return { identity, adopted: false };
|
|
5817
|
+
}
|
|
5818
|
+
assertTerminalLifecycleReady({
|
|
5819
|
+
options,
|
|
5820
|
+
terminal,
|
|
5821
|
+
terminalStatus: observed.status
|
|
5822
|
+
});
|
|
5823
|
+
if (!identity || !isExactNativeThreadId(identity.sessionId)) {
|
|
5824
|
+
throw new Error("the externally selected native thread has no exact supported identity");
|
|
5825
|
+
}
|
|
5826
|
+
if (managedSessionHasUnresolvedNativeTransition(storeDir, sourceSession)) {
|
|
5827
|
+
throw new Error(`managed Session ${sourceSession.session_id} has an unresolved native-thread transition`);
|
|
5828
|
+
}
|
|
5829
|
+
assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, sourceSession.session_id));
|
|
5830
|
+
if (terminal.agent === "codex"
|
|
5831
|
+
? !codexComposerEmpty(observed.status.screen.excerpt)
|
|
5832
|
+
: !claudeComposerEmpty(observed.status.screen.excerpt)) {
|
|
5833
|
+
throw new Error("external handoff adoption requires an exact empty idle composer");
|
|
5834
|
+
}
|
|
5835
|
+
if (terminal.agent === "codex") {
|
|
5836
|
+
await assertCodexComposerReadyForAutomatedInput({
|
|
5837
|
+
options,
|
|
5838
|
+
terminalControl: terminal.terminalControl
|
|
5839
|
+
});
|
|
5840
|
+
}
|
|
5841
|
+
const targetNativeThreadId = identity.sessionId.toLowerCase();
|
|
5842
|
+
const targetResolution = observedHandoffTargetResolution({
|
|
5843
|
+
storeDir,
|
|
5844
|
+
agent: terminal.agent,
|
|
5845
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
5846
|
+
nativeThreadId: targetNativeThreadId,
|
|
5847
|
+
sourceSessionId: sourceSession.session_id
|
|
5848
|
+
});
|
|
5849
|
+
const expectedTerminalToken = stringValue(options.expectedTerminalToken);
|
|
5850
|
+
if (targetResolution.status === "blocked") {
|
|
5851
|
+
if (expectedTerminalToken) {
|
|
5852
|
+
throw new Error("live source or target Session snapshot changed after the handoff was " +
|
|
5853
|
+
"listed; refresh AKK list");
|
|
5854
|
+
}
|
|
5855
|
+
throw new Error(targetResolution.reason);
|
|
5856
|
+
}
|
|
5857
|
+
const freshHandoffToken = observedHandoffAuthorityToken({
|
|
5858
|
+
terminal,
|
|
5859
|
+
identity,
|
|
5860
|
+
sourceSession,
|
|
5861
|
+
target: targetResolution.snapshot
|
|
5862
|
+
});
|
|
5863
|
+
if (expectedTerminalToken &&
|
|
5864
|
+
expectedTerminalToken !== freshHandoffToken) {
|
|
5865
|
+
throw new Error("live source, target, or terminal identity changed after the handoff " +
|
|
5866
|
+
"was listed; refresh AKK list");
|
|
5867
|
+
}
|
|
5868
|
+
const targetSession = targetResolution.session;
|
|
5869
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
5870
|
+
options,
|
|
5871
|
+
agent: terminal.agent,
|
|
5872
|
+
currentPid: terminal.pid,
|
|
5873
|
+
nativeThreadId: targetNativeThreadId,
|
|
5874
|
+
storeDir,
|
|
5875
|
+
terminalControl: terminal.terminalControl,
|
|
5876
|
+
excludedManagedSessionId: targetSession?.session_id
|
|
5877
|
+
});
|
|
5878
|
+
const adapterVersion = required(stringValue(agentVersionForRunningProcess(terminal.agent, terminal.pid, options)), "external handoff adoption requires the exact running agent version");
|
|
5879
|
+
const capability = terminal.adapter.probeThreadLifecycle?.(adapterVersion);
|
|
5880
|
+
if (capability?.status !== "supported") {
|
|
5881
|
+
throw new Error(capability?.reason ?? "external handoff adoption is unsupported for this agent version");
|
|
5882
|
+
}
|
|
5883
|
+
const now = cliNow();
|
|
5884
|
+
const sourceBinding = sourceSession.binding;
|
|
5885
|
+
const targetSessionId = targetSession?.session_id ?? createManagedSessionId(now);
|
|
5886
|
+
const transitionId = createNativeThreadTransitionId();
|
|
5887
|
+
const exactIdentity = exactLifecycleProcessIdentity(terminal, identity);
|
|
5888
|
+
const nextBinding = terminalBindingFrom({
|
|
5889
|
+
terminalId: terminal.conversationId,
|
|
5890
|
+
terminalControl: terminal.terminalControl,
|
|
5891
|
+
pid: terminal.pid,
|
|
5892
|
+
nativeThreadId: targetNativeThreadId,
|
|
5893
|
+
processUuid: exactIdentity.processUuid,
|
|
5894
|
+
processBirth: exactIdentity.processBirth,
|
|
5895
|
+
rollout: exactIdentity.rollout,
|
|
5896
|
+
evidence: `${exactIdentity.evidence}+human_observed`,
|
|
5897
|
+
generation: (targetSession?.binding?.generation ?? 0) + 1,
|
|
5898
|
+
now
|
|
5899
|
+
});
|
|
5900
|
+
const previousLedger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
5901
|
+
let transition = {
|
|
5902
|
+
schema: "agent-knock-knock/native-thread-transition",
|
|
5903
|
+
version: 1,
|
|
5904
|
+
transition_id: transitionId,
|
|
5905
|
+
operation: "adopt_external_thread",
|
|
5906
|
+
origin: "human_observed",
|
|
5907
|
+
terminal_input_sent: false,
|
|
5908
|
+
status: "prepared",
|
|
5909
|
+
terminal_id: terminal.conversationId,
|
|
5910
|
+
agent: terminal.agent,
|
|
5911
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
5912
|
+
source_session_id: sourceSession.session_id,
|
|
5913
|
+
source_expected_revision: managedSessionRevision(sourceSession),
|
|
5914
|
+
source_previous_last_transition_id: sourceSession.last_transition_id,
|
|
5915
|
+
target_session_id: targetSessionId,
|
|
5916
|
+
target_expected_revision: targetSession
|
|
5917
|
+
? managedSessionRevision(targetSession)
|
|
5918
|
+
: null,
|
|
5919
|
+
target_native_thread_id: targetNativeThreadId,
|
|
5920
|
+
before_native_thread_id: sourceBinding.native_thread_id,
|
|
5921
|
+
before_process_uuid: sourceBinding.native_process.process_uuid,
|
|
5922
|
+
before_process_started_at: exactIdentity.processStartedAt,
|
|
5923
|
+
before_process_birth: sourceBinding.native_process.process_birth,
|
|
5924
|
+
before_process_rollout: sourceBinding.native_process.rollout,
|
|
5925
|
+
before_binding: sourceBinding,
|
|
5926
|
+
adapter_version: adapterVersion,
|
|
5927
|
+
command_fingerprint: HUMAN_OBSERVED_HANDOFF_FINGERPRINT,
|
|
5928
|
+
dispatcher_pid: cliPid(),
|
|
5929
|
+
prepared_at: now.toISOString()
|
|
5930
|
+
};
|
|
5931
|
+
transition = saveNativeThreadTransition(storeDir, transition, {
|
|
5932
|
+
expectedRevision: null
|
|
5933
|
+
});
|
|
5934
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_TRANSITION_BEFORE_LEDGER === "1") {
|
|
5935
|
+
cliExit(88);
|
|
5936
|
+
}
|
|
5937
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
5938
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
5939
|
+
status: "prepared",
|
|
5940
|
+
target_native_thread_id: targetNativeThreadId,
|
|
5941
|
+
previous_generation_id: stringValue(previousLedger?.generation_id) ??
|
|
5942
|
+
stringValue(previousLedger?.message_id)
|
|
5943
|
+
}, { expectedTransitionId: null });
|
|
5944
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_PREPARED === "1") {
|
|
5945
|
+
cliExit(86);
|
|
5946
|
+
}
|
|
5947
|
+
const sourceTransitioning = saveManagedSession(storeDir, {
|
|
5948
|
+
...sourceSession,
|
|
5949
|
+
status: "transitioning",
|
|
5950
|
+
last_transition_id: transitionId,
|
|
5951
|
+
updated_at: now.toISOString()
|
|
5952
|
+
}, { expectedRevision: managedSessionRevision(sourceSession) });
|
|
5953
|
+
try {
|
|
5954
|
+
const reObserved = await observedExternalHandoffIdentity({
|
|
5955
|
+
options,
|
|
5956
|
+
terminal,
|
|
5957
|
+
sourceSession: sourceTransitioning,
|
|
5958
|
+
resolvedIdentity: await resolveCurrentNativeAgentSessionIdentity({
|
|
5959
|
+
options,
|
|
5960
|
+
agent: terminal.agent,
|
|
5961
|
+
pid: terminal.pid,
|
|
5962
|
+
cwd: terminal.terminalControl.currentPath,
|
|
5963
|
+
preferredSessionId: targetNativeThreadId,
|
|
5964
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
5965
|
+
sessionId: sourceBinding.native_thread_id,
|
|
5966
|
+
processUuid: sourceBinding.native_process.process_uuid,
|
|
5967
|
+
processBirth: sourceBinding.native_process.process_birth,
|
|
5968
|
+
rollout: sourceBinding.native_process.rollout,
|
|
5969
|
+
evidence: sourceBinding.native_process.evidence
|
|
5970
|
+
}),
|
|
5971
|
+
allowedAdditionalIdentities: []
|
|
5972
|
+
})
|
|
5973
|
+
});
|
|
5974
|
+
const reObservedExact = reObserved.identity
|
|
5975
|
+
? exactLifecycleProcessIdentity(terminal, reObserved.identity)
|
|
5976
|
+
: undefined;
|
|
5977
|
+
if (reObservedExact?.sessionId.toLowerCase() !== targetNativeThreadId ||
|
|
5978
|
+
reObservedExact.processUuid !== nextBinding.native_process.process_uuid ||
|
|
5979
|
+
reObservedExact.processBirth !== nextBinding.native_process.process_birth ||
|
|
5980
|
+
JSON.stringify(reObservedExact.rollout ?? null) !==
|
|
5981
|
+
JSON.stringify(nextBinding.native_process.rollout ?? null)) {
|
|
5982
|
+
throw new Error("live native thread changed during external handoff adoption");
|
|
5983
|
+
}
|
|
5984
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
5985
|
+
options,
|
|
5986
|
+
agent: terminal.agent,
|
|
5987
|
+
currentPid: terminal.pid,
|
|
5988
|
+
nativeThreadId: targetNativeThreadId,
|
|
5989
|
+
storeDir,
|
|
5990
|
+
terminalControl: terminal.terminalControl,
|
|
5991
|
+
excludedManagedSessionId: targetSession?.session_id
|
|
5992
|
+
});
|
|
5993
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
5994
|
+
...transition,
|
|
5995
|
+
status: "verified",
|
|
5996
|
+
after_binding: nextBinding,
|
|
5997
|
+
verified_at: cliNow().toISOString()
|
|
5998
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
5999
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_VERIFIED_TRANSITION_BEFORE_LEDGER ===
|
|
6000
|
+
"1") {
|
|
6001
|
+
cliExit(89);
|
|
6002
|
+
}
|
|
6003
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6004
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
6005
|
+
status: "verified",
|
|
6006
|
+
binding: sourceBinding
|
|
6007
|
+
}, {
|
|
6008
|
+
expectedTransitionId: transitionId,
|
|
6009
|
+
expectedStatus: "prepared"
|
|
6010
|
+
});
|
|
6011
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_VERIFIED === "1") {
|
|
6012
|
+
cliExit(87);
|
|
6013
|
+
}
|
|
6014
|
+
const committedTarget = commitVerifiedLifecycleTransition(storeDir, transition, cliNow().toISOString());
|
|
6015
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
6016
|
+
...transition,
|
|
6017
|
+
status: "committed",
|
|
6018
|
+
committed_at: cliNow().toISOString()
|
|
6019
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
6020
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6021
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
6022
|
+
status: "resolved",
|
|
6023
|
+
resolved_at: cliNow().toISOString(),
|
|
6024
|
+
binding: committedTarget.binding,
|
|
6025
|
+
reason: "verified human-observed native thread handoff committed"
|
|
6026
|
+
}, {
|
|
6027
|
+
expectedTransitionId: transitionId,
|
|
6028
|
+
expectedStatus: "verified"
|
|
6029
|
+
});
|
|
6030
|
+
runtimeLog("info", "human_observed_handoff_adopted", {
|
|
6031
|
+
transition_id: transitionId,
|
|
6032
|
+
terminal_id: terminal.conversationId,
|
|
6033
|
+
source_session_id: sourceSession.session_id,
|
|
6034
|
+
target_session_id: committedTarget.session_id,
|
|
6035
|
+
native_thread_id: targetNativeThreadId,
|
|
6036
|
+
terminal_input_sent: false
|
|
6037
|
+
});
|
|
6038
|
+
return {
|
|
6039
|
+
session: committedTarget,
|
|
6040
|
+
identity: exactIdentity,
|
|
6041
|
+
transition,
|
|
6042
|
+
adopted: true
|
|
6043
|
+
};
|
|
6044
|
+
}
|
|
6045
|
+
catch (error) {
|
|
6046
|
+
const failedAt = cliNow().toISOString();
|
|
6047
|
+
const durable = loadNativeThreadTransition(storeDir, transitionId);
|
|
6048
|
+
if (durable.status === "verified" || durable.status === "committed") {
|
|
6049
|
+
throw error;
|
|
6050
|
+
}
|
|
6051
|
+
const uncertain = saveNativeThreadTransition(storeDir, {
|
|
6052
|
+
...durable,
|
|
6053
|
+
status: "uncertain",
|
|
6054
|
+
uncertain_at: failedAt,
|
|
6055
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6056
|
+
do_not_retry: true
|
|
6057
|
+
}, { expectedRevision: nativeThreadTransitionRevision(durable) });
|
|
6058
|
+
saveManagedSession(storeDir, {
|
|
6059
|
+
...sourceTransitioning,
|
|
6060
|
+
status: "quarantined",
|
|
6061
|
+
quarantine_reason: "human-observed handoff could not be revalidated",
|
|
6062
|
+
updated_at: failedAt
|
|
6063
|
+
}, { expectedRevision: managedSessionRevision(sourceTransitioning) });
|
|
6064
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6065
|
+
...lifecycleLedgerFields(uncertain, storeDir),
|
|
6066
|
+
status: "uncertain",
|
|
6067
|
+
uncertain_at: failedAt,
|
|
6068
|
+
reason: "human-observed handoff could not be revalidated"
|
|
6069
|
+
}, { expectedTransitionId: transitionId });
|
|
6070
|
+
throw error;
|
|
6071
|
+
}
|
|
6072
|
+
}
|
|
6073
|
+
async function assertObservedHandoffTransportBoundary({ options, terminal, transition, requireEmptyComposer }) {
|
|
6074
|
+
const storeDir = storeDirFromOptions(options);
|
|
6075
|
+
const durable = loadNativeThreadTransition(storeDir, transition.transition_id);
|
|
6076
|
+
if (durable.operation !== "adopt_external_thread" ||
|
|
6077
|
+
durable.origin !== "human_observed" ||
|
|
6078
|
+
durable.terminal_input_sent !== false ||
|
|
6079
|
+
durable.status !== "committed" ||
|
|
6080
|
+
!durable.source_session_id ||
|
|
6081
|
+
!durable.before_binding ||
|
|
6082
|
+
!durable.after_binding ||
|
|
6083
|
+
JSON.stringify(durable.after_binding) !==
|
|
6084
|
+
JSON.stringify(transition.after_binding)) {
|
|
6085
|
+
throw new Error("human-observed handoff changed before terminal transport");
|
|
6086
|
+
}
|
|
6087
|
+
const source = loadManagedSession(storeDir, durable.source_session_id);
|
|
6088
|
+
const target = loadManagedSession(storeDir, durable.target_session_id);
|
|
6089
|
+
if (source.status !== "detached" ||
|
|
6090
|
+
source.last_transition_id !== durable.transition_id ||
|
|
6091
|
+
JSON.stringify(source.binding) !== JSON.stringify(durable.before_binding) ||
|
|
6092
|
+
target.status !== "bound" ||
|
|
6093
|
+
target.last_transition_id !== durable.transition_id ||
|
|
6094
|
+
JSON.stringify(target.binding) !== JSON.stringify(durable.after_binding)) {
|
|
6095
|
+
throw new Error("human-observed handoff Session authority changed before send");
|
|
6096
|
+
}
|
|
6097
|
+
const targetId = durable.after_binding.native_thread_id;
|
|
6098
|
+
if (!targetId) {
|
|
6099
|
+
throw new Error("human-observed handoff target identity is incomplete");
|
|
6100
|
+
}
|
|
6101
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
6102
|
+
options,
|
|
6103
|
+
agent: terminal.agent,
|
|
6104
|
+
pid: terminal.pid,
|
|
6105
|
+
cwd: terminal.terminalControl.currentPath,
|
|
6106
|
+
preferredSessionId: targetId,
|
|
6107
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
6108
|
+
sessionId: durable.before_native_thread_id,
|
|
6109
|
+
processUuid: durable.before_process_uuid,
|
|
6110
|
+
processBirth: durable.before_process_birth,
|
|
6111
|
+
rollout: durable.before_process_rollout,
|
|
6112
|
+
evidence: durable.before_binding.native_process.evidence
|
|
6113
|
+
}),
|
|
6114
|
+
allowedAdditionalIdentities: []
|
|
6115
|
+
});
|
|
6116
|
+
const bridge = createTerminalAgentBridge(options);
|
|
6117
|
+
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
6118
|
+
if (requireEmptyComposer) {
|
|
6119
|
+
assertSafeTerminalSend(terminal.agent, status);
|
|
6120
|
+
}
|
|
6121
|
+
else {
|
|
6122
|
+
const displayName = executorDefinitionForKind(terminal.agent).displayName;
|
|
6123
|
+
const approval = isRecord(status?.approval_state)
|
|
6124
|
+
? status.approval_state
|
|
6125
|
+
: undefined;
|
|
6126
|
+
if (status?.reachable !== true) {
|
|
6127
|
+
throw new Error(`${displayName} terminal status is unavailable`);
|
|
6128
|
+
}
|
|
6129
|
+
if (approval?.blocked === true) {
|
|
6130
|
+
throw new Error(stringValue(approval.reason) ??
|
|
6131
|
+
`${displayName} is waiting at a permission dialog`);
|
|
6132
|
+
}
|
|
6133
|
+
// With an exact draft in the composer, native TUIs can classify the screen
|
|
6134
|
+
// as `unknown` instead of `idle`. Exact draft materialization is proven by
|
|
6135
|
+
// the bridge before Enter, so only a positively busy state is unsafe here.
|
|
6136
|
+
if (status.activity_state !== "idle" &&
|
|
6137
|
+
status.activity_state !== "unknown") {
|
|
6138
|
+
throw new Error(`${displayName} terminal became ${stringValue(status.activity_state) ?? "unknown"} before handoff submission`);
|
|
6139
|
+
}
|
|
6140
|
+
}
|
|
6141
|
+
let liveIdentity = resolved;
|
|
6142
|
+
if (terminal.agent === "codex") {
|
|
6143
|
+
const foreground = terminal.adapter.observeThreadLifecycle?.({
|
|
6144
|
+
operation: { kind: "new_thread" },
|
|
6145
|
+
phase: "before",
|
|
6146
|
+
screen: status.screen.excerpt ?? ""
|
|
6147
|
+
});
|
|
6148
|
+
const foregroundId = foreground?.status === "observed" &&
|
|
6149
|
+
isExactNativeThreadId(foreground.nativeThreadId)
|
|
6150
|
+
? foreground.nativeThreadId.toLowerCase()
|
|
6151
|
+
: undefined;
|
|
6152
|
+
if (foregroundId && foregroundId !== targetId.toLowerCase()) {
|
|
6153
|
+
throw new Error("Codex foreground native thread changed after handoff adoption");
|
|
6154
|
+
}
|
|
6155
|
+
if (!durable.after_binding.native_process.rollout) {
|
|
6156
|
+
if (requireEmptyComposer &&
|
|
6157
|
+
foregroundId !== targetId.toLowerCase()) {
|
|
6158
|
+
throw new Error("status-card-only Codex handoff lost its exact foreground identity");
|
|
6159
|
+
}
|
|
6160
|
+
liveIdentity = resolved?.sessionId.toLowerCase() === targetId.toLowerCase()
|
|
6161
|
+
? resolved
|
|
6162
|
+
: {
|
|
6163
|
+
sessionId: targetId,
|
|
6164
|
+
processUuid: durable.after_binding.native_process.process_uuid,
|
|
6165
|
+
processBirth: durable.after_binding.native_process.process_birth,
|
|
6166
|
+
evidence: foreground?.evidence ?? "codex_status_card"
|
|
6167
|
+
};
|
|
6168
|
+
}
|
|
6169
|
+
else if (!liveIdentity ||
|
|
6170
|
+
liveIdentity.sessionId.toLowerCase() !== targetId.toLowerCase()) {
|
|
6171
|
+
throw new Error("Codex handoff rollout identity changed before terminal transport");
|
|
6172
|
+
}
|
|
6173
|
+
}
|
|
6174
|
+
if (!liveIdentity) {
|
|
6175
|
+
throw new Error("human-observed handoff identity is unavailable before send");
|
|
6176
|
+
}
|
|
6177
|
+
const exact = exactLifecycleProcessIdentity(terminal, liveIdentity);
|
|
6178
|
+
if (exact.sessionId.toLowerCase() !== targetId.toLowerCase() ||
|
|
6179
|
+
exact.processUuid !== durable.after_binding.native_process.process_uuid ||
|
|
6180
|
+
exact.processBirth !== durable.after_binding.native_process.process_birth ||
|
|
6181
|
+
JSON.stringify(exact.rollout ?? null) !==
|
|
6182
|
+
JSON.stringify(durable.after_binding.native_process.rollout ?? null)) {
|
|
6183
|
+
throw new Error("human-observed handoff identity changed before send");
|
|
6184
|
+
}
|
|
6185
|
+
if (requireEmptyComposer) {
|
|
6186
|
+
const empty = terminal.agent === "codex"
|
|
6187
|
+
? codexComposerEmpty(status.screen.excerpt)
|
|
6188
|
+
: claudeComposerEmpty(status.screen.excerpt);
|
|
6189
|
+
if (!empty) {
|
|
6190
|
+
throw new Error("human-observed handoff composer changed before text injection");
|
|
6191
|
+
}
|
|
6192
|
+
if (terminal.agent === "codex") {
|
|
6193
|
+
await assertCodexComposerReadyForAutomatedInput({
|
|
6194
|
+
options,
|
|
6195
|
+
terminalControl: terminal.terminalControl
|
|
6196
|
+
});
|
|
6197
|
+
}
|
|
6198
|
+
}
|
|
6199
|
+
}
|
|
5509
6200
|
function lifecycleBindingToken({ session, terminal, identity }) {
|
|
5510
6201
|
if (session) {
|
|
5511
6202
|
return managedSessionBindingToken(session);
|
|
@@ -7914,6 +8605,10 @@ async function runStatus(options) {
|
|
|
7914
8605
|
};
|
|
7915
8606
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
7916
8607
|
if (terminalConversation) {
|
|
8608
|
+
assertExpectedHandoffTokenUsesExactTerminalSelector({
|
|
8609
|
+
options,
|
|
8610
|
+
terminal: terminalConversation
|
|
8611
|
+
});
|
|
7917
8612
|
const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
|
|
7918
8613
|
pid: terminalConversation.pid,
|
|
7919
8614
|
cwd: terminalConversation.terminalControl.currentPath,
|
|
@@ -8503,6 +9198,13 @@ async function runSend(options) {
|
|
|
8503
9198
|
}
|
|
8504
9199
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
8505
9200
|
if (terminalConversation) {
|
|
9201
|
+
// A token copied from list is authority for exactly the advertised full
|
|
9202
|
+
// terminal selector. Reject aliases and implicit/no-selector resolution
|
|
9203
|
+
// before taking locks or touching Store state.
|
|
9204
|
+
assertExpectedHandoffTokenUsesExactTerminalSelector({
|
|
9205
|
+
options,
|
|
9206
|
+
terminal: terminalConversation
|
|
9207
|
+
});
|
|
8506
9208
|
if (!options.background) {
|
|
8507
9209
|
throw new Error("raw terminal sends require --background so AKK can persist and monitor the submission safely");
|
|
8508
9210
|
}
|
|
@@ -8534,7 +9236,7 @@ async function runSend(options) {
|
|
|
8534
9236
|
session: claimedSession
|
|
8535
9237
|
})
|
|
8536
9238
|
: { additional: [] };
|
|
8537
|
-
|
|
9239
|
+
let currentNativeIdentity = await resolveCurrentNativeAgentSessionIdentity({
|
|
8538
9240
|
options,
|
|
8539
9241
|
agent: terminalConversation.agent,
|
|
8540
9242
|
pid: terminalConversation.pid,
|
|
@@ -8545,12 +9247,27 @@ async function runSend(options) {
|
|
|
8545
9247
|
allowedCompanionIdentity: knownCodexCompanions.primary,
|
|
8546
9248
|
allowedAdditionalIdentities: knownCodexCompanions.additional
|
|
8547
9249
|
});
|
|
8548
|
-
|
|
9250
|
+
const physicalNativeIdentityBeforeHandoff = currentNativeIdentity;
|
|
9251
|
+
const handoff = await maybeAdoptObservedExternalThread({
|
|
8549
9252
|
options,
|
|
8550
9253
|
terminal: terminalConversation,
|
|
8551
|
-
|
|
9254
|
+
sourceSession: claimedSession,
|
|
9255
|
+
resolvedIdentity: currentNativeIdentity,
|
|
9256
|
+
storeDir: rawStoreDir
|
|
8552
9257
|
});
|
|
8553
|
-
|
|
9258
|
+
currentNativeIdentity =
|
|
9259
|
+
handoff.adopted &&
|
|
9260
|
+
terminalConversation.agent === "codex" &&
|
|
9261
|
+
!handoff.session?.binding?.native_process.rollout
|
|
9262
|
+
? physicalNativeIdentityBeforeHandoff
|
|
9263
|
+
: handoff.identity;
|
|
9264
|
+
let managedSession = handoff.session ??
|
|
9265
|
+
materializeCurrentManagedSession({
|
|
9266
|
+
options,
|
|
9267
|
+
terminal: terminalConversation,
|
|
9268
|
+
identity: currentNativeIdentity
|
|
9269
|
+
});
|
|
9270
|
+
if (!managedSession && currentNativeIdentity) {
|
|
8554
9271
|
managedSession = await reattachManagedSessionForNativeIdentity({
|
|
8555
9272
|
options,
|
|
8556
9273
|
terminal: terminalConversation,
|
|
@@ -8593,19 +9310,28 @@ async function runSend(options) {
|
|
|
8593
9310
|
storeDir: rawStoreDir,
|
|
8594
9311
|
session: managedSession,
|
|
8595
9312
|
observedIdentity: currentNativeIdentity
|
|
8596
|
-
}) ??
|
|
8597
|
-
|
|
9313
|
+
}) ?? (currentNativeIdentity === undefined ||
|
|
9314
|
+
nativeIdentityMatchesCodexPreMaterialization(currentNativeIdentity, knownCodexCompanions.primary)
|
|
9315
|
+
? knownCodexCompanions.primary
|
|
9316
|
+
: undefined);
|
|
9317
|
+
const allowedAdditionalIdentities = allowedPreMaterializationIdentity
|
|
9318
|
+
? knownCodexCompanions.additional
|
|
9319
|
+
: [];
|
|
8598
9320
|
const materializedNativeIdentity = currentNativeIdentity?.sessionId === logicalNativeIdentity?.sessionId
|
|
8599
9321
|
? currentNativeIdentity
|
|
8600
9322
|
: undefined;
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
9323
|
+
if (!(handoff.adopted &&
|
|
9324
|
+
terminalConversation.agent === "codex" &&
|
|
9325
|
+
!managedSession.binding?.native_process.rollout)) {
|
|
9326
|
+
await verifyCodexPendingManagedSendStatus({
|
|
9327
|
+
options,
|
|
9328
|
+
terminal: terminalConversation,
|
|
9329
|
+
session: managedSession,
|
|
9330
|
+
logicalIdentity: logicalNativeIdentity,
|
|
9331
|
+
allowedPreMaterializationIdentity,
|
|
9332
|
+
allowedAdditionalIdentities
|
|
9333
|
+
});
|
|
9334
|
+
}
|
|
8609
9335
|
const managedNativeThreadId = logicalNativeIdentity?.sessionId ??
|
|
8610
9336
|
managedSession.binding?.native_thread_id;
|
|
8611
9337
|
if (managedNativeThreadId) {
|
|
@@ -8683,7 +9409,13 @@ async function runSend(options) {
|
|
|
8683
9409
|
}
|
|
8684
9410
|
: undefined,
|
|
8685
9411
|
allowedPreMaterializationIdentity,
|
|
8686
|
-
allowedAdditionalIdentities
|
|
9412
|
+
allowedAdditionalIdentities,
|
|
9413
|
+
observedHandoff: handoff.adopted && handoff.transition
|
|
9414
|
+
? {
|
|
9415
|
+
terminal: terminalConversation,
|
|
9416
|
+
transition: handoff.transition
|
|
9417
|
+
}
|
|
9418
|
+
: undefined
|
|
8687
9419
|
});
|
|
8688
9420
|
}
|
|
8689
9421
|
finally {
|
|
@@ -8696,6 +9428,10 @@ async function runSend(options) {
|
|
|
8696
9428
|
}
|
|
8697
9429
|
return;
|
|
8698
9430
|
}
|
|
9431
|
+
if (stringValue(options.expectedTerminalToken)) {
|
|
9432
|
+
throw new Error("--expected-terminal-token cannot be used with a managed Session; " +
|
|
9433
|
+
"use the exact full terminal selector advertised by AKK list");
|
|
9434
|
+
}
|
|
8699
9435
|
const sessionId = required(stringValue(options.session ?? options.conversation ?? options.conversationId), "--session is required for an ordinary managed send");
|
|
8700
9436
|
const storeDir = storeDirFromOptions(options);
|
|
8701
9437
|
// Read only enough legacy/Session authority to identify the physical
|
|
@@ -9638,7 +10374,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
|
|
|
9638
10374
|
releaseTerminalLock();
|
|
9639
10375
|
}
|
|
9640
10376
|
}
|
|
9641
|
-
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 }) {
|
|
10377
|
+
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 = [], observedHandoff = undefined, continuingTurnResponse = false }) {
|
|
9642
10378
|
const bridge = terminalBridgeEnabled(conversation);
|
|
9643
10379
|
if (!terminalSendLockHeld) {
|
|
9644
10380
|
const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
|
|
@@ -9660,6 +10396,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9660
10396
|
onTerminalPreflightVerified,
|
|
9661
10397
|
allowedPreMaterializationIdentity,
|
|
9662
10398
|
allowedAdditionalIdentities,
|
|
10399
|
+
observedHandoff,
|
|
9663
10400
|
continuingTurnResponse
|
|
9664
10401
|
});
|
|
9665
10402
|
}
|
|
@@ -9699,6 +10436,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9699
10436
|
onTerminalPreflightVerified,
|
|
9700
10437
|
allowedPreMaterializationIdentity,
|
|
9701
10438
|
allowedAdditionalIdentities,
|
|
10439
|
+
observedHandoff,
|
|
9702
10440
|
continuingTurnResponse
|
|
9703
10441
|
});
|
|
9704
10442
|
}
|
|
@@ -9725,6 +10463,7 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9725
10463
|
onTerminalPreflightVerified,
|
|
9726
10464
|
allowedPreMaterializationIdentity,
|
|
9727
10465
|
allowedAdditionalIdentities,
|
|
10466
|
+
observedHandoff,
|
|
9728
10467
|
continuingTurnResponse
|
|
9729
10468
|
}));
|
|
9730
10469
|
}
|
|
@@ -9907,21 +10646,22 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9907
10646
|
runtime: preSendRuntime
|
|
9908
10647
|
});
|
|
9909
10648
|
assertSafeTerminalSend(executor.kind, status);
|
|
9910
|
-
if (executor.kind === "codex" &&
|
|
9911
|
-
(pendingManagedNativeBinding || allowedPreMaterializationIdentity)
|
|
9912
|
-
|
|
9913
|
-
|
|
9914
|
-
|
|
9915
|
-
|
|
9916
|
-
|
|
9917
|
-
|
|
9918
|
-
|
|
9919
|
-
|
|
9920
|
-
|
|
9921
|
-
|
|
9922
|
-
|
|
9923
|
-
|
|
9924
|
-
|
|
10649
|
+
if (executor.kind === "codex" && needsPostSendNativeBinding) {
|
|
10650
|
+
if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
|
|
10651
|
+
const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
|
|
10652
|
+
const foreground = createRuntimeTerminalAgentRegistry(options)
|
|
10653
|
+
.require("codex")
|
|
10654
|
+
.observeThreadLifecycle?.({
|
|
10655
|
+
operation: { kind: "new_thread" },
|
|
10656
|
+
phase: "before",
|
|
10657
|
+
screen: status.screen.excerpt ?? ""
|
|
10658
|
+
});
|
|
10659
|
+
if (!expectedForegroundId ||
|
|
10660
|
+
foreground?.status !== "observed" ||
|
|
10661
|
+
foreground.nativeThreadId !== expectedForegroundId) {
|
|
10662
|
+
throw new Error("Codex foreground thread changed after the managed /status proof; " +
|
|
10663
|
+
"refresh list before sending");
|
|
10664
|
+
}
|
|
9925
10665
|
}
|
|
9926
10666
|
await assertCodexComposerReadyForAutomatedInput({
|
|
9927
10667
|
options,
|
|
@@ -9936,6 +10676,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9936
10676
|
options,
|
|
9937
10677
|
currentIdentity: currentNativeIdentity,
|
|
9938
10678
|
expectedNativeThreadId: expectedManagedNativeThreadId,
|
|
10679
|
+
boundProcessUuid: stringValue(sendTakeover?.terminal_agent_process_uuid),
|
|
10680
|
+
boundProcessBirth: stringValue(sendTakeover?.terminal_agent_process_birth),
|
|
9939
10681
|
allowedPreMaterializationIdentity,
|
|
9940
10682
|
needsPostSendNativeBinding
|
|
9941
10683
|
});
|
|
@@ -10273,6 +11015,27 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10273
11015
|
try {
|
|
10274
11016
|
await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
|
|
10275
11017
|
runtime: preSendRuntime,
|
|
11018
|
+
requireExactComposerBeforeEnter: observedHandoff !== undefined,
|
|
11019
|
+
beforeText: observedHandoff
|
|
11020
|
+
? async () => {
|
|
11021
|
+
await assertObservedHandoffTransportBoundary({
|
|
11022
|
+
options,
|
|
11023
|
+
terminal: observedHandoff.terminal,
|
|
11024
|
+
transition: observedHandoff.transition,
|
|
11025
|
+
requireEmptyComposer: true
|
|
11026
|
+
});
|
|
11027
|
+
}
|
|
11028
|
+
: undefined,
|
|
11029
|
+
beforeEnter: observedHandoff
|
|
11030
|
+
? async () => {
|
|
11031
|
+
await assertObservedHandoffTransportBoundary({
|
|
11032
|
+
options,
|
|
11033
|
+
terminal: observedHandoff.terminal,
|
|
11034
|
+
transition: observedHandoff.transition,
|
|
11035
|
+
requireEmptyComposer: false
|
|
11036
|
+
});
|
|
11037
|
+
}
|
|
11038
|
+
: undefined,
|
|
10276
11039
|
async onTransportStage(event) {
|
|
10277
11040
|
const stageAt = cliNow().toISOString();
|
|
10278
11041
|
if (event.stage === "text_injected") {
|
|
@@ -10313,6 +11076,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10313
11076
|
previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
|
|
10314
11077
|
stringValue(previousDispatchLedger?.message_id)
|
|
10315
11078
|
});
|
|
11079
|
+
if (event.stage === "text_injected" && observedHandoff) {
|
|
11080
|
+
await assertObservedHandoffTransportBoundary({
|
|
11081
|
+
options,
|
|
11082
|
+
terminal: observedHandoff.terminal,
|
|
11083
|
+
transition: observedHandoff.transition,
|
|
11084
|
+
requireEmptyComposer: false
|
|
11085
|
+
});
|
|
11086
|
+
}
|
|
10316
11087
|
try {
|
|
10317
11088
|
appendEvent(logPath, {
|
|
10318
11089
|
ts: stageAt,
|
|
@@ -10332,6 +11103,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10332
11103
|
if (!enterDispatchedAt) {
|
|
10333
11104
|
throw new Error("terminal bridge returned without an enter_dispatched receipt");
|
|
10334
11105
|
}
|
|
11106
|
+
if (codexRolloutAcceptanceAnchor?.version === 2 &&
|
|
11107
|
+
cliEnv().AKK_TEST_EXIT_AFTER_VIRGIN_ENTER_DISPATCHED === "1") {
|
|
11108
|
+
cliExit(86);
|
|
11109
|
+
}
|
|
10335
11110
|
let submittedBase = stagedConversation;
|
|
10336
11111
|
if (needsPostSendNativeBinding) {
|
|
10337
11112
|
let boundIdentity;
|
|
@@ -10353,6 +11128,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10353
11128
|
}
|
|
10354
11129
|
if (boundIdentity) {
|
|
10355
11130
|
try {
|
|
11131
|
+
if (codexRolloutAcceptanceAnchor &&
|
|
11132
|
+
(boundIdentity.processUuid !==
|
|
11133
|
+
codexRolloutAcceptanceAnchor.process_uuid ||
|
|
11134
|
+
boundIdentity.processBirth !==
|
|
11135
|
+
codexRolloutAcceptanceAnchor.process_birth)) {
|
|
11136
|
+
throw new Error("Codex process incarnation changed while its native " +
|
|
11137
|
+
"thread materialized");
|
|
11138
|
+
}
|
|
10356
11139
|
const expectedNativeThreadId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
|
|
10357
11140
|
if (expectedNativeThreadId &&
|
|
10358
11141
|
boundIdentity.sessionId !== expectedNativeThreadId) {
|
|
@@ -10378,6 +11161,10 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10378
11161
|
terminalControl,
|
|
10379
11162
|
identity: boundIdentity
|
|
10380
11163
|
});
|
|
11164
|
+
if (codexRolloutAcceptanceAnchor?.version === 2 &&
|
|
11165
|
+
cliEnv().AKK_TEST_EXIT_AFTER_VIRGIN_SESSION_BINDING === "1") {
|
|
11166
|
+
cliExit(86);
|
|
11167
|
+
}
|
|
10381
11168
|
// A virgin terminal Turn and its Session start with the same
|
|
10382
11169
|
// provisional binding id/generation before Codex has materialized a
|
|
10383
11170
|
// native thread. Persist the observed identity as a monotonic
|
|
@@ -11050,7 +11837,7 @@ function syntheticTerminalAcceptanceEvidence({ options, executor, conversation,
|
|
|
11050
11837
|
.digest("hex")
|
|
11051
11838
|
};
|
|
11052
11839
|
}
|
|
11053
|
-
function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
|
|
11840
|
+
function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expectedNativeThreadId, boundProcessUuid, boundProcessBirth, allowedPreMaterializationIdentity, needsPostSendNativeBinding }) {
|
|
11054
11841
|
if (allowsSyntheticTerminalAcceptance(options)) {
|
|
11055
11842
|
return undefined;
|
|
11056
11843
|
}
|
|
@@ -11058,25 +11845,258 @@ function captureCodexAcceptanceAnchorForSend({ options, currentIdentity, expecte
|
|
|
11058
11845
|
? expectedNativeThreadId
|
|
11059
11846
|
: currentIdentity?.sessionId;
|
|
11060
11847
|
const processUuid = currentIdentity?.processUuid ??
|
|
11061
|
-
allowedPreMaterializationIdentity?.processUuid
|
|
11848
|
+
allowedPreMaterializationIdentity?.processUuid ??
|
|
11849
|
+
boundProcessUuid;
|
|
11062
11850
|
const processBirth = currentIdentity?.processBirth ??
|
|
11063
|
-
allowedPreMaterializationIdentity?.processBirth
|
|
11064
|
-
|
|
11065
|
-
|
|
11851
|
+
allowedPreMaterializationIdentity?.processBirth ??
|
|
11852
|
+
boundProcessBirth;
|
|
11853
|
+
if (!processUuid || !processBirth) {
|
|
11854
|
+
throw new Error("Codex native acceptance anchor requires an exact process incarnation");
|
|
11855
|
+
}
|
|
11856
|
+
if (!nativeThreadId && !needsPostSendNativeBinding) {
|
|
11857
|
+
throw new Error("Codex native acceptance anchor requires an exact thread");
|
|
11066
11858
|
}
|
|
11067
|
-
const base = { nativeThreadId, processUuid, processBirth };
|
|
11068
11859
|
if (!needsPostSendNativeBinding && currentIdentity?.rollout) {
|
|
11069
11860
|
return captureCodexRolloutAcceptanceAnchor({
|
|
11070
|
-
|
|
11861
|
+
nativeThreadId: nativeThreadId,
|
|
11862
|
+
processUuid,
|
|
11863
|
+
processBirth,
|
|
11864
|
+
now: cliNow(),
|
|
11071
11865
|
mode: "existing",
|
|
11072
11866
|
rollout: currentIdentity.rollout
|
|
11073
11867
|
});
|
|
11074
11868
|
}
|
|
11075
|
-
return
|
|
11076
|
-
|
|
11077
|
-
|
|
11078
|
-
|
|
11079
|
-
|
|
11869
|
+
return nativeThreadId
|
|
11870
|
+
? captureCodexRolloutAcceptanceAnchor({
|
|
11871
|
+
nativeThreadId,
|
|
11872
|
+
processUuid,
|
|
11873
|
+
processBirth,
|
|
11874
|
+
now: cliNow(),
|
|
11875
|
+
mode: "pre_materialization",
|
|
11876
|
+
expectedEmptyNativeSession: true
|
|
11877
|
+
})
|
|
11878
|
+
: captureCodexRolloutAcceptanceAnchor({
|
|
11879
|
+
processUuid,
|
|
11880
|
+
processBirth,
|
|
11881
|
+
now: cliNow(),
|
|
11882
|
+
mode: "pre_materialization",
|
|
11883
|
+
expectedEmptyNativeSession: true
|
|
11884
|
+
});
|
|
11885
|
+
}
|
|
11886
|
+
function exactNativeRolloutMatches(left, right) {
|
|
11887
|
+
return Boolean(isCompleteNativeRollout(left) &&
|
|
11888
|
+
right &&
|
|
11889
|
+
left.fd === right.fd &&
|
|
11890
|
+
left.device === right.device &&
|
|
11891
|
+
left.inode === right.inode &&
|
|
11892
|
+
left.path === right.path);
|
|
11893
|
+
}
|
|
11894
|
+
/**
|
|
11895
|
+
* Finish the one monotonic binding transaction that a process crash may split
|
|
11896
|
+
* after a virgin Codex Turn has durably dispatched Enter. This is deliberately
|
|
11897
|
+
* scoped to the v2 post-submission anchor: legacy/v1 Turns retain their normal
|
|
11898
|
+
* binding fences and never gain a new recovery path.
|
|
11899
|
+
*/
|
|
11900
|
+
async function recoverVirginCodexPostSubmissionBinding({ options, conversation, statePath, logPath, terminalLockHeld = false }) {
|
|
11901
|
+
const initialTakeover = isRecord(conversation.native_session_takeover)
|
|
11902
|
+
? conversation.native_session_takeover
|
|
11903
|
+
: undefined;
|
|
11904
|
+
const initialAnchor = isRecord(initialTakeover?.codex_rollout_acceptance_anchor)
|
|
11905
|
+
? initialTakeover.codex_rollout_acceptance_anchor
|
|
11906
|
+
: undefined;
|
|
11907
|
+
const initialSubmission = terminalBridgeSubmission(conversation);
|
|
11908
|
+
const initialTerminalControl = terminalControlFromTakeover(initialTakeover);
|
|
11909
|
+
if (executorForConversation(conversation).kind !== "codex" ||
|
|
11910
|
+
initialAnchor?.version !== 2 ||
|
|
11911
|
+
initialAnchor?.native_thread_binding !== "post_submission" ||
|
|
11912
|
+
!initialTerminalControl ||
|
|
11913
|
+
!["enter_dispatched", "agent_accepted"].includes(String(initialSubmission?.status ?? ""))) {
|
|
11914
|
+
return { conversation, state: "not_applicable" };
|
|
11915
|
+
}
|
|
11916
|
+
const storeDir = pathsForConversationDir(path.dirname(statePath)).storeDir;
|
|
11917
|
+
const releaseTerminalLock = terminalLockHeld
|
|
11918
|
+
? () => { }
|
|
11919
|
+
: acquireTerminalBridgeSendLock(storeDir, initialTerminalControl, { timeoutMs: 30000 });
|
|
11920
|
+
try {
|
|
11921
|
+
return await withStoreWriterLeaseAsync(storeDir, async () => {
|
|
11922
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
11923
|
+
try {
|
|
11924
|
+
const current = loadState(statePath);
|
|
11925
|
+
const takeover = isRecord(current.native_session_takeover)
|
|
11926
|
+
? current.native_session_takeover
|
|
11927
|
+
: undefined;
|
|
11928
|
+
const anchor = isRecord(takeover?.codex_rollout_acceptance_anchor)
|
|
11929
|
+
? takeover.codex_rollout_acceptance_anchor
|
|
11930
|
+
: undefined;
|
|
11931
|
+
const submission = terminalBridgeSubmission(current);
|
|
11932
|
+
const terminalControl = terminalControlFromTakeover(takeover);
|
|
11933
|
+
const messageId = stringValue(submission?.message_id);
|
|
11934
|
+
const requestHash = stringValue(takeover?.terminal_bridge_request_hash);
|
|
11935
|
+
const requestText = String(takeover?.terminal_bridge_request_text ?? current.user_request ?? "");
|
|
11936
|
+
if (executorForConversation(current).kind !== "codex" ||
|
|
11937
|
+
anchor?.version !== 2 ||
|
|
11938
|
+
anchor?.native_thread_binding !== "post_submission" ||
|
|
11939
|
+
!terminalControl ||
|
|
11940
|
+
!terminalControlsShareIncarnation(terminalControl, initialTerminalControl) ||
|
|
11941
|
+
!messageId ||
|
|
11942
|
+
messageId !== stringValue(takeover?.terminal_bridge_message_id) ||
|
|
11943
|
+
!["enter_dispatched", "agent_accepted"].includes(String(submission?.status ?? "")) ||
|
|
11944
|
+
!requestHash ||
|
|
11945
|
+
requestHash !== terminalBridgeRequestFingerprint(requestText)) {
|
|
11946
|
+
return { conversation: current, state: "not_applicable" };
|
|
11947
|
+
}
|
|
11948
|
+
const bindingId = stringValue(current.terminal_binding_id);
|
|
11949
|
+
const bindingGeneration = Number(current.terminal_binding_generation);
|
|
11950
|
+
const session = loadManagedSession(storeDir, sessionIdForConversation(current));
|
|
11951
|
+
const binding = session.binding;
|
|
11952
|
+
const pid = Number(takeover?.terminal_agent_pid);
|
|
11953
|
+
if (!bindingId ||
|
|
11954
|
+
!Number.isSafeInteger(bindingGeneration) ||
|
|
11955
|
+
!Number.isSafeInteger(pid) ||
|
|
11956
|
+
pid <= 1 ||
|
|
11957
|
+
session.agent !== "codex" ||
|
|
11958
|
+
session.status !== "bound" ||
|
|
11959
|
+
!binding ||
|
|
11960
|
+
binding.binding_id !== bindingId ||
|
|
11961
|
+
binding.generation !== bindingGeneration ||
|
|
11962
|
+
binding.native_process.pid !== pid ||
|
|
11963
|
+
!terminalControlsShareIncarnation(binding.terminal_control, terminalControl)) {
|
|
11964
|
+
throw new Error("virgin Codex post-submission binding changed before recovery");
|
|
11965
|
+
}
|
|
11966
|
+
const anchorProcessUuid = stringValue(anchor.process_uuid);
|
|
11967
|
+
const anchorProcessBirth = stringValue(anchor.process_birth);
|
|
11968
|
+
const turnProcessUuid = stringValue(takeover?.terminal_agent_process_uuid);
|
|
11969
|
+
const turnProcessBirth = stringValue(takeover?.terminal_agent_process_birth);
|
|
11970
|
+
if (!anchorProcessUuid ||
|
|
11971
|
+
!anchorProcessBirth ||
|
|
11972
|
+
turnProcessUuid !== anchorProcessUuid ||
|
|
11973
|
+
turnProcessBirth !== anchorProcessBirth ||
|
|
11974
|
+
binding.native_process.process_uuid !== anchorProcessUuid ||
|
|
11975
|
+
binding.native_process.process_birth !== anchorProcessBirth) {
|
|
11976
|
+
throw new Error("virgin Codex process incarnation changed before binding recovery");
|
|
11977
|
+
}
|
|
11978
|
+
const turnNativeThreadId = stringValue(current.native_thread_id) ??
|
|
11979
|
+
stringValue(takeover?.terminal_agent_session_id);
|
|
11980
|
+
const sessionNativeThreadId = binding.native_thread_id;
|
|
11981
|
+
if (turnNativeThreadId &&
|
|
11982
|
+
sessionNativeThreadId &&
|
|
11983
|
+
turnNativeThreadId !== sessionNativeThreadId) {
|
|
11984
|
+
throw new Error("virgin Codex Session and Turn disagree before binding recovery");
|
|
11985
|
+
}
|
|
11986
|
+
if (turnNativeThreadId && !isCompleteNativeRollout(takeover?.terminal_agent_rollout)) {
|
|
11987
|
+
throw new Error("virgin Codex Turn has a partial recovered native identity");
|
|
11988
|
+
}
|
|
11989
|
+
if (sessionNativeThreadId && !binding.native_process.rollout) {
|
|
11990
|
+
throw new Error("virgin Codex Session has a partial recovered native identity");
|
|
11991
|
+
}
|
|
11992
|
+
if (turnNativeThreadId && sessionNativeThreadId) {
|
|
11993
|
+
assertTurnBindingCurrent(current, "recover virgin Codex binding for");
|
|
11994
|
+
return { conversation: current, state: "already_bound" };
|
|
11995
|
+
}
|
|
11996
|
+
const preferredSessionId = turnNativeThreadId ?? sessionNativeThreadId;
|
|
11997
|
+
const identity = await resolveCurrentNativeAgentSessionIdentity({
|
|
11998
|
+
options,
|
|
11999
|
+
agent: "codex",
|
|
12000
|
+
pid,
|
|
12001
|
+
cwd: terminalControl.currentPath,
|
|
12002
|
+
preferredSessionId
|
|
12003
|
+
});
|
|
12004
|
+
if (!identity) {
|
|
12005
|
+
return { conversation: current, state: "pending" };
|
|
12006
|
+
}
|
|
12007
|
+
if (!isExactNativeThreadId(identity.sessionId) ||
|
|
12008
|
+
identity.processUuid !== anchorProcessUuid ||
|
|
12009
|
+
identity.processBirth !== anchorProcessBirth ||
|
|
12010
|
+
!isCompleteNativeRollout(identity.rollout) ||
|
|
12011
|
+
(preferredSessionId && identity.sessionId !== preferredSessionId) ||
|
|
12012
|
+
(turnNativeThreadId &&
|
|
12013
|
+
!exactNativeRolloutMatches(takeover?.terminal_agent_rollout, identity.rollout)) ||
|
|
12014
|
+
(sessionNativeThreadId &&
|
|
12015
|
+
!exactNativeRolloutMatches(binding.native_process.rollout, identity.rollout))) {
|
|
12016
|
+
throw new Error("virgin Codex native identity changed before binding recovery");
|
|
12017
|
+
}
|
|
12018
|
+
// When neither side was committed, only the exact native acceptance
|
|
12019
|
+
// record may tell us that this newly materialized UUID belongs to the
|
|
12020
|
+
// dispatched request. If either side was already committed before the
|
|
12021
|
+
// crash, that durable CAS plus a fresh exact identity is sufficient to
|
|
12022
|
+
// finish the other side while acceptance is still being written.
|
|
12023
|
+
if (!turnNativeThreadId && !sessionNativeThreadId) {
|
|
12024
|
+
const acceptance = detectCodexRolloutAcceptance({
|
|
12025
|
+
anchor: anchor,
|
|
12026
|
+
currentIdentity: identity,
|
|
12027
|
+
requestHash
|
|
12028
|
+
});
|
|
12029
|
+
if (!acceptance) {
|
|
12030
|
+
return { conversation: current, state: "pending" };
|
|
12031
|
+
}
|
|
12032
|
+
}
|
|
12033
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
12034
|
+
options,
|
|
12035
|
+
agent: "codex",
|
|
12036
|
+
currentPid: pid,
|
|
12037
|
+
nativeThreadId: identity.sessionId,
|
|
12038
|
+
storeDir,
|
|
12039
|
+
terminalControl,
|
|
12040
|
+
excludedManagedSessionId: session.session_id
|
|
12041
|
+
});
|
|
12042
|
+
if (!sessionNativeThreadId) {
|
|
12043
|
+
const persistedSession = persistManagedSessionNativeIdentity({
|
|
12044
|
+
conversation: current,
|
|
12045
|
+
terminalControl,
|
|
12046
|
+
identity
|
|
12047
|
+
});
|
|
12048
|
+
if (persistedSession?.binding?.native_thread_id !==
|
|
12049
|
+
identity.sessionId ||
|
|
12050
|
+
persistedSession.binding.native_process.process_uuid !==
|
|
12051
|
+
identity.processUuid ||
|
|
12052
|
+
persistedSession.binding.native_process.process_birth !==
|
|
12053
|
+
identity.processBirth ||
|
|
12054
|
+
!exactNativeRolloutMatches(persistedSession.binding.native_process.rollout, identity.rollout)) {
|
|
12055
|
+
throw new Error("virgin Codex Session identity was not durably committed during recovery");
|
|
12056
|
+
}
|
|
12057
|
+
}
|
|
12058
|
+
const recoveredAt = cliNow().toISOString();
|
|
12059
|
+
const recoveredConversation = turnNativeThreadId
|
|
12060
|
+
? current
|
|
12061
|
+
: {
|
|
12062
|
+
...withNativeAgentSessionIdentity(current, identity),
|
|
12063
|
+
updated_at: recoveredAt
|
|
12064
|
+
};
|
|
12065
|
+
if (!turnNativeThreadId) {
|
|
12066
|
+
saveState(statePath, recoveredConversation);
|
|
12067
|
+
}
|
|
12068
|
+
assertNativeAgentIdentityForTurn({
|
|
12069
|
+
conversation: recoveredConversation,
|
|
12070
|
+
currentIdentity: identity,
|
|
12071
|
+
operation: "recover virgin Codex binding for"
|
|
12072
|
+
});
|
|
12073
|
+
try {
|
|
12074
|
+
appendEvent(logPath, {
|
|
12075
|
+
ts: recoveredAt,
|
|
12076
|
+
conversation_id: recoveredConversation.conversation_id,
|
|
12077
|
+
event: "virgin_codex_post_submission_binding_recovered",
|
|
12078
|
+
message_id: messageId,
|
|
12079
|
+
native_thread_id: identity.sessionId,
|
|
12080
|
+
terminal_control: terminalControl
|
|
12081
|
+
});
|
|
12082
|
+
}
|
|
12083
|
+
catch (error) {
|
|
12084
|
+
runtimeLog("warn", "virgin_codex_binding_recovery_event_failed", {
|
|
12085
|
+
conversation_id: recoveredConversation.conversation_id,
|
|
12086
|
+
terminal_target: terminalControl.target,
|
|
12087
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12088
|
+
});
|
|
12089
|
+
}
|
|
12090
|
+
return { conversation: recoveredConversation, state: "recovered" };
|
|
12091
|
+
}
|
|
12092
|
+
finally {
|
|
12093
|
+
releaseStateLock();
|
|
12094
|
+
}
|
|
12095
|
+
});
|
|
12096
|
+
}
|
|
12097
|
+
finally {
|
|
12098
|
+
releaseTerminalLock();
|
|
12099
|
+
}
|
|
11080
12100
|
}
|
|
11081
12101
|
async function detectTerminalSubmissionAcceptance({ options, executor, conversation, terminalControl }) {
|
|
11082
12102
|
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
@@ -11187,12 +12207,57 @@ async function pollTerminalSubmissionAcceptance({ options, executor, conversatio
|
|
|
11187
12207
|
return { outcome: "pending_acceptance" };
|
|
11188
12208
|
}
|
|
11189
12209
|
async function reconcileTerminalAcceptanceInMonitor({ options, conversation, statePath, logPath, terminalControl, executor, terminalBridge }) {
|
|
11190
|
-
|
|
12210
|
+
let bindingRecovery = await recoverVirginCodexPostSubmissionBinding({
|
|
12211
|
+
options,
|
|
12212
|
+
conversation,
|
|
12213
|
+
statePath,
|
|
12214
|
+
logPath,
|
|
12215
|
+
terminalLockHeld: true
|
|
12216
|
+
});
|
|
12217
|
+
conversation = bindingRecovery.conversation;
|
|
12218
|
+
let evidence = await detectTerminalSubmissionAcceptance({
|
|
11191
12219
|
options,
|
|
11192
12220
|
executor: executor.kind,
|
|
11193
12221
|
conversation,
|
|
11194
12222
|
terminalControl
|
|
11195
12223
|
});
|
|
12224
|
+
const recoveredAnchor = isRecord(conversation.native_session_takeover) &&
|
|
12225
|
+
isRecord(conversation.native_session_takeover.codex_rollout_acceptance_anchor)
|
|
12226
|
+
? conversation.native_session_takeover.codex_rollout_acceptance_anchor
|
|
12227
|
+
: undefined;
|
|
12228
|
+
if (evidence &&
|
|
12229
|
+
recoveredAnchor?.version === 2 &&
|
|
12230
|
+
bindingRecovery.state === "not_applicable") {
|
|
12231
|
+
throw new Error("virgin Codex acceptance appeared without a recoverable Session/Turn binding");
|
|
12232
|
+
}
|
|
12233
|
+
if (evidence &&
|
|
12234
|
+
recoveredAnchor?.version === 2 &&
|
|
12235
|
+
bindingRecovery.state === "pending") {
|
|
12236
|
+
// The session_meta and exact user record can land between the first
|
|
12237
|
+
// recovery probe and this acceptance probe. Never persist agent_accepted
|
|
12238
|
+
// until the same evidence has also closed the Session/Turn binding CAS.
|
|
12239
|
+
bindingRecovery = await recoverVirginCodexPostSubmissionBinding({
|
|
12240
|
+
options,
|
|
12241
|
+
conversation,
|
|
12242
|
+
statePath,
|
|
12243
|
+
logPath,
|
|
12244
|
+
terminalLockHeld: true
|
|
12245
|
+
});
|
|
12246
|
+
if (bindingRecovery.state !== "recovered" &&
|
|
12247
|
+
bindingRecovery.state !== "already_bound") {
|
|
12248
|
+
return { outcome: "pending" };
|
|
12249
|
+
}
|
|
12250
|
+
conversation = bindingRecovery.conversation;
|
|
12251
|
+
evidence = await detectTerminalSubmissionAcceptance({
|
|
12252
|
+
options,
|
|
12253
|
+
executor: executor.kind,
|
|
12254
|
+
conversation,
|
|
12255
|
+
terminalControl
|
|
12256
|
+
});
|
|
12257
|
+
if (!evidence) {
|
|
12258
|
+
return { outcome: "pending" };
|
|
12259
|
+
}
|
|
12260
|
+
}
|
|
11196
12261
|
const submission = terminalBridgeSubmission(conversation);
|
|
11197
12262
|
const nativeTakeover = isRecord(conversation.native_session_takeover)
|
|
11198
12263
|
? conversation.native_session_takeover
|
|
@@ -12331,12 +13396,18 @@ async function reconcileMonitors(options, { includeCallbackRecovery, reason, con
|
|
|
12331
13396
|
ignored += 1;
|
|
12332
13397
|
continue;
|
|
12333
13398
|
}
|
|
12334
|
-
|
|
13399
|
+
let initialConversation = await migrateLegacyTerminalAgentIdentity({
|
|
12335
13400
|
conversation: loadState(statePath),
|
|
12336
13401
|
statePath,
|
|
12337
13402
|
logPath,
|
|
12338
13403
|
options
|
|
12339
13404
|
});
|
|
13405
|
+
initialConversation = (await recoverVirginCodexPostSubmissionBinding({
|
|
13406
|
+
options,
|
|
13407
|
+
conversation: initialConversation,
|
|
13408
|
+
statePath,
|
|
13409
|
+
logPath
|
|
13410
|
+
})).conversation;
|
|
12340
13411
|
assertTurnBindingCurrent(initialConversation, "reconcile monitor for");
|
|
12341
13412
|
const initialEligibility = terminalBridgeReconciliationEligibility(initialConversation);
|
|
12342
13413
|
if (!initialEligibility.eligible) {
|
|
@@ -12960,9 +14031,185 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
|
|
|
12960
14031
|
}
|
|
12961
14032
|
}
|
|
12962
14033
|
}
|
|
14034
|
+
async function runObservedHandoffClose({ options, statePath, logPath, initialConversation }) {
|
|
14035
|
+
const expectedToken = required(stringValue(options.expectedHandoffToken), "--expected-handoff-token is required");
|
|
14036
|
+
if (stringValue(options.expectedMessageId) ||
|
|
14037
|
+
stringValue(options.expectedTransitionId)) {
|
|
14038
|
+
throw new Error("--expected-handoff-token cannot be combined with dispatch or lifecycle recovery tokens");
|
|
14039
|
+
}
|
|
14040
|
+
if (stringValue(options.reason) !== "superseded_by_human_context_switch") {
|
|
14041
|
+
throw new Error("a handoff close requires reason superseded_by_human_context_switch");
|
|
14042
|
+
}
|
|
14043
|
+
const storeDir = storeDirFromOptions(options);
|
|
14044
|
+
const sourceSessionId = sessionIdForConversation(initialConversation);
|
|
14045
|
+
const initialSource = loadManagedSession(storeDir, sourceSessionId);
|
|
14046
|
+
if (!initialSource.binding || initialSource.status !== "bound") {
|
|
14047
|
+
throw new Error("handoff source Session is no longer bound; refresh list");
|
|
14048
|
+
}
|
|
14049
|
+
const terminal = await createTerminalAgentBridge(options).resolveStoredTerminal(initialSource.agent, initialSource.binding.native_process.pid, initialSource.binding.terminal_control, { pid: initialSource.binding.native_process.pid });
|
|
14050
|
+
const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, terminal.terminalControl, { timeoutMs: 30000 });
|
|
14051
|
+
try {
|
|
14052
|
+
await withStoreWriterLeaseAsync(storeDir, async () => {
|
|
14053
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
14054
|
+
try {
|
|
14055
|
+
const conversation = loadState(statePath);
|
|
14056
|
+
const turnId = turnIdForConversation(conversation);
|
|
14057
|
+
if (conversation.conversation_id !== initialConversation.conversation_id ||
|
|
14058
|
+
sessionIdForConversation(conversation) !== sourceSessionId ||
|
|
14059
|
+
!SESSION_SEND_BLOCKING_STATUSES.has(conversation.status)) {
|
|
14060
|
+
throw new Error("active handoff Turn changed after it was listed; refresh AKK list");
|
|
14061
|
+
}
|
|
14062
|
+
const source = loadManagedSession(storeDir, sourceSessionId);
|
|
14063
|
+
if (source.status !== "bound" ||
|
|
14064
|
+
!source.binding ||
|
|
14065
|
+
managedSessionHasUnresolvedNativeTransition(storeDir, source)) {
|
|
14066
|
+
throw new Error("handoff source Session changed after it was listed; refresh AKK list");
|
|
14067
|
+
}
|
|
14068
|
+
const companions = codexAllowedCompanionSetForManagedSession({
|
|
14069
|
+
storeDir,
|
|
14070
|
+
session: source
|
|
14071
|
+
});
|
|
14072
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
14073
|
+
options,
|
|
14074
|
+
agent: terminal.agent,
|
|
14075
|
+
pid: terminal.pid,
|
|
14076
|
+
cwd: terminal.terminalControl.currentPath,
|
|
14077
|
+
preferredSessionId: companions.primary
|
|
14078
|
+
? source.binding.native_thread_id
|
|
14079
|
+
: undefined,
|
|
14080
|
+
allowedCompanionIdentity: companions.primary,
|
|
14081
|
+
allowedAdditionalIdentities: companions.additional
|
|
14082
|
+
});
|
|
14083
|
+
const observed = await observedExternalHandoffIdentity({
|
|
14084
|
+
options,
|
|
14085
|
+
terminal,
|
|
14086
|
+
sourceSession: source,
|
|
14087
|
+
resolvedIdentity: resolved
|
|
14088
|
+
});
|
|
14089
|
+
if (!observed.identity ||
|
|
14090
|
+
managedBindingConflictKindForResolvedTerminal({
|
|
14091
|
+
storeDir,
|
|
14092
|
+
session: source,
|
|
14093
|
+
terminal,
|
|
14094
|
+
identity: observed.identity
|
|
14095
|
+
}) !== "live_external_thread_change") {
|
|
14096
|
+
throw new Error("live native thread no longer matches the listed handoff; refresh AKK list");
|
|
14097
|
+
}
|
|
14098
|
+
const targetNativeThreadId = observed.identity.sessionId.toLowerCase();
|
|
14099
|
+
const target = observedHandoffTargetResolution({
|
|
14100
|
+
storeDir,
|
|
14101
|
+
agent: terminal.agent,
|
|
14102
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
14103
|
+
nativeThreadId: targetNativeThreadId,
|
|
14104
|
+
sourceSessionId
|
|
14105
|
+
});
|
|
14106
|
+
if (target.status !== "eligible") {
|
|
14107
|
+
throw new Error("handoff target Session changed after it was listed; refresh AKK list");
|
|
14108
|
+
}
|
|
14109
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
14110
|
+
options,
|
|
14111
|
+
agent: terminal.agent,
|
|
14112
|
+
currentPid: terminal.pid,
|
|
14113
|
+
nativeThreadId: targetNativeThreadId,
|
|
14114
|
+
storeDir,
|
|
14115
|
+
terminalControl: terminal.terminalControl,
|
|
14116
|
+
excludedManagedSessionId: target.snapshot.state === "detached"
|
|
14117
|
+
? target.snapshot.session_id
|
|
14118
|
+
: undefined
|
|
14119
|
+
});
|
|
14120
|
+
const handoffToken = observedHandoffAuthorityToken({
|
|
14121
|
+
terminal,
|
|
14122
|
+
identity: observed.identity,
|
|
14123
|
+
sourceSession: source,
|
|
14124
|
+
target: target.snapshot
|
|
14125
|
+
});
|
|
14126
|
+
const takeover = isRecord(conversation.native_session_takeover)
|
|
14127
|
+
? conversation.native_session_takeover
|
|
14128
|
+
: undefined;
|
|
14129
|
+
const expectedMessageId = stringValue(takeover?.terminal_bridge_message_id) ?? stringValue(terminalBridgeSubmission(conversation)?.message_id);
|
|
14130
|
+
const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
14131
|
+
const exactDispatchGeneration = Boolean(expectedMessageId &&
|
|
14132
|
+
ledger &&
|
|
14133
|
+
!terminalDispatchLedgerLooksLifecycle(ledger) &&
|
|
14134
|
+
stringValue(ledger.conversation_id) ===
|
|
14135
|
+
conversation.conversation_id &&
|
|
14136
|
+
stringValue(ledger.session_id) === sourceSessionId &&
|
|
14137
|
+
stringValue(ledger.turn_id) === turnId &&
|
|
14138
|
+
stringValue(ledger.message_id) === expectedMessageId);
|
|
14139
|
+
const exactNoLedgerGeneration = Boolean(!expectedMessageId &&
|
|
14140
|
+
(!ledger || ledger.status === "resolved"));
|
|
14141
|
+
if (!exactDispatchGeneration && !exactNoLedgerGeneration) {
|
|
14142
|
+
throw new Error("active handoff dispatch generation changed; refresh AKK list");
|
|
14143
|
+
}
|
|
14144
|
+
const freshToken = activeTurnHandoffDecisionToken({
|
|
14145
|
+
handoffToken,
|
|
14146
|
+
turn: conversation,
|
|
14147
|
+
ledger
|
|
14148
|
+
});
|
|
14149
|
+
if (freshToken !== expectedToken) {
|
|
14150
|
+
throw new Error("active handoff snapshot changed; refresh AKK list before closing");
|
|
14151
|
+
}
|
|
14152
|
+
const now = cliNow().toISOString();
|
|
14153
|
+
const closed = {
|
|
14154
|
+
...conversation,
|
|
14155
|
+
status: "closed",
|
|
14156
|
+
closed_at: now,
|
|
14157
|
+
close_reason: "superseded_by_human_context_switch",
|
|
14158
|
+
disposition: "superseded_by_human_context_switch",
|
|
14159
|
+
updated_at: now
|
|
14160
|
+
};
|
|
14161
|
+
saveState(statePath, closed);
|
|
14162
|
+
const dispatchResolved = expectedMessageId
|
|
14163
|
+
? resolveTerminalBridgeDispatchLedger({
|
|
14164
|
+
terminalControl: terminal.terminalControl,
|
|
14165
|
+
conversation: closed,
|
|
14166
|
+
expectedMessageId,
|
|
14167
|
+
reason: "Turn superseded by a verified human context switch"
|
|
14168
|
+
})
|
|
14169
|
+
: false;
|
|
14170
|
+
if (expectedMessageId && !dispatchResolved) {
|
|
14171
|
+
throw new Error("active handoff dispatch changed during close; inspect before retrying");
|
|
14172
|
+
}
|
|
14173
|
+
appendEvent(logPath, {
|
|
14174
|
+
ts: now,
|
|
14175
|
+
conversation_id: conversation.conversation_id,
|
|
14176
|
+
event: "conversation_closed",
|
|
14177
|
+
status: "closed",
|
|
14178
|
+
reason: "superseded_by_human_context_switch",
|
|
14179
|
+
disposition: "superseded_by_human_context_switch",
|
|
14180
|
+
handoff_native_thread_id: targetNativeThreadId
|
|
14181
|
+
});
|
|
14182
|
+
runtimeLog("info", "conversation_closed", {
|
|
14183
|
+
conversation_id: conversation.conversation_id,
|
|
14184
|
+
status: "closed",
|
|
14185
|
+
reason: "superseded_by_human_context_switch",
|
|
14186
|
+
disposition: "superseded_by_human_context_switch",
|
|
14187
|
+
state_path: statePath,
|
|
14188
|
+
event_log_path: logPath
|
|
14189
|
+
});
|
|
14190
|
+
printJson({
|
|
14191
|
+
conversation: closed,
|
|
14192
|
+
closed: true,
|
|
14193
|
+
terminal_dispatch_resolved: dispatchResolved,
|
|
14194
|
+
handoff_disposition: "superseded_by_human_context_switch",
|
|
14195
|
+
next_action: "refresh list and use its follow-current send"
|
|
14196
|
+
});
|
|
14197
|
+
}
|
|
14198
|
+
finally {
|
|
14199
|
+
releaseStateLock();
|
|
14200
|
+
}
|
|
14201
|
+
});
|
|
14202
|
+
}
|
|
14203
|
+
finally {
|
|
14204
|
+
releaseTerminalLock();
|
|
14205
|
+
}
|
|
14206
|
+
}
|
|
12963
14207
|
async function runClose(options) {
|
|
12964
14208
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
12965
14209
|
if (terminalConversation) {
|
|
14210
|
+
if (stringValue(options.expectedHandoffToken)) {
|
|
14211
|
+
throw new Error("--expected-handoff-token cannot be used with raw terminal close");
|
|
14212
|
+
}
|
|
12966
14213
|
await runTerminalDispatchClose({
|
|
12967
14214
|
options,
|
|
12968
14215
|
terminalConversation
|
|
@@ -12970,6 +14217,15 @@ async function runClose(options) {
|
|
|
12970
14217
|
return;
|
|
12971
14218
|
}
|
|
12972
14219
|
const loaded = loadConversationFromOptions(options);
|
|
14220
|
+
if (stringValue(options.expectedHandoffToken)) {
|
|
14221
|
+
await runObservedHandoffClose({
|
|
14222
|
+
options,
|
|
14223
|
+
statePath: loaded.statePath,
|
|
14224
|
+
logPath: loaded.logPath,
|
|
14225
|
+
initialConversation: loaded.conversation
|
|
14226
|
+
});
|
|
14227
|
+
return;
|
|
14228
|
+
}
|
|
12973
14229
|
const { statePath, logPath } = loaded;
|
|
12974
14230
|
const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
|
|
12975
14231
|
? loaded.conversation.native_session_takeover
|
|
@@ -12983,11 +14239,18 @@ async function runClose(options) {
|
|
|
12983
14239
|
releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
12984
14240
|
const conversation = loadState(statePath);
|
|
12985
14241
|
const now = cliNow().toISOString();
|
|
14242
|
+
const closeReason = options.reason ?? "closed by request";
|
|
14243
|
+
const handoffDisposition = closeReason === "superseded_by_human_context_switch"
|
|
14244
|
+
? closeReason
|
|
14245
|
+
: undefined;
|
|
12986
14246
|
const closed = {
|
|
12987
14247
|
...conversation,
|
|
12988
14248
|
status: "closed",
|
|
12989
14249
|
closed_at: now,
|
|
12990
|
-
close_reason:
|
|
14250
|
+
close_reason: closeReason,
|
|
14251
|
+
...(handoffDisposition
|
|
14252
|
+
? { disposition: handoffDisposition }
|
|
14253
|
+
: {}),
|
|
12991
14254
|
updated_at: now
|
|
12992
14255
|
};
|
|
12993
14256
|
saveState(statePath, closed);
|
|
@@ -15027,6 +16290,7 @@ function terminalDispatchLedgerLooksLifecycle(ledger) {
|
|
|
15027
16290
|
ledger.transition_id !== undefined ||
|
|
15028
16291
|
ledger.operation === "new_thread" ||
|
|
15029
16292
|
ledger.operation === "resume_thread" ||
|
|
16293
|
+
ledger.operation === "adopt_external_thread" ||
|
|
15030
16294
|
ledger.adapter_version !== undefined ||
|
|
15031
16295
|
ledger.command_fingerprint !== undefined ||
|
|
15032
16296
|
ledger.target_session_id !== undefined ||
|
|
@@ -15042,6 +16306,8 @@ function lifecycleLedgerFields(transition, storeDir) {
|
|
|
15042
16306
|
generation_id: transition.transition_id,
|
|
15043
16307
|
transition_id: transition.transition_id,
|
|
15044
16308
|
operation: transition.operation,
|
|
16309
|
+
origin: transition.origin,
|
|
16310
|
+
terminal_input_sent: transition.terminal_input_sent,
|
|
15045
16311
|
terminal_id: transition.terminal_id,
|
|
15046
16312
|
agent: transition.agent,
|
|
15047
16313
|
workspace: transition.workspace,
|
|
@@ -15475,6 +16741,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
15475
16741
|
const dispatcherPid = Number(ledger.dispatcher_pid);
|
|
15476
16742
|
if (Number.isSafeInteger(dispatcherPid) &&
|
|
15477
16743
|
dispatcherPid > 1 &&
|
|
16744
|
+
dispatcherPid !== cliPid() &&
|
|
15478
16745
|
isProcessAlive(dispatcherPid)) {
|
|
15479
16746
|
return ledger;
|
|
15480
16747
|
}
|
|
@@ -15812,9 +17079,16 @@ function terminalSubmissionProofRank(status, lastProven) {
|
|
|
15812
17079
|
return 0;
|
|
15813
17080
|
}
|
|
15814
17081
|
async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
15815
|
-
|
|
17082
|
+
let ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
15816
17083
|
if (!ledger || ledger.status === "resolved") {
|
|
15817
|
-
|
|
17084
|
+
ledger = rebuildObservedHandoffLedgerFromTransition({
|
|
17085
|
+
options,
|
|
17086
|
+
terminal,
|
|
17087
|
+
previousLedger: ledger
|
|
17088
|
+
});
|
|
17089
|
+
if (!ledger) {
|
|
17090
|
+
return;
|
|
17091
|
+
}
|
|
15818
17092
|
}
|
|
15819
17093
|
if (!terminalDispatchLedgerLooksLifecycle(ledger)) {
|
|
15820
17094
|
return;
|
|
@@ -15833,6 +17107,175 @@ async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
|
15833
17107
|
"expected-transition-id recovery action");
|
|
15834
17108
|
}
|
|
15835
17109
|
}
|
|
17110
|
+
function rebuildObservedHandoffLedgerFromTransition({ options, terminal, previousLedger }) {
|
|
17111
|
+
const storeDir = storeDirFromOptions(options);
|
|
17112
|
+
const candidates = listNativeThreadTransitions(storeDir).filter((transition) => transition.operation === "adopt_external_thread" &&
|
|
17113
|
+
transition.origin === "human_observed" &&
|
|
17114
|
+
transition.terminal_input_sent === false &&
|
|
17115
|
+
["prepared", "verified"].includes(transition.status) &&
|
|
17116
|
+
transition.agent === terminal.agent &&
|
|
17117
|
+
transition.before_binding?.native_process.pid === terminal.pid &&
|
|
17118
|
+
terminalControlAliasMatches(transition.terminal_id, transition.before_binding?.terminal_control, terminal.conversationId, terminal.terminalControl) &&
|
|
17119
|
+
matchesConfiguredWorkspace(transition.workspace, terminal.terminalControl.currentPath));
|
|
17120
|
+
if (candidates.length === 0) {
|
|
17121
|
+
return undefined;
|
|
17122
|
+
}
|
|
17123
|
+
if (candidates.length !== 1) {
|
|
17124
|
+
throw new Error(`terminal ${terminal.terminalControl.target} has multiple unresolved ` +
|
|
17125
|
+
"human-observed handoff transitions; refusing to infer one ledger owner");
|
|
17126
|
+
}
|
|
17127
|
+
const transition = candidates[0];
|
|
17128
|
+
if (previousLedger?.status === "resolved" &&
|
|
17129
|
+
stringValue(previousLedger.transition_id) === transition.transition_id) {
|
|
17130
|
+
throw new Error(`human-observed handoff transition ${transition.transition_id} is ` +
|
|
17131
|
+
"unresolved but its terminal ledger is already resolved");
|
|
17132
|
+
}
|
|
17133
|
+
const rebuilt = {
|
|
17134
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17135
|
+
status: transition.status,
|
|
17136
|
+
binding: transition.before_binding,
|
|
17137
|
+
terminal_control: {
|
|
17138
|
+
kind: terminal.terminalControl.kind,
|
|
17139
|
+
target: terminal.terminalControl.target,
|
|
17140
|
+
socket_path: terminal.terminalControl.socketPath ?? null,
|
|
17141
|
+
pane_pid: terminal.terminalControl.panePid ?? null,
|
|
17142
|
+
current_path: terminal.terminalControl.currentPath ?? null
|
|
17143
|
+
},
|
|
17144
|
+
...(hasCanonicalTerminalEndpoint(terminal.terminalControl)
|
|
17145
|
+
? {
|
|
17146
|
+
terminal_endpoint: terminalControlEvidence(terminal.terminalControl)
|
|
17147
|
+
}
|
|
17148
|
+
: {}),
|
|
17149
|
+
previous_generation_id: stringValue(previousLedger?.generation_id) ??
|
|
17150
|
+
stringValue(previousLedger?.message_id)
|
|
17151
|
+
};
|
|
17152
|
+
// Validate the complete transition/terminal/adapter relationship before
|
|
17153
|
+
// replacing a missing or older resolved ledger. Rebuilding a fence is a
|
|
17154
|
+
// Store-side recovery operation only and never sends terminal input.
|
|
17155
|
+
assertLifecycleLedgerMatchesTransition({
|
|
17156
|
+
options,
|
|
17157
|
+
terminal,
|
|
17158
|
+
ledger: rebuilt,
|
|
17159
|
+
transition,
|
|
17160
|
+
storeDir
|
|
17161
|
+
});
|
|
17162
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, rebuilt, {
|
|
17163
|
+
expectedTransitionId: null
|
|
17164
|
+
});
|
|
17165
|
+
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
17166
|
+
}
|
|
17167
|
+
async function recoverPreparedObservedHandoff({ options, terminal, ledger, transition, storeDir, now }) {
|
|
17168
|
+
if (transition.operation !== "adopt_external_thread" ||
|
|
17169
|
+
transition.status !== "prepared" ||
|
|
17170
|
+
ledger.status !== "prepared" ||
|
|
17171
|
+
!transition.source_session_id ||
|
|
17172
|
+
transition.source_expected_revision === undefined ||
|
|
17173
|
+
!transition.before_binding ||
|
|
17174
|
+
!transition.target_native_thread_id) {
|
|
17175
|
+
return transition;
|
|
17176
|
+
}
|
|
17177
|
+
let source = loadManagedSession(storeDir, transition.source_session_id);
|
|
17178
|
+
if (source.status === "bound" &&
|
|
17179
|
+
source.revision === transition.source_expected_revision &&
|
|
17180
|
+
JSON.stringify(source.binding) === JSON.stringify(transition.before_binding)) {
|
|
17181
|
+
source = saveManagedSession(storeDir, {
|
|
17182
|
+
...source,
|
|
17183
|
+
status: "transitioning",
|
|
17184
|
+
last_transition_id: transition.transition_id,
|
|
17185
|
+
updated_at: now
|
|
17186
|
+
}, { expectedRevision: managedSessionRevision(source) });
|
|
17187
|
+
}
|
|
17188
|
+
else if (source.status !== "transitioning" ||
|
|
17189
|
+
source.last_transition_id !== transition.transition_id ||
|
|
17190
|
+
source.revision !== transition.source_expected_revision + 1 ||
|
|
17191
|
+
JSON.stringify(source.binding) !== JSON.stringify(transition.before_binding)) {
|
|
17192
|
+
throw new Error("human-observed handoff source changed before recovery");
|
|
17193
|
+
}
|
|
17194
|
+
const before = transition.before_binding;
|
|
17195
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
17196
|
+
options,
|
|
17197
|
+
agent: terminal.agent,
|
|
17198
|
+
pid: terminal.pid,
|
|
17199
|
+
cwd: terminal.terminalControl.currentPath,
|
|
17200
|
+
preferredSessionId: transition.target_native_thread_id,
|
|
17201
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
17202
|
+
sessionId: transition.before_native_thread_id,
|
|
17203
|
+
processUuid: transition.before_process_uuid,
|
|
17204
|
+
processBirth: transition.before_process_birth,
|
|
17205
|
+
rollout: transition.before_process_rollout,
|
|
17206
|
+
evidence: before.native_process.evidence
|
|
17207
|
+
}),
|
|
17208
|
+
allowedAdditionalIdentities: []
|
|
17209
|
+
});
|
|
17210
|
+
const observed = await observedExternalHandoffIdentity({
|
|
17211
|
+
options,
|
|
17212
|
+
terminal,
|
|
17213
|
+
sourceSession: source,
|
|
17214
|
+
resolvedIdentity: resolved
|
|
17215
|
+
});
|
|
17216
|
+
const liveId = observed.identity?.sessionId.toLowerCase();
|
|
17217
|
+
if (liveId === transition.before_native_thread_id.toLowerCase()) {
|
|
17218
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
17219
|
+
...transition,
|
|
17220
|
+
status: "aborted",
|
|
17221
|
+
aborted_at: now,
|
|
17222
|
+
error: "recovery observed the exact source native thread"
|
|
17223
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
17224
|
+
restorePreparedLifecycleSource(storeDir, transition, now);
|
|
17225
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
17226
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17227
|
+
status: "resolved",
|
|
17228
|
+
resolved_at: now,
|
|
17229
|
+
reason: "human-observed handoff rolled back to its exact source"
|
|
17230
|
+
}, { expectedTransitionId: transition.transition_id });
|
|
17231
|
+
return transition;
|
|
17232
|
+
}
|
|
17233
|
+
if (!observed.identity ||
|
|
17234
|
+
liveId !== transition.target_native_thread_id.toLowerCase()) {
|
|
17235
|
+
throw new Error("human-observed handoff recovery found neither its exact source nor target");
|
|
17236
|
+
}
|
|
17237
|
+
const target = tryLoadManagedSession(storeDir, transition.target_session_id);
|
|
17238
|
+
if ((target?.revision ?? null) !== transition.target_expected_revision) {
|
|
17239
|
+
throw new Error("human-observed handoff target changed before recovery");
|
|
17240
|
+
}
|
|
17241
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
17242
|
+
options,
|
|
17243
|
+
agent: terminal.agent,
|
|
17244
|
+
currentPid: terminal.pid,
|
|
17245
|
+
nativeThreadId: transition.target_native_thread_id,
|
|
17246
|
+
storeDir,
|
|
17247
|
+
terminalControl: terminal.terminalControl,
|
|
17248
|
+
excludedManagedSessionId: target?.session_id
|
|
17249
|
+
});
|
|
17250
|
+
const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
|
|
17251
|
+
const afterBinding = terminalBindingFrom({
|
|
17252
|
+
terminalId: terminal.conversationId,
|
|
17253
|
+
terminalControl: terminal.terminalControl,
|
|
17254
|
+
pid: terminal.pid,
|
|
17255
|
+
nativeThreadId: transition.target_native_thread_id,
|
|
17256
|
+
processUuid: exact.processUuid,
|
|
17257
|
+
processBirth: exact.processBirth,
|
|
17258
|
+
rollout: exact.rollout,
|
|
17259
|
+
evidence: `${exact.evidence}+human_observed_recovery`,
|
|
17260
|
+
generation: (target?.binding?.generation ?? 0) + 1,
|
|
17261
|
+
now: new Date(now)
|
|
17262
|
+
});
|
|
17263
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
17264
|
+
...transition,
|
|
17265
|
+
status: "verified",
|
|
17266
|
+
verified_at: now,
|
|
17267
|
+
after_binding: afterBinding
|
|
17268
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
17269
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
17270
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17271
|
+
status: "verified",
|
|
17272
|
+
binding: before
|
|
17273
|
+
}, {
|
|
17274
|
+
expectedTransitionId: transition.transition_id,
|
|
17275
|
+
expectedStatus: "prepared"
|
|
17276
|
+
});
|
|
17277
|
+
return transition;
|
|
17278
|
+
}
|
|
15836
17279
|
async function reconcileLifecycleDispatchLedger(options, terminal, ledger, authority = {
|
|
15837
17280
|
kind: "automatic"
|
|
15838
17281
|
}) {
|
|
@@ -15876,6 +17319,34 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
|
|
|
15876
17319
|
transition,
|
|
15877
17320
|
storeDir
|
|
15878
17321
|
});
|
|
17322
|
+
if (transition.operation === "adopt_external_thread" &&
|
|
17323
|
+
transition.status === "verified" &&
|
|
17324
|
+
ledger.status === "prepared") {
|
|
17325
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
17326
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17327
|
+
status: "verified",
|
|
17328
|
+
binding: transition.before_binding,
|
|
17329
|
+
previous_generation_id: stringValue(ledger.previous_generation_id)
|
|
17330
|
+
}, {
|
|
17331
|
+
expectedTransitionId: transition.transition_id,
|
|
17332
|
+
expectedStatus: "prepared"
|
|
17333
|
+
});
|
|
17334
|
+
ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
17335
|
+
}
|
|
17336
|
+
if (transition.operation === "adopt_external_thread" &&
|
|
17337
|
+
transition.status === "prepared") {
|
|
17338
|
+
transition = await recoverPreparedObservedHandoff({
|
|
17339
|
+
options,
|
|
17340
|
+
terminal,
|
|
17341
|
+
ledger,
|
|
17342
|
+
transition,
|
|
17343
|
+
storeDir,
|
|
17344
|
+
now
|
|
17345
|
+
});
|
|
17346
|
+
if (["aborted", "committed"].includes(transition.status)) {
|
|
17347
|
+
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
17348
|
+
}
|
|
17349
|
+
}
|
|
15879
17350
|
if (transition.status === "prepared" && ledger.status === "prepared") {
|
|
15880
17351
|
await assertNativeThreadHasExclusiveOwnership({
|
|
15881
17352
|
options,
|
|
@@ -16014,9 +17485,9 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
|
|
|
16014
17485
|
}
|
|
16015
17486
|
}
|
|
16016
17487
|
function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, transition, storeDir }) {
|
|
16017
|
-
const operationTarget = transition.operation === "
|
|
16018
|
-
?
|
|
16019
|
-
:
|
|
17488
|
+
const operationTarget = transition.operation === "new_thread"
|
|
17489
|
+
? undefined
|
|
17490
|
+
: transition.target_native_thread_id;
|
|
16020
17491
|
const ledgerControl = isRecord(ledger.terminal_control)
|
|
16021
17492
|
? ledger.terminal_control
|
|
16022
17493
|
: undefined;
|
|
@@ -16025,8 +17496,12 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16025
17496
|
dispatching: ["prepared", "dispatching", "uncertain"],
|
|
16026
17497
|
submitted: ["dispatching", "submitted", "uncertain"],
|
|
16027
17498
|
uncertain: ["dispatching", "submitted", "uncertain"],
|
|
16028
|
-
verified:
|
|
16029
|
-
|
|
17499
|
+
verified: transition.operation === "adopt_external_thread"
|
|
17500
|
+
? ["prepared", "verified", "uncertain"]
|
|
17501
|
+
: ["submitted", "verified", "uncertain"],
|
|
17502
|
+
committed: transition.operation === "adopt_external_thread"
|
|
17503
|
+
? ["verified", "uncertain"]
|
|
17504
|
+
: ["submitted", "verified", "uncertain"],
|
|
16030
17505
|
aborted: transition.reconciled_outcome === "before"
|
|
16031
17506
|
? ["dispatching", "submitted", "uncertain"]
|
|
16032
17507
|
: ["prepared", "dispatching"]
|
|
@@ -16035,6 +17510,8 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16035
17510
|
stringValue(ledger.transition_id) !== transition.transition_id ||
|
|
16036
17511
|
stringValue(ledger.generation_id) !== transition.transition_id ||
|
|
16037
17512
|
stringValue(ledger.operation) !== transition.operation ||
|
|
17513
|
+
stringValue(ledger.origin) !== transition.origin ||
|
|
17514
|
+
ledger.terminal_input_sent !== transition.terminal_input_sent ||
|
|
16038
17515
|
stringValue(ledger.terminal_id) !== transition.terminal_id ||
|
|
16039
17516
|
stringValue(ledger.agent) !== transition.agent ||
|
|
16040
17517
|
stringValue(ledger.workspace) !== transition.workspace ||
|
|
@@ -16080,6 +17557,16 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16080
17557
|
const adapter = createRuntimeTerminalAgentRegistry(options)
|
|
16081
17558
|
.require(terminal.agent);
|
|
16082
17559
|
const capability = adapter.probeThreadLifecycle?.(currentVersion);
|
|
17560
|
+
if (transition.operation === "adopt_external_thread") {
|
|
17561
|
+
if (currentVersion !== transition.adapter_version ||
|
|
17562
|
+
capability?.status !== "supported" ||
|
|
17563
|
+
transition.origin !== "human_observed" ||
|
|
17564
|
+
transition.terminal_input_sent !== false ||
|
|
17565
|
+
transition.command_fingerprint !== HUMAN_OBSERVED_HANDOFF_FINGERPRINT) {
|
|
17566
|
+
throw new Error("human-observed handoff adapter profile changed during recovery");
|
|
17567
|
+
}
|
|
17568
|
+
return;
|
|
17569
|
+
}
|
|
16083
17570
|
const operation = transition.operation === "resume_thread"
|
|
16084
17571
|
? {
|
|
16085
17572
|
kind: "resume_thread",
|
|
@@ -16104,6 +17591,49 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
|
|
|
16104
17591
|
!terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
|
|
16105
17592
|
throw new Error("verified after_binding no longer matches the terminal or pid");
|
|
16106
17593
|
}
|
|
17594
|
+
if (transition.operation === "adopt_external_thread") {
|
|
17595
|
+
if (transition.origin !== "human_observed" ||
|
|
17596
|
+
transition.terminal_input_sent !== false ||
|
|
17597
|
+
!transition.source_session_id ||
|
|
17598
|
+
!transition.before_binding) {
|
|
17599
|
+
throw new Error("human-observed handoff recovery evidence is incomplete");
|
|
17600
|
+
}
|
|
17601
|
+
const storeDir = storeDirFromOptions(options);
|
|
17602
|
+
const source = loadManagedSession(storeDir, transition.source_session_id);
|
|
17603
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
17604
|
+
options,
|
|
17605
|
+
agent: terminal.agent,
|
|
17606
|
+
pid: terminal.pid,
|
|
17607
|
+
cwd: terminal.terminalControl.currentPath,
|
|
17608
|
+
preferredSessionId: binding.native_thread_id,
|
|
17609
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
17610
|
+
sessionId: transition.before_native_thread_id,
|
|
17611
|
+
processUuid: transition.before_process_uuid,
|
|
17612
|
+
processBirth: transition.before_process_birth,
|
|
17613
|
+
rollout: transition.before_process_rollout,
|
|
17614
|
+
evidence: transition.before_binding.native_process.evidence
|
|
17615
|
+
}),
|
|
17616
|
+
allowedAdditionalIdentities: []
|
|
17617
|
+
});
|
|
17618
|
+
const observed = await observedExternalHandoffIdentity({
|
|
17619
|
+
options,
|
|
17620
|
+
terminal,
|
|
17621
|
+
sourceSession: source,
|
|
17622
|
+
resolvedIdentity: resolved
|
|
17623
|
+
});
|
|
17624
|
+
if (!observed.identity) {
|
|
17625
|
+
throw new Error("human-observed handoff target identity is unavailable during recovery");
|
|
17626
|
+
}
|
|
17627
|
+
const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
|
|
17628
|
+
if (exact.sessionId.toLowerCase() !== binding.native_thread_id.toLowerCase() ||
|
|
17629
|
+
exact.processUuid !== binding.native_process.process_uuid ||
|
|
17630
|
+
exact.processBirth !== binding.native_process.process_birth ||
|
|
17631
|
+
JSON.stringify(exact.rollout ?? null) !==
|
|
17632
|
+
JSON.stringify(binding.native_process.rollout ?? null)) {
|
|
17633
|
+
throw new Error("human-observed handoff target identity changed during recovery");
|
|
17634
|
+
}
|
|
17635
|
+
return;
|
|
17636
|
+
}
|
|
16107
17637
|
const bridge = createTerminalAgentBridge(options);
|
|
16108
17638
|
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
16109
17639
|
if (!status.reachable ||
|
|
@@ -16178,6 +17708,9 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
|
|
|
16178
17708
|
}
|
|
16179
17709
|
}
|
|
16180
17710
|
async function reconcileUncertainLifecycleTransition({ options, terminal, transition, storeDir, now }) {
|
|
17711
|
+
if (transition.operation === "adopt_external_thread") {
|
|
17712
|
+
throw new Error("uncertain human-observed handoff remains quarantined because recovery cannot send terminal input");
|
|
17713
|
+
}
|
|
16181
17714
|
const live = terminal.agent === "claude"
|
|
16182
17715
|
? await probeManualClaudeLifecycleRecoveryIdentity({
|
|
16183
17716
|
options,
|
|
@@ -16220,15 +17753,18 @@ async function reconcileUncertainLifecycleTransition({ options, terminal, transi
|
|
|
16220
17753
|
}, { expectedTransitionId: transition.transition_id });
|
|
16221
17754
|
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
16222
17755
|
}
|
|
16223
|
-
if (transition.operation
|
|
17756
|
+
if (!["resume_thread", "adopt_external_thread"].includes(transition.operation) ||
|
|
16224
17757
|
live.sessionId !== transition.target_native_thread_id) {
|
|
16225
17758
|
throw new Error("fresh native thread identity matches neither the recorded before identity " +
|
|
16226
17759
|
"nor a durably known exact after identity");
|
|
16227
17760
|
}
|
|
16228
|
-
if (
|
|
17761
|
+
if (transition.operation === "resume_thread" &&
|
|
17762
|
+
terminal.agent === "codex" &&
|
|
17763
|
+
!live.rollout) {
|
|
16229
17764
|
throw new Error("Codex resume recovery requires an exact live rollout incarnation");
|
|
16230
17765
|
}
|
|
16231
|
-
if (
|
|
17766
|
+
if (transition.operation === "resume_thread" &&
|
|
17767
|
+
terminal.agent === "codex") {
|
|
16232
17768
|
assertResumedCodexRolloutMatchesCandidate(live, transition.target_candidate_file_identity);
|
|
16233
17769
|
}
|
|
16234
17770
|
const targetAtPrepare = tryLoadManagedSession(storeDir, transition.target_session_id);
|
|
@@ -16516,7 +18052,9 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
|
|
|
16516
18052
|
? "no_rollout"
|
|
16517
18053
|
: "invalid"
|
|
16518
18054
|
: classifyCodexLifecyclePostcondition({
|
|
16519
|
-
operation: transition.operation
|
|
18055
|
+
operation: transition.operation === "resume_thread"
|
|
18056
|
+
? "resume_thread"
|
|
18057
|
+
: "new_thread",
|
|
16520
18058
|
parsedNativeThreadId: observed.nativeThreadId,
|
|
16521
18059
|
observationSucceeded: true,
|
|
16522
18060
|
observedIdentity: resolved,
|
|
@@ -16815,7 +18353,9 @@ function commitVerifiedLifecycleTransition(storeDir, transition, now) {
|
|
|
16815
18353
|
status: "bound",
|
|
16816
18354
|
binding: afterBinding,
|
|
16817
18355
|
lineage: targetExisting?.lineage ?? {
|
|
16818
|
-
created_by: transition.operation
|
|
18356
|
+
created_by: transition.operation === "adopt_external_thread"
|
|
18357
|
+
? "human_observed"
|
|
18358
|
+
: transition.operation,
|
|
16819
18359
|
previous_session_id: transition.source_session_id,
|
|
16820
18360
|
resumed_from_native_thread_id: transition.operation === "resume_thread"
|
|
16821
18361
|
? transition.target_native_thread_id
|
|
@@ -20065,7 +21605,7 @@ function usage() {
|
|
|
20065
21605
|
agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
|
|
20066
21606
|
agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
|
|
20067
21607
|
agent-knock-knock status [--turn <turn-id|selector>] [--conversation <selector>] [--store-dir <dir>] [--reconcile] [--trace]
|
|
20068
|
-
agent-knock-knock send [--session <session-id|selector>] [--conversation <selector>] --message <text> [--type task] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
|
|
21608
|
+
agent-knock-knock send [--session <session-id|selector>] [--conversation <selector>] --message <text> [--expected-terminal-token <token>] [--type task] [--agent-timeout-minutes <minutes>] [--agent-hard-timeout-minutes <minutes>]
|
|
20069
21609
|
agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
|
|
20070
21610
|
agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
|
|
20071
21611
|
agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
|
|
@@ -20078,7 +21618,7 @@ function usage() {
|
|
|
20078
21618
|
agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20079
21619
|
agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20080
21620
|
agent-knock-knock retry-callback [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20081
|
-
agent-knock-knock close [--turn <turn-id|selector>] [--conversation <selector>] [--reason <text>] [--expected-message-id <message-id> | --expected-transition-id <transition-id>]
|
|
21621
|
+
agent-knock-knock close [--turn <turn-id|selector>] [--conversation <selector>] [--reason <text>] [--expected-message-id <message-id> | --expected-transition-id <transition-id> | --expected-handoff-token <token>]
|
|
20082
21622
|
agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
|
|
20083
21623
|
agent-knock-knock doctor [--openclaw-bin <path>] [--tmux-bin <path>] [--herdr-bin <path>]
|
|
20084
21624
|
agent-knock-knock callback --state <file> --message-json <json> [--record-only]
|