@scotthuang/agent-knock-knock 0.12.1 → 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 +16 -0
- package/README.md +12 -9
- package/dist/src/cli-core.js +1281 -44
- package/dist/src/cli-core.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/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,11 +9247,26 @@ 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
|
});
|
|
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
|
+
});
|
|
8553
9270
|
if (!managedSession && currentNativeIdentity) {
|
|
8554
9271
|
managedSession = await reattachManagedSessionForNativeIdentity({
|
|
8555
9272
|
options,
|
|
@@ -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
|
}
|
|
@@ -10276,6 +11015,27 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10276
11015
|
try {
|
|
10277
11016
|
await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
|
|
10278
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,
|
|
10279
11039
|
async onTransportStage(event) {
|
|
10280
11040
|
const stageAt = cliNow().toISOString();
|
|
10281
11041
|
if (event.stage === "text_injected") {
|
|
@@ -10316,6 +11076,14 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10316
11076
|
previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
|
|
10317
11077
|
stringValue(previousDispatchLedger?.message_id)
|
|
10318
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
|
+
}
|
|
10319
11087
|
try {
|
|
10320
11088
|
appendEvent(logPath, {
|
|
10321
11089
|
ts: stageAt,
|
|
@@ -13263,9 +14031,185 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
|
|
|
13263
14031
|
}
|
|
13264
14032
|
}
|
|
13265
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
|
+
}
|
|
13266
14207
|
async function runClose(options) {
|
|
13267
14208
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
13268
14209
|
if (terminalConversation) {
|
|
14210
|
+
if (stringValue(options.expectedHandoffToken)) {
|
|
14211
|
+
throw new Error("--expected-handoff-token cannot be used with raw terminal close");
|
|
14212
|
+
}
|
|
13269
14213
|
await runTerminalDispatchClose({
|
|
13270
14214
|
options,
|
|
13271
14215
|
terminalConversation
|
|
@@ -13273,6 +14217,15 @@ async function runClose(options) {
|
|
|
13273
14217
|
return;
|
|
13274
14218
|
}
|
|
13275
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
|
+
}
|
|
13276
14229
|
const { statePath, logPath } = loaded;
|
|
13277
14230
|
const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
|
|
13278
14231
|
? loaded.conversation.native_session_takeover
|
|
@@ -13286,11 +14239,18 @@ async function runClose(options) {
|
|
|
13286
14239
|
releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
13287
14240
|
const conversation = loadState(statePath);
|
|
13288
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;
|
|
13289
14246
|
const closed = {
|
|
13290
14247
|
...conversation,
|
|
13291
14248
|
status: "closed",
|
|
13292
14249
|
closed_at: now,
|
|
13293
|
-
close_reason:
|
|
14250
|
+
close_reason: closeReason,
|
|
14251
|
+
...(handoffDisposition
|
|
14252
|
+
? { disposition: handoffDisposition }
|
|
14253
|
+
: {}),
|
|
13294
14254
|
updated_at: now
|
|
13295
14255
|
};
|
|
13296
14256
|
saveState(statePath, closed);
|
|
@@ -15330,6 +16290,7 @@ function terminalDispatchLedgerLooksLifecycle(ledger) {
|
|
|
15330
16290
|
ledger.transition_id !== undefined ||
|
|
15331
16291
|
ledger.operation === "new_thread" ||
|
|
15332
16292
|
ledger.operation === "resume_thread" ||
|
|
16293
|
+
ledger.operation === "adopt_external_thread" ||
|
|
15333
16294
|
ledger.adapter_version !== undefined ||
|
|
15334
16295
|
ledger.command_fingerprint !== undefined ||
|
|
15335
16296
|
ledger.target_session_id !== undefined ||
|
|
@@ -15345,6 +16306,8 @@ function lifecycleLedgerFields(transition, storeDir) {
|
|
|
15345
16306
|
generation_id: transition.transition_id,
|
|
15346
16307
|
transition_id: transition.transition_id,
|
|
15347
16308
|
operation: transition.operation,
|
|
16309
|
+
origin: transition.origin,
|
|
16310
|
+
terminal_input_sent: transition.terminal_input_sent,
|
|
15348
16311
|
terminal_id: transition.terminal_id,
|
|
15349
16312
|
agent: transition.agent,
|
|
15350
16313
|
workspace: transition.workspace,
|
|
@@ -15778,6 +16741,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
15778
16741
|
const dispatcherPid = Number(ledger.dispatcher_pid);
|
|
15779
16742
|
if (Number.isSafeInteger(dispatcherPid) &&
|
|
15780
16743
|
dispatcherPid > 1 &&
|
|
16744
|
+
dispatcherPid !== cliPid() &&
|
|
15781
16745
|
isProcessAlive(dispatcherPid)) {
|
|
15782
16746
|
return ledger;
|
|
15783
16747
|
}
|
|
@@ -16115,9 +17079,16 @@ function terminalSubmissionProofRank(status, lastProven) {
|
|
|
16115
17079
|
return 0;
|
|
16116
17080
|
}
|
|
16117
17081
|
async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
16118
|
-
|
|
17082
|
+
let ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
16119
17083
|
if (!ledger || ledger.status === "resolved") {
|
|
16120
|
-
|
|
17084
|
+
ledger = rebuildObservedHandoffLedgerFromTransition({
|
|
17085
|
+
options,
|
|
17086
|
+
terminal,
|
|
17087
|
+
previousLedger: ledger
|
|
17088
|
+
});
|
|
17089
|
+
if (!ledger) {
|
|
17090
|
+
return;
|
|
17091
|
+
}
|
|
16121
17092
|
}
|
|
16122
17093
|
if (!terminalDispatchLedgerLooksLifecycle(ledger)) {
|
|
16123
17094
|
return;
|
|
@@ -16136,6 +17107,175 @@ async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
|
16136
17107
|
"expected-transition-id recovery action");
|
|
16137
17108
|
}
|
|
16138
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
|
+
}
|
|
16139
17279
|
async function reconcileLifecycleDispatchLedger(options, terminal, ledger, authority = {
|
|
16140
17280
|
kind: "automatic"
|
|
16141
17281
|
}) {
|
|
@@ -16179,6 +17319,34 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
|
|
|
16179
17319
|
transition,
|
|
16180
17320
|
storeDir
|
|
16181
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
|
+
}
|
|
16182
17350
|
if (transition.status === "prepared" && ledger.status === "prepared") {
|
|
16183
17351
|
await assertNativeThreadHasExclusiveOwnership({
|
|
16184
17352
|
options,
|
|
@@ -16317,9 +17485,9 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
|
|
|
16317
17485
|
}
|
|
16318
17486
|
}
|
|
16319
17487
|
function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, transition, storeDir }) {
|
|
16320
|
-
const operationTarget = transition.operation === "
|
|
16321
|
-
?
|
|
16322
|
-
:
|
|
17488
|
+
const operationTarget = transition.operation === "new_thread"
|
|
17489
|
+
? undefined
|
|
17490
|
+
: transition.target_native_thread_id;
|
|
16323
17491
|
const ledgerControl = isRecord(ledger.terminal_control)
|
|
16324
17492
|
? ledger.terminal_control
|
|
16325
17493
|
: undefined;
|
|
@@ -16328,8 +17496,12 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16328
17496
|
dispatching: ["prepared", "dispatching", "uncertain"],
|
|
16329
17497
|
submitted: ["dispatching", "submitted", "uncertain"],
|
|
16330
17498
|
uncertain: ["dispatching", "submitted", "uncertain"],
|
|
16331
|
-
verified:
|
|
16332
|
-
|
|
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"],
|
|
16333
17505
|
aborted: transition.reconciled_outcome === "before"
|
|
16334
17506
|
? ["dispatching", "submitted", "uncertain"]
|
|
16335
17507
|
: ["prepared", "dispatching"]
|
|
@@ -16338,6 +17510,8 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16338
17510
|
stringValue(ledger.transition_id) !== transition.transition_id ||
|
|
16339
17511
|
stringValue(ledger.generation_id) !== transition.transition_id ||
|
|
16340
17512
|
stringValue(ledger.operation) !== transition.operation ||
|
|
17513
|
+
stringValue(ledger.origin) !== transition.origin ||
|
|
17514
|
+
ledger.terminal_input_sent !== transition.terminal_input_sent ||
|
|
16341
17515
|
stringValue(ledger.terminal_id) !== transition.terminal_id ||
|
|
16342
17516
|
stringValue(ledger.agent) !== transition.agent ||
|
|
16343
17517
|
stringValue(ledger.workspace) !== transition.workspace ||
|
|
@@ -16383,6 +17557,16 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16383
17557
|
const adapter = createRuntimeTerminalAgentRegistry(options)
|
|
16384
17558
|
.require(terminal.agent);
|
|
16385
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
|
+
}
|
|
16386
17570
|
const operation = transition.operation === "resume_thread"
|
|
16387
17571
|
? {
|
|
16388
17572
|
kind: "resume_thread",
|
|
@@ -16407,6 +17591,49 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
|
|
|
16407
17591
|
!terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
|
|
16408
17592
|
throw new Error("verified after_binding no longer matches the terminal or pid");
|
|
16409
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
|
+
}
|
|
16410
17637
|
const bridge = createTerminalAgentBridge(options);
|
|
16411
17638
|
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
16412
17639
|
if (!status.reachable ||
|
|
@@ -16481,6 +17708,9 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
|
|
|
16481
17708
|
}
|
|
16482
17709
|
}
|
|
16483
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
|
+
}
|
|
16484
17714
|
const live = terminal.agent === "claude"
|
|
16485
17715
|
? await probeManualClaudeLifecycleRecoveryIdentity({
|
|
16486
17716
|
options,
|
|
@@ -16523,15 +17753,18 @@ async function reconcileUncertainLifecycleTransition({ options, terminal, transi
|
|
|
16523
17753
|
}, { expectedTransitionId: transition.transition_id });
|
|
16524
17754
|
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
16525
17755
|
}
|
|
16526
|
-
if (transition.operation
|
|
17756
|
+
if (!["resume_thread", "adopt_external_thread"].includes(transition.operation) ||
|
|
16527
17757
|
live.sessionId !== transition.target_native_thread_id) {
|
|
16528
17758
|
throw new Error("fresh native thread identity matches neither the recorded before identity " +
|
|
16529
17759
|
"nor a durably known exact after identity");
|
|
16530
17760
|
}
|
|
16531
|
-
if (
|
|
17761
|
+
if (transition.operation === "resume_thread" &&
|
|
17762
|
+
terminal.agent === "codex" &&
|
|
17763
|
+
!live.rollout) {
|
|
16532
17764
|
throw new Error("Codex resume recovery requires an exact live rollout incarnation");
|
|
16533
17765
|
}
|
|
16534
|
-
if (
|
|
17766
|
+
if (transition.operation === "resume_thread" &&
|
|
17767
|
+
terminal.agent === "codex") {
|
|
16535
17768
|
assertResumedCodexRolloutMatchesCandidate(live, transition.target_candidate_file_identity);
|
|
16536
17769
|
}
|
|
16537
17770
|
const targetAtPrepare = tryLoadManagedSession(storeDir, transition.target_session_id);
|
|
@@ -16819,7 +18052,9 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
|
|
|
16819
18052
|
? "no_rollout"
|
|
16820
18053
|
: "invalid"
|
|
16821
18054
|
: classifyCodexLifecyclePostcondition({
|
|
16822
|
-
operation: transition.operation
|
|
18055
|
+
operation: transition.operation === "resume_thread"
|
|
18056
|
+
? "resume_thread"
|
|
18057
|
+
: "new_thread",
|
|
16823
18058
|
parsedNativeThreadId: observed.nativeThreadId,
|
|
16824
18059
|
observationSucceeded: true,
|
|
16825
18060
|
observedIdentity: resolved,
|
|
@@ -17118,7 +18353,9 @@ function commitVerifiedLifecycleTransition(storeDir, transition, now) {
|
|
|
17118
18353
|
status: "bound",
|
|
17119
18354
|
binding: afterBinding,
|
|
17120
18355
|
lineage: targetExisting?.lineage ?? {
|
|
17121
|
-
created_by: transition.operation
|
|
18356
|
+
created_by: transition.operation === "adopt_external_thread"
|
|
18357
|
+
? "human_observed"
|
|
18358
|
+
: transition.operation,
|
|
17122
18359
|
previous_session_id: transition.source_session_id,
|
|
17123
18360
|
resumed_from_native_thread_id: transition.operation === "resume_thread"
|
|
17124
18361
|
? transition.target_native_thread_id
|
|
@@ -20368,7 +21605,7 @@ function usage() {
|
|
|
20368
21605
|
agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
|
|
20369
21606
|
agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
|
|
20370
21607
|
agent-knock-knock status [--turn <turn-id|selector>] [--conversation <selector>] [--store-dir <dir>] [--reconcile] [--trace]
|
|
20371
|
-
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>]
|
|
20372
21609
|
agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
|
|
20373
21610
|
agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
|
|
20374
21611
|
agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
|
|
@@ -20381,7 +21618,7 @@ function usage() {
|
|
|
20381
21618
|
agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20382
21619
|
agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20383
21620
|
agent-knock-knock retry-callback [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20384
|
-
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>]
|
|
20385
21622
|
agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
|
|
20386
21623
|
agent-knock-knock doctor [--openclaw-bin <path>] [--tmux-bin <path>] [--herdr-bin <path>]
|
|
20387
21624
|
agent-knock-knock callback --state <file> --message-json <json> [--record-only]
|