@scotthuang/agent-knock-knock 0.12.1 → 0.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +12 -9
- package/dist/src/cli-core.js +1726 -79
- 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 +21 -3
- package/dist/src/terminal-agent-bridge.js +80 -21
- 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,17 +16,17 @@ 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";
|
|
26
26
|
import { associateTerminalEndpointEvidence, hasCanonicalTerminalEndpoint, sameTerminalControlEvidenceIncarnation, sameTerminalControlIncarnation, terminalControlEvidence, terminalControlEvidenceMatches, terminalEndpointFromControlRef, terminalEndpointIdentityFromEvidence, terminalEndpointIdentityKey, terminalLegacyRuntimeRoute, sameTerminalEndpointIdentity } from "./terminal-control-ref.js";
|
|
27
27
|
import { createProductionTerminalAgentRegistry } from "./terminal-agent-registry.js";
|
|
28
28
|
import { parseProcessElapsedSeconds, StaticTerminalProcessSource, SystemTerminalProcessSource } from "./terminal-process-source.js";
|
|
29
|
-
import { isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
|
|
29
|
+
import { isExactClaudeIdleComposer, isExactClaudeNativeInspectionIdleComposer, NativeInspectionSubmissionError, TerminalAgentBridge, TerminalInputNotStartedError } from "./terminal-agent-bridge.js";
|
|
30
30
|
import { evaluateApprovalPolicy } from "./approval-policy.js";
|
|
31
31
|
import { evaluateDoctorCapabilities, runDoctorCapabilityProbes } from "./doctor-capabilities.js";
|
|
32
32
|
import { runOpenClawChainDiagnostics } from "./openclaw-doctor.js";
|
|
@@ -2916,24 +2916,24 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
|
|
|
2916
2916
|
});
|
|
2917
2917
|
}
|
|
2918
2918
|
}
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2919
|
+
const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
|
|
2920
|
+
options,
|
|
2921
|
+
agent: session.agent,
|
|
2922
|
+
pid: session.pid,
|
|
2923
|
+
cwd: session.cwd ?? terminalControl.currentPath,
|
|
2924
|
+
preferredSessionId: codexIdentityContext?.preferredSessionId,
|
|
2925
|
+
allowedCompanionIdentity: codexIdentityContext?.companions.primary,
|
|
2926
|
+
allowedAdditionalIdentities: codexIdentityContext?.companions.additional
|
|
2927
|
+
});
|
|
2928
|
+
const nativeAgentIdentity = nativeIdentityObservation.status === "resolved"
|
|
2929
|
+
? nativeIdentityObservation.identity
|
|
2930
|
+
: undefined;
|
|
2931
|
+
if (nativeIdentityObservation.status === "unavailable") {
|
|
2932
2932
|
runtimeLog("warn", "terminal_native_session_identity_unavailable", {
|
|
2933
2933
|
agent: session.agent,
|
|
2934
2934
|
terminal_target: terminalControl.target,
|
|
2935
2935
|
pid: session.pid,
|
|
2936
|
-
error:
|
|
2936
|
+
error: nativeIdentityObservation.reason
|
|
2937
2937
|
});
|
|
2938
2938
|
}
|
|
2939
2939
|
let nativeProcessUuid = nativeAgentIdentity?.processUuid;
|
|
@@ -3002,24 +3002,24 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
|
|
|
3002
3002
|
const nativeInspectionHasBlockingTurn = listConversations(storeDirFromOptions(options)).some((turn) => isDiscoverableTmuxConversation(turn) &&
|
|
3003
3003
|
terminalControlsShareIncarnation(terminalControlForManagedConversation(turn), terminalControl) &&
|
|
3004
3004
|
SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
|
|
3005
|
-
let
|
|
3005
|
+
let automatedInputComposerReady = nativeInspectionComposerEmpty(session.agent, terminalState.screen_excerpt);
|
|
3006
3006
|
if (session.agent === "codex" &&
|
|
3007
|
-
terminalState.activity_state === "idle"
|
|
3007
|
+
(terminalState.activity_state === "idle" ||
|
|
3008
|
+
(terminalState.activity_state === "unknown" &&
|
|
3009
|
+
nativeIdentityObservation.status === "verified_absent")) &&
|
|
3008
3010
|
terminalState.approval_state.blocked !== true &&
|
|
3009
|
-
nativeInspectionCapability.status === "supported" &&
|
|
3010
|
-
nativeInspectionCapability.statusInspection === true &&
|
|
3011
3011
|
terminalControl.capabilities.includes("send_keys") &&
|
|
3012
3012
|
terminalControl.capabilities.includes("screen_status")) {
|
|
3013
3013
|
try {
|
|
3014
3014
|
const provider = createTerminalControlProvider(options);
|
|
3015
3015
|
const resolvedTerminal = await provider.resolve(provider.endpoint(terminalControl));
|
|
3016
3016
|
const styledScreen = await provider.capture(resolvedTerminal, { scrollbackLines: 40, preserveEscapes: true });
|
|
3017
|
-
|
|
3017
|
+
automatedInputComposerReady = codexStyledComposerEmpty(styledScreen);
|
|
3018
3018
|
}
|
|
3019
3019
|
catch {
|
|
3020
3020
|
// Advertising an input action is optional. The action itself repeats the
|
|
3021
3021
|
// same styled composer proof under the terminal lock before any input.
|
|
3022
|
-
|
|
3022
|
+
automatedInputComposerReady = false;
|
|
3023
3023
|
}
|
|
3024
3024
|
}
|
|
3025
3025
|
const entry = {
|
|
@@ -3040,6 +3040,14 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
|
|
|
3040
3040
|
native_agent_process_birth: nativeProcessBirth,
|
|
3041
3041
|
native_agent_rollout: nativeAgentIdentity?.rollout,
|
|
3042
3042
|
native_agent_identity_evidence: nativeProcessEvidence,
|
|
3043
|
+
native_agent_identity_observation: {
|
|
3044
|
+
status: nativeIdentityObservation.status,
|
|
3045
|
+
...(nativeIdentityObservation.status === "unavailable"
|
|
3046
|
+
? { reason: nativeIdentityObservation.reason }
|
|
3047
|
+
: nativeIdentityObservation.status === "verified_absent"
|
|
3048
|
+
? { evidence: nativeIdentityObservation.evidence }
|
|
3049
|
+
: {})
|
|
3050
|
+
},
|
|
3043
3051
|
agent_version: agentVersion,
|
|
3044
3052
|
native_thread_lifecycle: lifecycleCapability,
|
|
3045
3053
|
native_inspection: nativeInspectionCapability,
|
|
@@ -3050,6 +3058,10 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
|
|
|
3050
3058
|
approval_state: terminalState.approval_state,
|
|
3051
3059
|
activity_state: terminalState.activity_state,
|
|
3052
3060
|
activity_reason: terminalState.activity_reason,
|
|
3061
|
+
// Internal action-projection evidence. terminalFirstListProjection strips
|
|
3062
|
+
// this field after gating every automated-input action that can follow a
|
|
3063
|
+
// human native-thread switch.
|
|
3064
|
+
_automated_input_composer_ready: automatedInputComposerReady,
|
|
3053
3065
|
...(orphanedDispatch
|
|
3054
3066
|
? {
|
|
3055
3067
|
orphaned_terminal_dispatch: {
|
|
@@ -3080,7 +3092,8 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
|
|
|
3080
3092
|
codexLifecycleIncarnationAvailable,
|
|
3081
3093
|
native_inspect: nativeInspectionCapability.status === "supported" &&
|
|
3082
3094
|
nativeInspectionCapability.statusInspection === true &&
|
|
3083
|
-
|
|
3095
|
+
terminalState.activity_state === "idle" &&
|
|
3096
|
+
automatedInputComposerReady &&
|
|
3084
3097
|
terminalControl.capabilities.includes("send_keys") &&
|
|
3085
3098
|
terminalControl.capabilities.includes("screen_status") &&
|
|
3086
3099
|
(session.agent === "codex"
|
|
@@ -3106,6 +3119,7 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3106
3119
|
return control ? [control] : [];
|
|
3107
3120
|
});
|
|
3108
3121
|
const projectedTerminals = terminals.map((terminal) => {
|
|
3122
|
+
const { _automated_input_composer_ready: automatedInputComposerReady, ...publicTerminal } = terminal;
|
|
3109
3123
|
const terminalControl = isRecord(terminal.terminal_control)
|
|
3110
3124
|
? terminal.terminal_control
|
|
3111
3125
|
: undefined;
|
|
@@ -3220,11 +3234,120 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3220
3234
|
const soleBindingConflict = conflictingBoundSessionClaims.length === 1
|
|
3221
3235
|
? conflictingBoundSessionClaims[0]
|
|
3222
3236
|
: undefined;
|
|
3237
|
+
const externalHandoffDetected = conflictingBoundSessionClaims.some(({ kind }) => kind === "live_external_thread_change");
|
|
3223
3238
|
const conflictingSessionRevision = Number(soleBindingConflict?.session.revision);
|
|
3224
3239
|
const conflictingSessionTurns = soleBindingConflict
|
|
3225
3240
|
? managedTurnsForSession(storeDir, soleBindingConflict.session.session_id)
|
|
3226
3241
|
: [];
|
|
3227
3242
|
const expectedTerminalToken = stringValue(terminal.lifecycle_binding_token);
|
|
3243
|
+
const externalHandoffNativeThreadId = stringValue(terminal.native_agent_status_card_session_id) ?? stringValue(terminal.native_agent_session_id);
|
|
3244
|
+
const resolvedNativeThreadId = stringValue(terminal.native_agent_session_id);
|
|
3245
|
+
const externalHandoffTerminalToken = terminalControl &&
|
|
3246
|
+
externalHandoffNativeThreadId &&
|
|
3247
|
+
isExactNativeThreadId(externalHandoffNativeThreadId)
|
|
3248
|
+
? unmanagedTerminalBindingToken({
|
|
3249
|
+
terminalId: stringValue(terminal.id),
|
|
3250
|
+
terminalControl,
|
|
3251
|
+
agent: terminal.agent,
|
|
3252
|
+
pid: Number(terminal.pid),
|
|
3253
|
+
workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
|
|
3254
|
+
nativeThreadId: externalHandoffNativeThreadId,
|
|
3255
|
+
processUuid: stringValue(terminal.native_agent_process_uuid),
|
|
3256
|
+
processBirth: stringValue(terminal.native_agent_process_birth),
|
|
3257
|
+
rollout: resolvedNativeThreadId === externalHandoffNativeThreadId &&
|
|
3258
|
+
isRecord(terminal.native_agent_rollout)
|
|
3259
|
+
? terminal.native_agent_rollout
|
|
3260
|
+
: undefined
|
|
3261
|
+
})
|
|
3262
|
+
: undefined;
|
|
3263
|
+
const externalHandoffTarget = soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3264
|
+
externalHandoffNativeThreadId &&
|
|
3265
|
+
isExactNativeThreadId(externalHandoffNativeThreadId)
|
|
3266
|
+
? observedHandoffTargetResolution({
|
|
3267
|
+
storeDir,
|
|
3268
|
+
agent: terminal.agent,
|
|
3269
|
+
workspace: terminal.workspace ?? terminal.cwd ?? cliCwd(),
|
|
3270
|
+
nativeThreadId: externalHandoffNativeThreadId.toLowerCase(),
|
|
3271
|
+
sourceSessionId: soleBindingConflict.session.session_id
|
|
3272
|
+
})
|
|
3273
|
+
: undefined;
|
|
3274
|
+
const externalHandoffSnapshotToken = externalHandoffTerminalToken &&
|
|
3275
|
+
soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3276
|
+
externalHandoffTarget?.status === "eligible"
|
|
3277
|
+
? humanObservedHandoffBindingToken({
|
|
3278
|
+
terminal_token: externalHandoffTerminalToken,
|
|
3279
|
+
source_session_id: soleBindingConflict.session.session_id,
|
|
3280
|
+
source_revision: managedSessionRevision(soleBindingConflict.session),
|
|
3281
|
+
source_binding_token: managedSessionBindingToken(soleBindingConflict.session),
|
|
3282
|
+
target: externalHandoffTarget.snapshot
|
|
3283
|
+
})
|
|
3284
|
+
: undefined;
|
|
3285
|
+
const blockingHandoffTurns = conflictingSessionTurns.filter((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status));
|
|
3286
|
+
const nativeIdentityObservation = isRecord(terminal.native_agent_identity_observation)
|
|
3287
|
+
? terminal.native_agent_identity_observation
|
|
3288
|
+
: undefined;
|
|
3289
|
+
const verifiedEmptyProcessUuid = stringValue(terminal.native_agent_process_uuid);
|
|
3290
|
+
const verifiedEmptyProcessBirth = stringValue(terminal.native_agent_process_birth);
|
|
3291
|
+
const verifiedEmptyWorkspace = stringValue(terminal.workspace ?? terminal.cwd);
|
|
3292
|
+
const verifiedEmptySourceNativeThreadId = stringValue(soleBindingConflict?.session.binding?.native_thread_id)?.toLowerCase();
|
|
3293
|
+
const verifiedEmptySourceActiveElsewhere = Boolean(verifiedEmptySourceNativeThreadId &&
|
|
3294
|
+
terminals.some((candidate) => candidate !== terminal &&
|
|
3295
|
+
candidate.agent === "codex" &&
|
|
3296
|
+
stringValue(candidate.native_agent_session_id)?.toLowerCase() ===
|
|
3297
|
+
verifiedEmptySourceNativeThreadId));
|
|
3298
|
+
const verifiedEmptyRawSendAction = isRecord(rawActions.send)
|
|
3299
|
+
? rawActions.send
|
|
3300
|
+
: {
|
|
3301
|
+
tool: "agent_knock_knock_send",
|
|
3302
|
+
arguments: { selector: stringValue(terminal.id) },
|
|
3303
|
+
missing_required: ["request"]
|
|
3304
|
+
};
|
|
3305
|
+
const verifiedEmptyCodexHandoffEligible = Boolean(mutationsAllowed &&
|
|
3306
|
+
terminal.agent === "codex" &&
|
|
3307
|
+
terminalControl &&
|
|
3308
|
+
discoveredOwnership.state === "none" &&
|
|
3309
|
+
unresolvedSessionClaims.length === 0 &&
|
|
3310
|
+
matchingSessions.length === 0 &&
|
|
3311
|
+
soleBindingConflict?.kind === "unverifiable" &&
|
|
3312
|
+
nativeIdentityObservation?.status === "verified_absent" &&
|
|
3313
|
+
verifiedEmptyProcessUuid &&
|
|
3314
|
+
verifiedEmptyProcessBirth &&
|
|
3315
|
+
verifiedEmptyWorkspace &&
|
|
3316
|
+
exactBoundCodexSourceForVerifiedEmptyHandoff({
|
|
3317
|
+
session: soleBindingConflict.session,
|
|
3318
|
+
terminalId: String(terminal.id),
|
|
3319
|
+
terminalControl,
|
|
3320
|
+
pid: Number(terminal.pid),
|
|
3321
|
+
workspace: verifiedEmptyWorkspace,
|
|
3322
|
+
processUuid: verifiedEmptyProcessUuid,
|
|
3323
|
+
processBirth: verifiedEmptyProcessBirth
|
|
3324
|
+
}) &&
|
|
3325
|
+
["idle", "unknown"].includes(String(terminal.activity_state)) &&
|
|
3326
|
+
automatedInputComposerReady === true &&
|
|
3327
|
+
!(isRecord(terminal.approval_state) &&
|
|
3328
|
+
terminal.approval_state.blocked === true) &&
|
|
3329
|
+
blockingHandoffTurns.length === 0 &&
|
|
3330
|
+
!managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
|
|
3331
|
+
!verifiedEmptySourceActiveElsewhere &&
|
|
3332
|
+
terminal.orphaned_terminal_dispatch === undefined &&
|
|
3333
|
+
terminalControl?.capabilities.includes("send_keys") &&
|
|
3334
|
+
terminalControl.capabilities.includes("screen_status"));
|
|
3335
|
+
const verifiedEmptyCodexSnapshotToken = verifiedEmptyCodexHandoffEligible &&
|
|
3336
|
+
terminalControl &&
|
|
3337
|
+
soleBindingConflict &&
|
|
3338
|
+
verifiedEmptyProcessUuid &&
|
|
3339
|
+
verifiedEmptyProcessBirth &&
|
|
3340
|
+
verifiedEmptyWorkspace
|
|
3341
|
+
? verifiedEmptyCodexHandoffToken({
|
|
3342
|
+
terminalId: String(terminal.id),
|
|
3343
|
+
terminalControl,
|
|
3344
|
+
pid: Number(terminal.pid),
|
|
3345
|
+
workspace: verifiedEmptyWorkspace,
|
|
3346
|
+
processUuid: verifiedEmptyProcessUuid,
|
|
3347
|
+
processBirth: verifiedEmptyProcessBirth,
|
|
3348
|
+
sourceSession: soleBindingConflict.session
|
|
3349
|
+
})
|
|
3350
|
+
: undefined;
|
|
3228
3351
|
const reconcileBindingAction = mutationsAllowed &&
|
|
3229
3352
|
discoveredOwnership.state === "none" &&
|
|
3230
3353
|
unresolvedSessionClaims.length === 0 &&
|
|
@@ -3251,6 +3374,66 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3251
3374
|
requires_user_intent: true
|
|
3252
3375
|
}
|
|
3253
3376
|
: undefined;
|
|
3377
|
+
const externalHandoffAdoptable = Boolean(mutationsAllowed &&
|
|
3378
|
+
discoveredOwnership.state === "none" &&
|
|
3379
|
+
unresolvedSessionClaims.length === 0 &&
|
|
3380
|
+
soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3381
|
+
terminal.activity_state === "idle" &&
|
|
3382
|
+
automatedInputComposerReady === true &&
|
|
3383
|
+
!(isRecord(terminal.approval_state) &&
|
|
3384
|
+
terminal.approval_state.blocked === true) &&
|
|
3385
|
+
!conflictingSessionTurns.some((turn) => SESSION_SEND_BLOCKING_STATUSES.has(turn.status)) &&
|
|
3386
|
+
!managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
|
|
3387
|
+
externalHandoffTarget?.status === "eligible" &&
|
|
3388
|
+
isRecord(rawActions.send) &&
|
|
3389
|
+
Boolean(externalHandoffSnapshotToken));
|
|
3390
|
+
const handoffDecisionTurn = mutationsAllowed &&
|
|
3391
|
+
soleBindingConflict?.kind === "live_external_thread_change" &&
|
|
3392
|
+
externalHandoffTarget?.status === "eligible" &&
|
|
3393
|
+
terminal.activity_state === "idle" &&
|
|
3394
|
+
!(isRecord(terminal.approval_state) &&
|
|
3395
|
+
terminal.approval_state.blocked === true) &&
|
|
3396
|
+
!managedSessionHasUnresolvedNativeTransition(storeDir, soleBindingConflict.session) &&
|
|
3397
|
+
blockingHandoffTurns.length === 1
|
|
3398
|
+
? blockingHandoffTurns[0]
|
|
3399
|
+
: undefined;
|
|
3400
|
+
const handoffDecisionToken = handoffDecisionTurn &&
|
|
3401
|
+
externalHandoffSnapshotToken &&
|
|
3402
|
+
terminalControl
|
|
3403
|
+
? activeTurnHandoffDecisionToken({
|
|
3404
|
+
handoffToken: externalHandoffSnapshotToken,
|
|
3405
|
+
turn: handoffDecisionTurn,
|
|
3406
|
+
ledger: loadTerminalBridgeDispatchLedger(terminalControl)
|
|
3407
|
+
})
|
|
3408
|
+
: undefined;
|
|
3409
|
+
const handoffDecision = handoffDecisionTurn &&
|
|
3410
|
+
handoffDecisionToken &&
|
|
3411
|
+
externalHandoffNativeThreadId
|
|
3412
|
+
? {
|
|
3413
|
+
kind: "active_turn_requires_decision",
|
|
3414
|
+
source_session_id: soleBindingConflict?.session.session_id,
|
|
3415
|
+
source_turn_id: turnIdForConversation(handoffDecisionTurn),
|
|
3416
|
+
live_native_thread_id: externalHandoffNativeThreadId,
|
|
3417
|
+
choices: {
|
|
3418
|
+
take_over_current: {
|
|
3419
|
+
action: {
|
|
3420
|
+
tool: "agent_knock_knock_close",
|
|
3421
|
+
arguments: {
|
|
3422
|
+
turn_id: turnIdForConversation(handoffDecisionTurn),
|
|
3423
|
+
reason: "superseded_by_human_context_switch",
|
|
3424
|
+
expected_handoff_token: handoffDecisionToken
|
|
3425
|
+
},
|
|
3426
|
+
requires_explicit_user_confirmation: true
|
|
3427
|
+
},
|
|
3428
|
+
after: "refresh list and use its follow-current send"
|
|
3429
|
+
},
|
|
3430
|
+
keep_source: {
|
|
3431
|
+
effect: "no Store or terminal mutation",
|
|
3432
|
+
after: "restore source native thread in the Codex/Claude TUI, then refresh list"
|
|
3433
|
+
}
|
|
3434
|
+
}
|
|
3435
|
+
}
|
|
3436
|
+
: undefined;
|
|
3254
3437
|
const terminalCanAcceptSend = ownership.state === "none" && isRecord(sessionAwareRawActions.send);
|
|
3255
3438
|
if (ownership.state === "current" &&
|
|
3256
3439
|
!allRelated.some((conversation) => conversation.conversation_id === ownership.conversation.conversation_id)) {
|
|
@@ -3339,6 +3522,32 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3339
3522
|
: ownership.state === "conflict"
|
|
3340
3523
|
? {
|
|
3341
3524
|
...safeTerminalActionsDuringConflict(sessionAwareRawActions),
|
|
3525
|
+
...(externalHandoffAdoptable
|
|
3526
|
+
? {
|
|
3527
|
+
send: {
|
|
3528
|
+
...rawActions.send,
|
|
3529
|
+
arguments: {
|
|
3530
|
+
...(isRecord(rawActions.send.arguments)
|
|
3531
|
+
? rawActions.send.arguments
|
|
3532
|
+
: {}),
|
|
3533
|
+
expected_terminal_token: externalHandoffSnapshotToken
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
: {}),
|
|
3538
|
+
...(verifiedEmptyCodexSnapshotToken
|
|
3539
|
+
? {
|
|
3540
|
+
send: {
|
|
3541
|
+
...verifiedEmptyRawSendAction,
|
|
3542
|
+
arguments: {
|
|
3543
|
+
...(isRecord(verifiedEmptyRawSendAction.arguments)
|
|
3544
|
+
? verifiedEmptyRawSendAction.arguments
|
|
3545
|
+
: {}),
|
|
3546
|
+
expected_terminal_token: verifiedEmptyCodexSnapshotToken
|
|
3547
|
+
}
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
: {}),
|
|
3342
3551
|
...(reconcileBindingAction
|
|
3343
3552
|
? { reconcile_binding: reconcileBindingAction }
|
|
3344
3553
|
: {})
|
|
@@ -3366,8 +3575,19 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3366
3575
|
lifecycle_binding_token: managedSessionBindingToken(authoritativeSession)
|
|
3367
3576
|
}
|
|
3368
3577
|
: {};
|
|
3578
|
+
const publicManagementConflict = ownership.state === "conflict" && verifiedEmptyCodexSnapshotToken
|
|
3579
|
+
? {
|
|
3580
|
+
...ownership.conflict,
|
|
3581
|
+
kind: "verified_empty_native_session",
|
|
3582
|
+
reason: "the previously bound Codex rollout is conclusively closed " +
|
|
3583
|
+
"while the same terminal process is at an exact empty prompt",
|
|
3584
|
+
recovery: "use only the exact snapshot-bound send action listed for this terminal"
|
|
3585
|
+
}
|
|
3586
|
+
: ownership.state === "conflict"
|
|
3587
|
+
? ownership.conflict
|
|
3588
|
+
: undefined;
|
|
3369
3589
|
return {
|
|
3370
|
-
...
|
|
3590
|
+
...publicTerminal,
|
|
3371
3591
|
...authoritativeIdentity,
|
|
3372
3592
|
management_state: ownership.state === "conflict"
|
|
3373
3593
|
? "conflict"
|
|
@@ -3375,8 +3595,24 @@ function terminalFirstListProjection({ storeDir, terminals, managedSessions, ses
|
|
|
3375
3595
|
? "managed"
|
|
3376
3596
|
: "unmanaged",
|
|
3377
3597
|
...(ownership.state === "conflict"
|
|
3378
|
-
? { management_conflict:
|
|
3598
|
+
? { management_conflict: publicManagementConflict }
|
|
3599
|
+
: {}),
|
|
3600
|
+
...(externalHandoffDetected
|
|
3601
|
+
? {
|
|
3602
|
+
handoff_state: externalHandoffAdoptable
|
|
3603
|
+
? "external_handoff_adoptable"
|
|
3604
|
+
: "external_handoff_blocked",
|
|
3605
|
+
...(automatedInputComposerReady !== true
|
|
3606
|
+
? {
|
|
3607
|
+
handoff_blocked_reason: "the current terminal composer is not an exact empty idle frame"
|
|
3608
|
+
}
|
|
3609
|
+
: {})
|
|
3610
|
+
}
|
|
3611
|
+
: {}),
|
|
3612
|
+
...(verifiedEmptyCodexSnapshotToken
|
|
3613
|
+
? { handoff_state: "verified_empty_native_session_adoptable" }
|
|
3379
3614
|
: {}),
|
|
3615
|
+
...(handoffDecision ? { handoff_decision: handoffDecision } : {}),
|
|
3380
3616
|
managed: management,
|
|
3381
3617
|
available_actions: availableActions
|
|
3382
3618
|
};
|
|
@@ -3558,8 +3794,10 @@ function managedBindingConflictKindForLiveTerminalEntry({ storeDir, session, ter
|
|
|
3558
3794
|
statusCardThreadId.toLowerCase()) {
|
|
3559
3795
|
return relationship === "same" &&
|
|
3560
3796
|
isExactNativeThreadId(liveNativeThreadId) &&
|
|
3561
|
-
liveNativeThreadId.toLowerCase() ===
|
|
3562
|
-
statusCardThreadId.toLowerCase()
|
|
3797
|
+
(liveNativeThreadId.toLowerCase() ===
|
|
3798
|
+
statusCardThreadId.toLowerCase() ||
|
|
3799
|
+
liveNativeThreadId.toLowerCase() ===
|
|
3800
|
+
binding.native_thread_id.toLowerCase())
|
|
3563
3801
|
? "live_external_thread_change"
|
|
3564
3802
|
: "unverifiable";
|
|
3565
3803
|
}
|
|
@@ -4073,15 +4311,15 @@ function managedListApprovalState(conversation) {
|
|
|
4073
4311
|
}
|
|
4074
4312
|
function listActionContracts() {
|
|
4075
4313
|
return {
|
|
4076
|
-
version:
|
|
4314
|
+
version: 9,
|
|
4077
4315
|
instructions: [
|
|
4078
|
-
"Treat terminals[] as the primary resource and use only actions present in available_actions.",
|
|
4316
|
+
"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
4317
|
"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
4318
|
"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
4319
|
"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
|
|
4320
|
+
"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. A conclusively ended Codex rollout may expose the same snapshot-bound send only after AKK proves zero current rollout and an exact empty composer; it detaches the ended Session and creates an isolated virgin Session. Other binding conflicts remain fail-closed and may expose only exact low-level reconcile_binding recovery.",
|
|
4083
4321
|
"List resumable threads before resume; use only a complete native_thread_id and the action returned for that candidate.",
|
|
4084
|
-
"
|
|
4322
|
+
"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
4323
|
"Use respond only for an in-flight turn that is explicitly waiting for OpenClaw.",
|
|
4086
4324
|
"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
4325
|
"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 +4350,23 @@ function listActionContracts() {
|
|
|
4112
4350
|
available_actions: {
|
|
4113
4351
|
meaning: "currently_safe_actions",
|
|
4114
4352
|
authoritative_for_tool_calls: true
|
|
4353
|
+
},
|
|
4354
|
+
native_agent_identity_observation: {
|
|
4355
|
+
meaning: "the latest bounded native identity probe; verified_absent is distinct from an unavailable resolver",
|
|
4356
|
+
authoritative_for_tool_calls: false
|
|
4357
|
+
},
|
|
4358
|
+
management_conflict: {
|
|
4359
|
+
verified_empty_native_session: "a previously bound Codex rollout is conclusively closed; only the exact snapshot-bound send in available_actions may detach it and create an isolated virgin Session"
|
|
4360
|
+
},
|
|
4361
|
+
handoff_state: {
|
|
4362
|
+
meaning: "why a conflict-scoped follow-current send is available or blocked",
|
|
4363
|
+
verified_empty_native_session_adoptable: "the exact current terminal snapshot permits the listed conflict send; availability is revalidated before terminal input"
|
|
4364
|
+
},
|
|
4365
|
+
handoff_decision: {
|
|
4366
|
+
meaning: "an explicit human choice required before superseding an active source Turn",
|
|
4367
|
+
authoritative_action_path: "terminals[].handoff_decision.choices.take_over_current.action",
|
|
4368
|
+
requires_explicit_user_confirmation: true,
|
|
4369
|
+
after_success: "refresh list before using a follow-current send action"
|
|
4115
4370
|
}
|
|
4116
4371
|
},
|
|
4117
4372
|
actions: {
|
|
@@ -4123,13 +4378,14 @@ function listActionContracts() {
|
|
|
4123
4378
|
required: ["request"],
|
|
4124
4379
|
optional: [
|
|
4125
4380
|
"selector",
|
|
4381
|
+
"expected_terminal_token",
|
|
4126
4382
|
"type",
|
|
4127
4383
|
"idleTimeoutMinutes",
|
|
4128
4384
|
"agentTimeoutMinutes",
|
|
4129
4385
|
"agentHardTimeoutMinutes"
|
|
4130
4386
|
],
|
|
4131
4387
|
unsupported: ["timeoutSeconds"],
|
|
4132
|
-
ordinary_use: "Create a new managed turn in the
|
|
4388
|
+
ordinary_use: "Create a new managed turn in the exact Session. A live terminal selector can attach an unmanaged pane, adopt one verified human-selected native context, or use the exact listed conflict send to detach a verified-empty Codex source before an isolated virgin attach; an explicit session_id never follows the pane."
|
|
4133
4389
|
},
|
|
4134
4390
|
new_thread: {
|
|
4135
4391
|
tool: "agent_knock_knock_new_thread",
|
|
@@ -4240,9 +4496,11 @@ function listActionContracts() {
|
|
|
4240
4496
|
optional: [
|
|
4241
4497
|
"reason",
|
|
4242
4498
|
"expected_message_id",
|
|
4243
|
-
"expected_transition_id"
|
|
4499
|
+
"expected_transition_id",
|
|
4500
|
+
"expected_handoff_token"
|
|
4244
4501
|
],
|
|
4245
|
-
requires_explicit_user_confirmation: true
|
|
4502
|
+
requires_explicit_user_confirmation: true,
|
|
4503
|
+
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
4504
|
}
|
|
4247
4505
|
}
|
|
4248
4506
|
};
|
|
@@ -4413,11 +4671,18 @@ function availableListActions(entry, { conversation } = {}) {
|
|
|
4413
4671
|
return actions;
|
|
4414
4672
|
}
|
|
4415
4673
|
async function resolveConversationSelectorOption(commandName, options) {
|
|
4674
|
+
const sendOperation = commandName === "send";
|
|
4675
|
+
if (sendOperation && stringValue(options.expectedTerminalToken)) {
|
|
4676
|
+
// Selector resolution may replace an alias (or an omitted selector) with a
|
|
4677
|
+
// discovered full terminal id. Preserve the caller's actual authority so
|
|
4678
|
+
// the handoff token fence cannot mistake that convenience resolution for
|
|
4679
|
+
// an exact selector supplied by the caller.
|
|
4680
|
+
originalExpectedTerminalSelector.set(options, stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim());
|
|
4681
|
+
}
|
|
4416
4682
|
if (!SESSION_SELECTOR_COMMANDS.has(String(commandName ?? "")) ||
|
|
4417
4683
|
options.state) {
|
|
4418
4684
|
return;
|
|
4419
4685
|
}
|
|
4420
|
-
const sendOperation = commandName === "send";
|
|
4421
4686
|
const supplied = stringValue(sendOperation
|
|
4422
4687
|
? options.session ?? options.conversation ?? options.conversationId
|
|
4423
4688
|
: options.turn ?? options.conversation ?? options.conversationId)?.trim();
|
|
@@ -4859,7 +5124,10 @@ function codexKnownBeforeIdentityForManagedSession({ storeDir, session, requireN
|
|
|
4859
5124
|
}
|
|
4860
5125
|
const after = transition.after_binding;
|
|
4861
5126
|
if (transition.status !== "committed" ||
|
|
4862
|
-
(requireNewThread &&
|
|
5127
|
+
(requireNewThread && ![
|
|
5128
|
+
"new_thread",
|
|
5129
|
+
"adopt_external_thread"
|
|
5130
|
+
].includes(transition.operation)) ||
|
|
4863
5131
|
transition.target_session_id !== session.session_id ||
|
|
4864
5132
|
!after ||
|
|
4865
5133
|
after.binding_id !== binding.binding_id ||
|
|
@@ -4911,7 +5179,7 @@ function codexLingeringBeforeIdentityMatchesSession({ storeDir, session, identit
|
|
|
4911
5179
|
}
|
|
4912
5180
|
const after = transition.after_binding;
|
|
4913
5181
|
if (transition.status !== "committed" ||
|
|
4914
|
-
transition.operation
|
|
5182
|
+
!["new_thread", "adopt_external_thread"].includes(transition.operation) ||
|
|
4915
5183
|
transition.target_session_id !== session.session_id ||
|
|
4916
5184
|
!after ||
|
|
4917
5185
|
after.binding_id !== binding.binding_id ||
|
|
@@ -5506,6 +5774,764 @@ async function reattachManagedSessionForNativeIdentity({ options, terminal, iden
|
|
|
5506
5774
|
updated_at: now.toISOString()
|
|
5507
5775
|
}, { expectedRevision: managedSessionRevision(existing) });
|
|
5508
5776
|
}
|
|
5777
|
+
const HUMAN_OBSERVED_HANDOFF_FINGERPRINT = nativeThreadCommandFingerprint("adopt_external_thread:human_observed:no_terminal_input:v1");
|
|
5778
|
+
function exactBoundCodexSourceForVerifiedEmptyHandoff({ session, terminalId, terminalControl, pid, workspace, processUuid, processBirth }) {
|
|
5779
|
+
const binding = session.binding;
|
|
5780
|
+
return Boolean(session.agent === "codex" &&
|
|
5781
|
+
session.status === "bound" &&
|
|
5782
|
+
binding &&
|
|
5783
|
+
isExactNativeThreadId(binding.native_thread_id) &&
|
|
5784
|
+
isCompleteNativeRollout(binding.native_process.rollout) &&
|
|
5785
|
+
binding.native_process.process_uuid &&
|
|
5786
|
+
binding.native_process.process_birth &&
|
|
5787
|
+
binding.native_process.pid === pid &&
|
|
5788
|
+
terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminalId, terminalControl) &&
|
|
5789
|
+
workspace &&
|
|
5790
|
+
path.resolve(session.workspace) === path.resolve(workspace) &&
|
|
5791
|
+
processIncarnationRelationship({
|
|
5792
|
+
binding,
|
|
5793
|
+
livePid: pid,
|
|
5794
|
+
liveProcessUuid: processUuid,
|
|
5795
|
+
liveProcessBirth: processBirth
|
|
5796
|
+
}) === "same");
|
|
5797
|
+
}
|
|
5798
|
+
function verifiedEmptyCodexHandoffToken({ terminalId, terminalControl, pid, workspace, processUuid, processBirth, sourceSession }) {
|
|
5799
|
+
const terminalToken = unmanagedTerminalBindingToken({
|
|
5800
|
+
terminalId,
|
|
5801
|
+
terminalControl,
|
|
5802
|
+
agent: "codex",
|
|
5803
|
+
pid,
|
|
5804
|
+
workspace,
|
|
5805
|
+
processUuid,
|
|
5806
|
+
processBirth
|
|
5807
|
+
});
|
|
5808
|
+
return createHash("sha256")
|
|
5809
|
+
.update(JSON.stringify({
|
|
5810
|
+
version: 1,
|
|
5811
|
+
kind: "verified_empty_codex_handoff",
|
|
5812
|
+
terminal_token: terminalToken,
|
|
5813
|
+
source_session_id: sourceSession.session_id,
|
|
5814
|
+
source_revision: managedSessionRevision(sourceSession),
|
|
5815
|
+
source_binding_token: managedSessionBindingToken(sourceSession),
|
|
5816
|
+
observation: "verified_absent"
|
|
5817
|
+
}))
|
|
5818
|
+
.digest("hex");
|
|
5819
|
+
}
|
|
5820
|
+
async function assertVerifiedEmptyCodexHandoffBoundary({ options, terminal, sourceSession, expectedSourceStatus, requireNoDispatch, requireEmptyComposer = true }) {
|
|
5821
|
+
const currentSource = loadManagedSession(storeDirFromOptions(options), sourceSession.session_id);
|
|
5822
|
+
if (currentSource.status !== expectedSourceStatus ||
|
|
5823
|
+
currentSource.revision !== sourceSession.revision ||
|
|
5824
|
+
managedSessionBindingToken(currentSource) !==
|
|
5825
|
+
managedSessionBindingToken(sourceSession)) {
|
|
5826
|
+
throw new Error("the verified-empty source Session changed; refresh AKK list");
|
|
5827
|
+
}
|
|
5828
|
+
const liveIncarnation = codexProcessIncarnationForPid(terminal.pid);
|
|
5829
|
+
if (!exactBoundCodexSourceForVerifiedEmptyHandoff({
|
|
5830
|
+
session: {
|
|
5831
|
+
...currentSource,
|
|
5832
|
+
// The exact source binding remains authoritative after the monotonic
|
|
5833
|
+
// detach; only the Session status changes.
|
|
5834
|
+
status: "bound"
|
|
5835
|
+
},
|
|
5836
|
+
terminalId: terminal.conversationId,
|
|
5837
|
+
terminalControl: terminal.terminalControl,
|
|
5838
|
+
pid: terminal.pid,
|
|
5839
|
+
workspace: terminal.terminalControl.currentPath,
|
|
5840
|
+
processUuid: liveIncarnation.processUuid,
|
|
5841
|
+
processBirth: liveIncarnation.processBirth
|
|
5842
|
+
})) {
|
|
5843
|
+
throw new Error("the verified-empty source binding no longer matches the terminal");
|
|
5844
|
+
}
|
|
5845
|
+
const observation = await observeCurrentNativeAgentSessionIdentity({
|
|
5846
|
+
options,
|
|
5847
|
+
agent: "codex",
|
|
5848
|
+
pid: terminal.pid,
|
|
5849
|
+
cwd: terminal.terminalControl.currentPath
|
|
5850
|
+
});
|
|
5851
|
+
if (observation.status === "unavailable") {
|
|
5852
|
+
throw new Error(`Codex native identity observation is unavailable: ${observation.reason}`);
|
|
5853
|
+
}
|
|
5854
|
+
if (observation.status !== "verified_absent") {
|
|
5855
|
+
throw new Error(`Codex materialized native thread ${observation.identity.sessionId}; ` +
|
|
5856
|
+
"refresh AKK list before sending");
|
|
5857
|
+
}
|
|
5858
|
+
const status = await createTerminalAgentBridge(options).status("codex", terminal.terminalControl, {
|
|
5859
|
+
runtime: terminalRuntimeForLiveIdentity({
|
|
5860
|
+
terminal,
|
|
5861
|
+
expectedEmptyNativeSession: true
|
|
5862
|
+
})
|
|
5863
|
+
});
|
|
5864
|
+
if (status.reachable !== true ||
|
|
5865
|
+
status.approval_state.blocked === true ||
|
|
5866
|
+
!["idle", "unknown"].includes(status.activity_state)) {
|
|
5867
|
+
throw new Error(`terminal ${terminal.terminalControl.target} is not at a verified ` +
|
|
5868
|
+
`empty Codex prompt (${status.activity_state}: ${status.activity_reason})`);
|
|
5869
|
+
}
|
|
5870
|
+
if (requireNoDispatch) {
|
|
5871
|
+
assertTerminalLifecycleReady({
|
|
5872
|
+
options,
|
|
5873
|
+
terminal,
|
|
5874
|
+
// A fully styled empty/placeholder composer below is stronger prompt
|
|
5875
|
+
// evidence than the plain-screen parser's conservative `unknown`.
|
|
5876
|
+
terminalStatus: { ...status, activity_state: "idle" }
|
|
5877
|
+
});
|
|
5878
|
+
}
|
|
5879
|
+
if (managedSessionHasUnresolvedNativeTransition(storeDirFromOptions(options), currentSource)) {
|
|
5880
|
+
throw new Error(`managed Session ${currentSource.session_id} has an unresolved native-thread transition`);
|
|
5881
|
+
}
|
|
5882
|
+
if (requireEmptyComposer) {
|
|
5883
|
+
await assertCodexComposerReadyForAutomatedInput({
|
|
5884
|
+
options,
|
|
5885
|
+
terminalControl: terminal.terminalControl
|
|
5886
|
+
});
|
|
5887
|
+
}
|
|
5888
|
+
}
|
|
5889
|
+
async function maybeDetachVerifiedEmptyCodexSource({ options, terminal, sourceSession, observation }) {
|
|
5890
|
+
if (terminal.agent !== "codex" ||
|
|
5891
|
+
!sourceSession?.binding ||
|
|
5892
|
+
observation.status !== "verified_absent") {
|
|
5893
|
+
return undefined;
|
|
5894
|
+
}
|
|
5895
|
+
const processUuid = sourceSession.binding.native_process.process_uuid;
|
|
5896
|
+
const processBirth = sourceSession.binding.native_process.process_birth;
|
|
5897
|
+
const workspace = terminal.terminalControl.currentPath;
|
|
5898
|
+
const liveIncarnation = codexProcessIncarnationForPid(terminal.pid);
|
|
5899
|
+
if (!processUuid ||
|
|
5900
|
+
!processBirth ||
|
|
5901
|
+
!workspace ||
|
|
5902
|
+
!exactBoundCodexSourceForVerifiedEmptyHandoff({
|
|
5903
|
+
session: sourceSession,
|
|
5904
|
+
terminalId: terminal.conversationId,
|
|
5905
|
+
terminalControl: terminal.terminalControl,
|
|
5906
|
+
pid: terminal.pid,
|
|
5907
|
+
workspace,
|
|
5908
|
+
processUuid: liveIncarnation.processUuid,
|
|
5909
|
+
processBirth: liveIncarnation.processBirth
|
|
5910
|
+
})) {
|
|
5911
|
+
return undefined;
|
|
5912
|
+
}
|
|
5913
|
+
const expectedToken = verifiedEmptyCodexHandoffToken({
|
|
5914
|
+
terminalId: terminal.conversationId,
|
|
5915
|
+
terminalControl: terminal.terminalControl,
|
|
5916
|
+
pid: terminal.pid,
|
|
5917
|
+
workspace,
|
|
5918
|
+
processUuid,
|
|
5919
|
+
processBirth,
|
|
5920
|
+
sourceSession
|
|
5921
|
+
});
|
|
5922
|
+
if (stringValue(options.expectedTerminalToken) !== expectedToken) {
|
|
5923
|
+
throw new Error("verified-empty Codex handoff requires the fresh exact terminal token " +
|
|
5924
|
+
"advertised by AKK list");
|
|
5925
|
+
}
|
|
5926
|
+
await assertVerifiedEmptyCodexHandoffBoundary({
|
|
5927
|
+
options,
|
|
5928
|
+
terminal,
|
|
5929
|
+
sourceSession,
|
|
5930
|
+
expectedSourceStatus: "bound",
|
|
5931
|
+
requireNoDispatch: true
|
|
5932
|
+
});
|
|
5933
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
5934
|
+
options,
|
|
5935
|
+
agent: "codex",
|
|
5936
|
+
currentPid: terminal.pid,
|
|
5937
|
+
nativeThreadId: sourceSession.binding.native_thread_id,
|
|
5938
|
+
storeDir: storeDirFromOptions(options),
|
|
5939
|
+
terminalControl: terminal.terminalControl,
|
|
5940
|
+
excludedManagedSessionId: sourceSession.session_id
|
|
5941
|
+
});
|
|
5942
|
+
const detachedAt = cliNow().toISOString();
|
|
5943
|
+
const detached = saveManagedSession(storeDirFromOptions(options), {
|
|
5944
|
+
...sourceSession,
|
|
5945
|
+
status: "detached",
|
|
5946
|
+
detached_at: detachedAt,
|
|
5947
|
+
updated_at: detachedAt
|
|
5948
|
+
}, {
|
|
5949
|
+
expectedRevision: managedSessionRevision(sourceSession)
|
|
5950
|
+
});
|
|
5951
|
+
runtimeLog("info", "verified_empty_codex_source_detached", {
|
|
5952
|
+
terminal_id: terminal.conversationId,
|
|
5953
|
+
source_session_id: sourceSession.session_id,
|
|
5954
|
+
native_thread_id: sourceSession.binding.native_thread_id,
|
|
5955
|
+
process_uuid: processUuid,
|
|
5956
|
+
process_birth: processBirth,
|
|
5957
|
+
terminal_input_sent: false
|
|
5958
|
+
});
|
|
5959
|
+
return {
|
|
5960
|
+
detached,
|
|
5961
|
+
boundary: {
|
|
5962
|
+
terminal,
|
|
5963
|
+
detachedSourceSessionId: detached.session_id,
|
|
5964
|
+
detachedSourceRevision: managedSessionRevision(detached),
|
|
5965
|
+
detachedSourceBindingToken: managedSessionBindingToken(detached),
|
|
5966
|
+
processUuid,
|
|
5967
|
+
processBirth
|
|
5968
|
+
}
|
|
5969
|
+
};
|
|
5970
|
+
}
|
|
5971
|
+
async function assertVerifiedEmptyCodexTransportBoundary({ options, boundary, requireEmptyComposer }) {
|
|
5972
|
+
const source = loadManagedSession(storeDirFromOptions(options), boundary.detachedSourceSessionId);
|
|
5973
|
+
if (source.status !== "detached" ||
|
|
5974
|
+
managedSessionRevision(source) !== boundary.detachedSourceRevision ||
|
|
5975
|
+
managedSessionBindingToken(source) !==
|
|
5976
|
+
boundary.detachedSourceBindingToken ||
|
|
5977
|
+
source.binding?.native_process.process_uuid !== boundary.processUuid ||
|
|
5978
|
+
source.binding.native_process.process_birth !== boundary.processBirth) {
|
|
5979
|
+
throw new Error("verified-empty Codex source authority changed before text injection");
|
|
5980
|
+
}
|
|
5981
|
+
await assertVerifiedEmptyCodexHandoffBoundary({
|
|
5982
|
+
options,
|
|
5983
|
+
terminal: boundary.terminal,
|
|
5984
|
+
sourceSession: source,
|
|
5985
|
+
expectedSourceStatus: "detached",
|
|
5986
|
+
requireNoDispatch: false,
|
|
5987
|
+
requireEmptyComposer
|
|
5988
|
+
});
|
|
5989
|
+
}
|
|
5990
|
+
function observedHandoffAuthorityToken({ terminal, identity, sourceSession, target }) {
|
|
5991
|
+
const exact = exactLifecycleProcessIdentity(terminal, identity);
|
|
5992
|
+
const terminalToken = unmanagedTerminalBindingToken({
|
|
5993
|
+
terminalId: terminal.conversationId,
|
|
5994
|
+
terminalControl: terminal.terminalControl,
|
|
5995
|
+
agent: terminal.agent,
|
|
5996
|
+
pid: terminal.pid,
|
|
5997
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
5998
|
+
nativeThreadId: exact.sessionId,
|
|
5999
|
+
processUuid: exact.processUuid,
|
|
6000
|
+
processBirth: exact.processBirth,
|
|
6001
|
+
rollout: exact.rollout
|
|
6002
|
+
});
|
|
6003
|
+
return humanObservedHandoffBindingToken({
|
|
6004
|
+
terminal_token: terminalToken,
|
|
6005
|
+
source_session_id: sourceSession.session_id,
|
|
6006
|
+
source_revision: managedSessionRevision(sourceSession),
|
|
6007
|
+
source_binding_token: managedSessionBindingToken(sourceSession),
|
|
6008
|
+
target
|
|
6009
|
+
});
|
|
6010
|
+
}
|
|
6011
|
+
function activeTurnHandoffDecisionToken({ handoffToken, turn, ledger }) {
|
|
6012
|
+
const takeover = isRecord(turn.native_session_takeover)
|
|
6013
|
+
? turn.native_session_takeover
|
|
6014
|
+
: undefined;
|
|
6015
|
+
const submission = terminalBridgeSubmission(turn);
|
|
6016
|
+
return createHash("sha256")
|
|
6017
|
+
.update(JSON.stringify({
|
|
6018
|
+
version: 1,
|
|
6019
|
+
kind: "active_turn_human_handoff",
|
|
6020
|
+
handoff_token: handoffToken,
|
|
6021
|
+
session_id: sessionIdForConversation(turn),
|
|
6022
|
+
turn_id: turnIdForConversation(turn),
|
|
6023
|
+
turn_status: turn.status,
|
|
6024
|
+
turn_updated_at: turn.updated_at ?? null,
|
|
6025
|
+
current_message_id: stringValue(takeover?.terminal_bridge_message_id) ??
|
|
6026
|
+
stringValue(submission?.message_id) ??
|
|
6027
|
+
null,
|
|
6028
|
+
ledger_generation_id: stringValue(ledger?.generation_id) ?? null,
|
|
6029
|
+
ledger_message_id: stringValue(ledger?.message_id) ?? null,
|
|
6030
|
+
ledger_status: stringValue(ledger?.status) ?? null
|
|
6031
|
+
}))
|
|
6032
|
+
.digest("hex");
|
|
6033
|
+
}
|
|
6034
|
+
const originalExpectedTerminalSelector = new WeakMap();
|
|
6035
|
+
function assertExpectedHandoffTokenUsesExactTerminalSelector({ options, terminal }) {
|
|
6036
|
+
if (!stringValue(options.expectedTerminalToken)) {
|
|
6037
|
+
return;
|
|
6038
|
+
}
|
|
6039
|
+
const supplied = originalExpectedTerminalSelector.has(options)
|
|
6040
|
+
? originalExpectedTerminalSelector.get(options)
|
|
6041
|
+
: stringValue(options.session ?? options.conversation ?? options.conversationId)?.trim();
|
|
6042
|
+
if (supplied !== terminal.conversationId) {
|
|
6043
|
+
throw new Error("--expected-terminal-token is valid only with the exact full terminal " +
|
|
6044
|
+
"conversation selector advertised by AKK list");
|
|
6045
|
+
}
|
|
6046
|
+
}
|
|
6047
|
+
async function observedExternalHandoffIdentity({ options, terminal, sourceSession, resolvedIdentity }) {
|
|
6048
|
+
const bridge = createTerminalAgentBridge(options);
|
|
6049
|
+
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
6050
|
+
assertSafeTerminalSend(terminal.agent, status);
|
|
6051
|
+
if (terminal.agent !== "codex") {
|
|
6052
|
+
return { identity: resolvedIdentity, status };
|
|
6053
|
+
}
|
|
6054
|
+
const sourceBinding = sourceSession.binding;
|
|
6055
|
+
const statusCard = terminal.adapter.observeThreadLifecycle?.({
|
|
6056
|
+
operation: { kind: "new_thread" },
|
|
6057
|
+
phase: "before",
|
|
6058
|
+
screen: status.screen.excerpt ?? ""
|
|
6059
|
+
});
|
|
6060
|
+
const statusCardId = statusCard?.status === "observed" &&
|
|
6061
|
+
isExactNativeThreadId(statusCard.nativeThreadId)
|
|
6062
|
+
? statusCard.nativeThreadId.toLowerCase()
|
|
6063
|
+
: undefined;
|
|
6064
|
+
const sourceId = sourceBinding?.native_thread_id?.toLowerCase();
|
|
6065
|
+
if (statusCardId && sourceId && statusCardId !== sourceId) {
|
|
6066
|
+
const processUuid = sourceBinding?.native_process.process_uuid;
|
|
6067
|
+
const processBirth = sourceBinding?.native_process.process_birth;
|
|
6068
|
+
if (!processUuid || !processBirth) {
|
|
6069
|
+
return { identity: undefined, status };
|
|
6070
|
+
}
|
|
6071
|
+
const resolvedMatchesStatus = resolvedIdentity?.sessionId.toLowerCase() === statusCardId;
|
|
6072
|
+
return {
|
|
6073
|
+
identity: {
|
|
6074
|
+
sessionId: statusCardId,
|
|
6075
|
+
processUuid,
|
|
6076
|
+
processBirth,
|
|
6077
|
+
rollout: resolvedMatchesStatus ? resolvedIdentity?.rollout : undefined,
|
|
6078
|
+
evidence: resolvedMatchesStatus
|
|
6079
|
+
? `${resolvedIdentity?.evidence ?? "native_thread_boundary"}+codex_status_card`
|
|
6080
|
+
: statusCard?.evidence ?? "codex_status_card"
|
|
6081
|
+
},
|
|
6082
|
+
status
|
|
6083
|
+
};
|
|
6084
|
+
}
|
|
6085
|
+
return { identity: resolvedIdentity, status };
|
|
6086
|
+
}
|
|
6087
|
+
function observedHandoffTargetResolution({ storeDir, agent, workspace, nativeThreadId, sourceSessionId }) {
|
|
6088
|
+
const matches = listManagedSessions(storeDir).filter((session) => session.session_id !== sourceSessionId &&
|
|
6089
|
+
session.agent === agent &&
|
|
6090
|
+
session.binding?.native_thread_id?.toLowerCase() === nativeThreadId &&
|
|
6091
|
+
path.resolve(session.workspace) === path.resolve(workspace));
|
|
6092
|
+
if (matches.length > 1) {
|
|
6093
|
+
return {
|
|
6094
|
+
status: "blocked",
|
|
6095
|
+
reason: `native thread ${nativeThreadId} is claimed by multiple managed Sessions`
|
|
6096
|
+
};
|
|
6097
|
+
}
|
|
6098
|
+
const target = matches[0];
|
|
6099
|
+
if (!target) {
|
|
6100
|
+
return { status: "eligible", snapshot: { state: "absent" } };
|
|
6101
|
+
}
|
|
6102
|
+
if (!target.binding ||
|
|
6103
|
+
target.status !== "detached" ||
|
|
6104
|
+
managedSessionHasUnresolvedNativeTransition(storeDir, target)) {
|
|
6105
|
+
return {
|
|
6106
|
+
status: "blocked",
|
|
6107
|
+
reason: `managed Session ${target.session_id} cannot be adopted from ` +
|
|
6108
|
+
`${target.status} state or while its lifecycle is unresolved`
|
|
6109
|
+
};
|
|
6110
|
+
}
|
|
6111
|
+
try {
|
|
6112
|
+
assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, target.session_id));
|
|
6113
|
+
}
|
|
6114
|
+
catch (error) {
|
|
6115
|
+
return {
|
|
6116
|
+
status: "blocked",
|
|
6117
|
+
reason: `managed Session ${target.session_id} has unresolved work: ` +
|
|
6118
|
+
`${error instanceof Error ? error.message : String(error)}`
|
|
6119
|
+
};
|
|
6120
|
+
}
|
|
6121
|
+
return {
|
|
6122
|
+
status: "eligible",
|
|
6123
|
+
session: target,
|
|
6124
|
+
snapshot: {
|
|
6125
|
+
state: "detached",
|
|
6126
|
+
session_id: target.session_id,
|
|
6127
|
+
revision: managedSessionRevision(target),
|
|
6128
|
+
status: "detached",
|
|
6129
|
+
binding_token: managedSessionBindingToken(target)
|
|
6130
|
+
}
|
|
6131
|
+
};
|
|
6132
|
+
}
|
|
6133
|
+
async function maybeAdoptObservedExternalThread({ options, terminal, sourceSession, resolvedIdentity, storeDir }) {
|
|
6134
|
+
if (!sourceSession?.binding) {
|
|
6135
|
+
return { identity: resolvedIdentity, adopted: false };
|
|
6136
|
+
}
|
|
6137
|
+
const observed = await observedExternalHandoffIdentity({
|
|
6138
|
+
options,
|
|
6139
|
+
terminal,
|
|
6140
|
+
sourceSession,
|
|
6141
|
+
resolvedIdentity
|
|
6142
|
+
});
|
|
6143
|
+
const identity = observed.identity;
|
|
6144
|
+
const conflictKind = managedBindingConflictKindForResolvedTerminal({
|
|
6145
|
+
storeDir,
|
|
6146
|
+
session: sourceSession,
|
|
6147
|
+
terminal,
|
|
6148
|
+
identity
|
|
6149
|
+
});
|
|
6150
|
+
if (conflictKind !== "live_external_thread_change") {
|
|
6151
|
+
return { identity, adopted: false };
|
|
6152
|
+
}
|
|
6153
|
+
assertTerminalLifecycleReady({
|
|
6154
|
+
options,
|
|
6155
|
+
terminal,
|
|
6156
|
+
terminalStatus: observed.status
|
|
6157
|
+
});
|
|
6158
|
+
if (!identity || !isExactNativeThreadId(identity.sessionId)) {
|
|
6159
|
+
throw new Error("the externally selected native thread has no exact supported identity");
|
|
6160
|
+
}
|
|
6161
|
+
if (managedSessionHasUnresolvedNativeTransition(storeDir, sourceSession)) {
|
|
6162
|
+
throw new Error(`managed Session ${sourceSession.session_id} has an unresolved native-thread transition`);
|
|
6163
|
+
}
|
|
6164
|
+
assertManagedSessionCanStartTurn(managedTurnsForSession(storeDir, sourceSession.session_id));
|
|
6165
|
+
if (terminal.agent === "codex"
|
|
6166
|
+
? !codexComposerEmpty(observed.status.screen.excerpt)
|
|
6167
|
+
: !claudeComposerEmpty(observed.status.screen.excerpt)) {
|
|
6168
|
+
throw new Error("external handoff adoption requires an exact empty idle composer");
|
|
6169
|
+
}
|
|
6170
|
+
if (terminal.agent === "codex") {
|
|
6171
|
+
await assertCodexComposerReadyForAutomatedInput({
|
|
6172
|
+
options,
|
|
6173
|
+
terminalControl: terminal.terminalControl
|
|
6174
|
+
});
|
|
6175
|
+
}
|
|
6176
|
+
const targetNativeThreadId = identity.sessionId.toLowerCase();
|
|
6177
|
+
const targetResolution = observedHandoffTargetResolution({
|
|
6178
|
+
storeDir,
|
|
6179
|
+
agent: terminal.agent,
|
|
6180
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
6181
|
+
nativeThreadId: targetNativeThreadId,
|
|
6182
|
+
sourceSessionId: sourceSession.session_id
|
|
6183
|
+
});
|
|
6184
|
+
const expectedTerminalToken = stringValue(options.expectedTerminalToken);
|
|
6185
|
+
if (targetResolution.status === "blocked") {
|
|
6186
|
+
if (expectedTerminalToken) {
|
|
6187
|
+
throw new Error("live source or target Session snapshot changed after the handoff was " +
|
|
6188
|
+
"listed; refresh AKK list");
|
|
6189
|
+
}
|
|
6190
|
+
throw new Error(targetResolution.reason);
|
|
6191
|
+
}
|
|
6192
|
+
const freshHandoffToken = observedHandoffAuthorityToken({
|
|
6193
|
+
terminal,
|
|
6194
|
+
identity,
|
|
6195
|
+
sourceSession,
|
|
6196
|
+
target: targetResolution.snapshot
|
|
6197
|
+
});
|
|
6198
|
+
if (expectedTerminalToken &&
|
|
6199
|
+
expectedTerminalToken !== freshHandoffToken) {
|
|
6200
|
+
throw new Error("live source, target, or terminal identity changed after the handoff " +
|
|
6201
|
+
"was listed; refresh AKK list");
|
|
6202
|
+
}
|
|
6203
|
+
const targetSession = targetResolution.session;
|
|
6204
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
6205
|
+
options,
|
|
6206
|
+
agent: terminal.agent,
|
|
6207
|
+
currentPid: terminal.pid,
|
|
6208
|
+
nativeThreadId: targetNativeThreadId,
|
|
6209
|
+
storeDir,
|
|
6210
|
+
terminalControl: terminal.terminalControl,
|
|
6211
|
+
excludedManagedSessionId: targetSession?.session_id
|
|
6212
|
+
});
|
|
6213
|
+
const adapterVersion = required(stringValue(agentVersionForRunningProcess(terminal.agent, terminal.pid, options)), "external handoff adoption requires the exact running agent version");
|
|
6214
|
+
const capability = terminal.adapter.probeThreadLifecycle?.(adapterVersion);
|
|
6215
|
+
if (capability?.status !== "supported") {
|
|
6216
|
+
throw new Error(capability?.reason ?? "external handoff adoption is unsupported for this agent version");
|
|
6217
|
+
}
|
|
6218
|
+
const now = cliNow();
|
|
6219
|
+
const sourceBinding = sourceSession.binding;
|
|
6220
|
+
const targetSessionId = targetSession?.session_id ?? createManagedSessionId(now);
|
|
6221
|
+
const transitionId = createNativeThreadTransitionId();
|
|
6222
|
+
const exactIdentity = exactLifecycleProcessIdentity(terminal, identity);
|
|
6223
|
+
const nextBinding = terminalBindingFrom({
|
|
6224
|
+
terminalId: terminal.conversationId,
|
|
6225
|
+
terminalControl: terminal.terminalControl,
|
|
6226
|
+
pid: terminal.pid,
|
|
6227
|
+
nativeThreadId: targetNativeThreadId,
|
|
6228
|
+
processUuid: exactIdentity.processUuid,
|
|
6229
|
+
processBirth: exactIdentity.processBirth,
|
|
6230
|
+
rollout: exactIdentity.rollout,
|
|
6231
|
+
evidence: `${exactIdentity.evidence}+human_observed`,
|
|
6232
|
+
generation: (targetSession?.binding?.generation ?? 0) + 1,
|
|
6233
|
+
now
|
|
6234
|
+
});
|
|
6235
|
+
const previousLedger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
6236
|
+
let transition = {
|
|
6237
|
+
schema: "agent-knock-knock/native-thread-transition",
|
|
6238
|
+
version: 1,
|
|
6239
|
+
transition_id: transitionId,
|
|
6240
|
+
operation: "adopt_external_thread",
|
|
6241
|
+
origin: "human_observed",
|
|
6242
|
+
terminal_input_sent: false,
|
|
6243
|
+
status: "prepared",
|
|
6244
|
+
terminal_id: terminal.conversationId,
|
|
6245
|
+
agent: terminal.agent,
|
|
6246
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
6247
|
+
source_session_id: sourceSession.session_id,
|
|
6248
|
+
source_expected_revision: managedSessionRevision(sourceSession),
|
|
6249
|
+
source_previous_last_transition_id: sourceSession.last_transition_id,
|
|
6250
|
+
target_session_id: targetSessionId,
|
|
6251
|
+
target_expected_revision: targetSession
|
|
6252
|
+
? managedSessionRevision(targetSession)
|
|
6253
|
+
: null,
|
|
6254
|
+
target_native_thread_id: targetNativeThreadId,
|
|
6255
|
+
before_native_thread_id: sourceBinding.native_thread_id,
|
|
6256
|
+
before_process_uuid: sourceBinding.native_process.process_uuid,
|
|
6257
|
+
before_process_started_at: exactIdentity.processStartedAt,
|
|
6258
|
+
before_process_birth: sourceBinding.native_process.process_birth,
|
|
6259
|
+
before_process_rollout: sourceBinding.native_process.rollout,
|
|
6260
|
+
before_binding: sourceBinding,
|
|
6261
|
+
adapter_version: adapterVersion,
|
|
6262
|
+
command_fingerprint: HUMAN_OBSERVED_HANDOFF_FINGERPRINT,
|
|
6263
|
+
dispatcher_pid: cliPid(),
|
|
6264
|
+
prepared_at: now.toISOString()
|
|
6265
|
+
};
|
|
6266
|
+
transition = saveNativeThreadTransition(storeDir, transition, {
|
|
6267
|
+
expectedRevision: null
|
|
6268
|
+
});
|
|
6269
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_TRANSITION_BEFORE_LEDGER === "1") {
|
|
6270
|
+
cliExit(88);
|
|
6271
|
+
}
|
|
6272
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6273
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
6274
|
+
status: "prepared",
|
|
6275
|
+
target_native_thread_id: targetNativeThreadId,
|
|
6276
|
+
previous_generation_id: stringValue(previousLedger?.generation_id) ??
|
|
6277
|
+
stringValue(previousLedger?.message_id)
|
|
6278
|
+
}, { expectedTransitionId: null });
|
|
6279
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_PREPARED === "1") {
|
|
6280
|
+
cliExit(86);
|
|
6281
|
+
}
|
|
6282
|
+
const sourceTransitioning = saveManagedSession(storeDir, {
|
|
6283
|
+
...sourceSession,
|
|
6284
|
+
status: "transitioning",
|
|
6285
|
+
last_transition_id: transitionId,
|
|
6286
|
+
updated_at: now.toISOString()
|
|
6287
|
+
}, { expectedRevision: managedSessionRevision(sourceSession) });
|
|
6288
|
+
try {
|
|
6289
|
+
const reObserved = await observedExternalHandoffIdentity({
|
|
6290
|
+
options,
|
|
6291
|
+
terminal,
|
|
6292
|
+
sourceSession: sourceTransitioning,
|
|
6293
|
+
resolvedIdentity: await resolveCurrentNativeAgentSessionIdentity({
|
|
6294
|
+
options,
|
|
6295
|
+
agent: terminal.agent,
|
|
6296
|
+
pid: terminal.pid,
|
|
6297
|
+
cwd: terminal.terminalControl.currentPath,
|
|
6298
|
+
preferredSessionId: targetNativeThreadId,
|
|
6299
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
6300
|
+
sessionId: sourceBinding.native_thread_id,
|
|
6301
|
+
processUuid: sourceBinding.native_process.process_uuid,
|
|
6302
|
+
processBirth: sourceBinding.native_process.process_birth,
|
|
6303
|
+
rollout: sourceBinding.native_process.rollout,
|
|
6304
|
+
evidence: sourceBinding.native_process.evidence
|
|
6305
|
+
}),
|
|
6306
|
+
allowedAdditionalIdentities: []
|
|
6307
|
+
})
|
|
6308
|
+
});
|
|
6309
|
+
const reObservedExact = reObserved.identity
|
|
6310
|
+
? exactLifecycleProcessIdentity(terminal, reObserved.identity)
|
|
6311
|
+
: undefined;
|
|
6312
|
+
if (reObservedExact?.sessionId.toLowerCase() !== targetNativeThreadId ||
|
|
6313
|
+
reObservedExact.processUuid !== nextBinding.native_process.process_uuid ||
|
|
6314
|
+
reObservedExact.processBirth !== nextBinding.native_process.process_birth ||
|
|
6315
|
+
JSON.stringify(reObservedExact.rollout ?? null) !==
|
|
6316
|
+
JSON.stringify(nextBinding.native_process.rollout ?? null)) {
|
|
6317
|
+
throw new Error("live native thread changed during external handoff adoption");
|
|
6318
|
+
}
|
|
6319
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
6320
|
+
options,
|
|
6321
|
+
agent: terminal.agent,
|
|
6322
|
+
currentPid: terminal.pid,
|
|
6323
|
+
nativeThreadId: targetNativeThreadId,
|
|
6324
|
+
storeDir,
|
|
6325
|
+
terminalControl: terminal.terminalControl,
|
|
6326
|
+
excludedManagedSessionId: targetSession?.session_id
|
|
6327
|
+
});
|
|
6328
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
6329
|
+
...transition,
|
|
6330
|
+
status: "verified",
|
|
6331
|
+
after_binding: nextBinding,
|
|
6332
|
+
verified_at: cliNow().toISOString()
|
|
6333
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
6334
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_HANDOFF_VERIFIED_TRANSITION_BEFORE_LEDGER ===
|
|
6335
|
+
"1") {
|
|
6336
|
+
cliExit(89);
|
|
6337
|
+
}
|
|
6338
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6339
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
6340
|
+
status: "verified",
|
|
6341
|
+
binding: sourceBinding
|
|
6342
|
+
}, {
|
|
6343
|
+
expectedTransitionId: transitionId,
|
|
6344
|
+
expectedStatus: "prepared"
|
|
6345
|
+
});
|
|
6346
|
+
if (cliEnv().AKK_TEST_EXIT_AFTER_LIFECYCLE_VERIFIED === "1") {
|
|
6347
|
+
cliExit(87);
|
|
6348
|
+
}
|
|
6349
|
+
const committedTarget = commitVerifiedLifecycleTransition(storeDir, transition, cliNow().toISOString());
|
|
6350
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
6351
|
+
...transition,
|
|
6352
|
+
status: "committed",
|
|
6353
|
+
committed_at: cliNow().toISOString()
|
|
6354
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
6355
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6356
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
6357
|
+
status: "resolved",
|
|
6358
|
+
resolved_at: cliNow().toISOString(),
|
|
6359
|
+
binding: committedTarget.binding,
|
|
6360
|
+
reason: "verified human-observed native thread handoff committed"
|
|
6361
|
+
}, {
|
|
6362
|
+
expectedTransitionId: transitionId,
|
|
6363
|
+
expectedStatus: "verified"
|
|
6364
|
+
});
|
|
6365
|
+
runtimeLog("info", "human_observed_handoff_adopted", {
|
|
6366
|
+
transition_id: transitionId,
|
|
6367
|
+
terminal_id: terminal.conversationId,
|
|
6368
|
+
source_session_id: sourceSession.session_id,
|
|
6369
|
+
target_session_id: committedTarget.session_id,
|
|
6370
|
+
native_thread_id: targetNativeThreadId,
|
|
6371
|
+
terminal_input_sent: false
|
|
6372
|
+
});
|
|
6373
|
+
return {
|
|
6374
|
+
session: committedTarget,
|
|
6375
|
+
identity: exactIdentity,
|
|
6376
|
+
transition,
|
|
6377
|
+
adopted: true
|
|
6378
|
+
};
|
|
6379
|
+
}
|
|
6380
|
+
catch (error) {
|
|
6381
|
+
const failedAt = cliNow().toISOString();
|
|
6382
|
+
const durable = loadNativeThreadTransition(storeDir, transitionId);
|
|
6383
|
+
if (durable.status === "verified" || durable.status === "committed") {
|
|
6384
|
+
throw error;
|
|
6385
|
+
}
|
|
6386
|
+
const uncertain = saveNativeThreadTransition(storeDir, {
|
|
6387
|
+
...durable,
|
|
6388
|
+
status: "uncertain",
|
|
6389
|
+
uncertain_at: failedAt,
|
|
6390
|
+
error: error instanceof Error ? error.message : String(error),
|
|
6391
|
+
do_not_retry: true
|
|
6392
|
+
}, { expectedRevision: nativeThreadTransitionRevision(durable) });
|
|
6393
|
+
saveManagedSession(storeDir, {
|
|
6394
|
+
...sourceTransitioning,
|
|
6395
|
+
status: "quarantined",
|
|
6396
|
+
quarantine_reason: "human-observed handoff could not be revalidated",
|
|
6397
|
+
updated_at: failedAt
|
|
6398
|
+
}, { expectedRevision: managedSessionRevision(sourceTransitioning) });
|
|
6399
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
6400
|
+
...lifecycleLedgerFields(uncertain, storeDir),
|
|
6401
|
+
status: "uncertain",
|
|
6402
|
+
uncertain_at: failedAt,
|
|
6403
|
+
reason: "human-observed handoff could not be revalidated"
|
|
6404
|
+
}, { expectedTransitionId: transitionId });
|
|
6405
|
+
throw error;
|
|
6406
|
+
}
|
|
6407
|
+
}
|
|
6408
|
+
async function assertObservedHandoffTransportBoundary({ options, terminal, transition, requireEmptyComposer }) {
|
|
6409
|
+
const storeDir = storeDirFromOptions(options);
|
|
6410
|
+
const durable = loadNativeThreadTransition(storeDir, transition.transition_id);
|
|
6411
|
+
if (durable.operation !== "adopt_external_thread" ||
|
|
6412
|
+
durable.origin !== "human_observed" ||
|
|
6413
|
+
durable.terminal_input_sent !== false ||
|
|
6414
|
+
durable.status !== "committed" ||
|
|
6415
|
+
!durable.source_session_id ||
|
|
6416
|
+
!durable.before_binding ||
|
|
6417
|
+
!durable.after_binding ||
|
|
6418
|
+
JSON.stringify(durable.after_binding) !==
|
|
6419
|
+
JSON.stringify(transition.after_binding)) {
|
|
6420
|
+
throw new Error("human-observed handoff changed before terminal transport");
|
|
6421
|
+
}
|
|
6422
|
+
const source = loadManagedSession(storeDir, durable.source_session_id);
|
|
6423
|
+
const target = loadManagedSession(storeDir, durable.target_session_id);
|
|
6424
|
+
if (source.status !== "detached" ||
|
|
6425
|
+
source.last_transition_id !== durable.transition_id ||
|
|
6426
|
+
JSON.stringify(source.binding) !== JSON.stringify(durable.before_binding) ||
|
|
6427
|
+
target.status !== "bound" ||
|
|
6428
|
+
target.last_transition_id !== durable.transition_id ||
|
|
6429
|
+
JSON.stringify(target.binding) !== JSON.stringify(durable.after_binding)) {
|
|
6430
|
+
throw new Error("human-observed handoff Session authority changed before send");
|
|
6431
|
+
}
|
|
6432
|
+
const targetId = durable.after_binding.native_thread_id;
|
|
6433
|
+
if (!targetId) {
|
|
6434
|
+
throw new Error("human-observed handoff target identity is incomplete");
|
|
6435
|
+
}
|
|
6436
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
6437
|
+
options,
|
|
6438
|
+
agent: terminal.agent,
|
|
6439
|
+
pid: terminal.pid,
|
|
6440
|
+
cwd: terminal.terminalControl.currentPath,
|
|
6441
|
+
preferredSessionId: targetId,
|
|
6442
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
6443
|
+
sessionId: durable.before_native_thread_id,
|
|
6444
|
+
processUuid: durable.before_process_uuid,
|
|
6445
|
+
processBirth: durable.before_process_birth,
|
|
6446
|
+
rollout: durable.before_process_rollout,
|
|
6447
|
+
evidence: durable.before_binding.native_process.evidence
|
|
6448
|
+
}),
|
|
6449
|
+
allowedAdditionalIdentities: []
|
|
6450
|
+
});
|
|
6451
|
+
const bridge = createTerminalAgentBridge(options);
|
|
6452
|
+
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
6453
|
+
if (requireEmptyComposer) {
|
|
6454
|
+
assertSafeTerminalSend(terminal.agent, status);
|
|
6455
|
+
}
|
|
6456
|
+
else {
|
|
6457
|
+
const displayName = executorDefinitionForKind(terminal.agent).displayName;
|
|
6458
|
+
const approval = isRecord(status?.approval_state)
|
|
6459
|
+
? status.approval_state
|
|
6460
|
+
: undefined;
|
|
6461
|
+
if (status?.reachable !== true) {
|
|
6462
|
+
throw new Error(`${displayName} terminal status is unavailable`);
|
|
6463
|
+
}
|
|
6464
|
+
if (approval?.blocked === true) {
|
|
6465
|
+
throw new Error(stringValue(approval.reason) ??
|
|
6466
|
+
`${displayName} is waiting at a permission dialog`);
|
|
6467
|
+
}
|
|
6468
|
+
// With an exact draft in the composer, native TUIs can classify the screen
|
|
6469
|
+
// as `unknown` instead of `idle`. Exact draft materialization is proven by
|
|
6470
|
+
// the bridge before Enter, so only a positively busy state is unsafe here.
|
|
6471
|
+
if (status.activity_state !== "idle" &&
|
|
6472
|
+
status.activity_state !== "unknown") {
|
|
6473
|
+
throw new Error(`${displayName} terminal became ${stringValue(status.activity_state) ?? "unknown"} before handoff submission`);
|
|
6474
|
+
}
|
|
6475
|
+
}
|
|
6476
|
+
let liveIdentity = resolved;
|
|
6477
|
+
if (terminal.agent === "codex") {
|
|
6478
|
+
const foreground = terminal.adapter.observeThreadLifecycle?.({
|
|
6479
|
+
operation: { kind: "new_thread" },
|
|
6480
|
+
phase: "before",
|
|
6481
|
+
screen: status.screen.excerpt ?? ""
|
|
6482
|
+
});
|
|
6483
|
+
const foregroundId = foreground?.status === "observed" &&
|
|
6484
|
+
isExactNativeThreadId(foreground.nativeThreadId)
|
|
6485
|
+
? foreground.nativeThreadId.toLowerCase()
|
|
6486
|
+
: undefined;
|
|
6487
|
+
if (foregroundId && foregroundId !== targetId.toLowerCase()) {
|
|
6488
|
+
throw new Error("Codex foreground native thread changed after handoff adoption");
|
|
6489
|
+
}
|
|
6490
|
+
if (!durable.after_binding.native_process.rollout) {
|
|
6491
|
+
if (requireEmptyComposer &&
|
|
6492
|
+
foregroundId !== targetId.toLowerCase()) {
|
|
6493
|
+
throw new Error("status-card-only Codex handoff lost its exact foreground identity");
|
|
6494
|
+
}
|
|
6495
|
+
liveIdentity = resolved?.sessionId.toLowerCase() === targetId.toLowerCase()
|
|
6496
|
+
? resolved
|
|
6497
|
+
: {
|
|
6498
|
+
sessionId: targetId,
|
|
6499
|
+
processUuid: durable.after_binding.native_process.process_uuid,
|
|
6500
|
+
processBirth: durable.after_binding.native_process.process_birth,
|
|
6501
|
+
evidence: foreground?.evidence ?? "codex_status_card"
|
|
6502
|
+
};
|
|
6503
|
+
}
|
|
6504
|
+
else if (!liveIdentity ||
|
|
6505
|
+
liveIdentity.sessionId.toLowerCase() !== targetId.toLowerCase()) {
|
|
6506
|
+
throw new Error("Codex handoff rollout identity changed before terminal transport");
|
|
6507
|
+
}
|
|
6508
|
+
}
|
|
6509
|
+
if (!liveIdentity) {
|
|
6510
|
+
throw new Error("human-observed handoff identity is unavailable before send");
|
|
6511
|
+
}
|
|
6512
|
+
const exact = exactLifecycleProcessIdentity(terminal, liveIdentity);
|
|
6513
|
+
if (exact.sessionId.toLowerCase() !== targetId.toLowerCase() ||
|
|
6514
|
+
exact.processUuid !== durable.after_binding.native_process.process_uuid ||
|
|
6515
|
+
exact.processBirth !== durable.after_binding.native_process.process_birth ||
|
|
6516
|
+
JSON.stringify(exact.rollout ?? null) !==
|
|
6517
|
+
JSON.stringify(durable.after_binding.native_process.rollout ?? null)) {
|
|
6518
|
+
throw new Error("human-observed handoff identity changed before send");
|
|
6519
|
+
}
|
|
6520
|
+
if (requireEmptyComposer) {
|
|
6521
|
+
const empty = terminal.agent === "codex"
|
|
6522
|
+
? codexComposerEmpty(status.screen.excerpt)
|
|
6523
|
+
: claudeComposerEmpty(status.screen.excerpt);
|
|
6524
|
+
if (!empty) {
|
|
6525
|
+
throw new Error("human-observed handoff composer changed before text injection");
|
|
6526
|
+
}
|
|
6527
|
+
if (terminal.agent === "codex") {
|
|
6528
|
+
await assertCodexComposerReadyForAutomatedInput({
|
|
6529
|
+
options,
|
|
6530
|
+
terminalControl: terminal.terminalControl
|
|
6531
|
+
});
|
|
6532
|
+
}
|
|
6533
|
+
}
|
|
6534
|
+
}
|
|
5509
6535
|
function lifecycleBindingToken({ session, terminal, identity }) {
|
|
5510
6536
|
if (session) {
|
|
5511
6537
|
return managedSessionBindingToken(session);
|
|
@@ -7914,6 +8940,10 @@ async function runStatus(options) {
|
|
|
7914
8940
|
};
|
|
7915
8941
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
7916
8942
|
if (terminalConversation) {
|
|
8943
|
+
assertExpectedHandoffTokenUsesExactTerminalSelector({
|
|
8944
|
+
options,
|
|
8945
|
+
terminal: terminalConversation
|
|
8946
|
+
});
|
|
7917
8947
|
const terminalStatus = await terminalStatusForControl(terminalConversation.agent, terminalConversation.terminalControl, options, {
|
|
7918
8948
|
pid: terminalConversation.pid,
|
|
7919
8949
|
cwd: terminalConversation.terminalControl.currentPath,
|
|
@@ -8503,6 +9533,13 @@ async function runSend(options) {
|
|
|
8503
9533
|
}
|
|
8504
9534
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
8505
9535
|
if (terminalConversation) {
|
|
9536
|
+
// A token copied from list is authority for exactly the advertised full
|
|
9537
|
+
// terminal selector. Reject aliases and implicit/no-selector resolution
|
|
9538
|
+
// before taking locks or touching Store state.
|
|
9539
|
+
assertExpectedHandoffTokenUsesExactTerminalSelector({
|
|
9540
|
+
options,
|
|
9541
|
+
terminal: terminalConversation
|
|
9542
|
+
});
|
|
8506
9543
|
if (!options.background) {
|
|
8507
9544
|
throw new Error("raw terminal sends require --background so AKK can persist and monitor the submission safely");
|
|
8508
9545
|
}
|
|
@@ -8527,14 +9564,14 @@ async function runSend(options) {
|
|
|
8527
9564
|
})) {
|
|
8528
9565
|
return;
|
|
8529
9566
|
}
|
|
8530
|
-
|
|
8531
|
-
|
|
9567
|
+
let claimedSession = soleBoundManagedSessionClaimForTerminal(rawStoreDir, terminalConversation);
|
|
9568
|
+
let knownCodexCompanions = claimedSession
|
|
8532
9569
|
? codexAllowedCompanionSetForManagedSession({
|
|
8533
9570
|
storeDir: rawStoreDir,
|
|
8534
9571
|
session: claimedSession
|
|
8535
9572
|
})
|
|
8536
9573
|
: { additional: [] };
|
|
8537
|
-
const
|
|
9574
|
+
const nativeIdentityObservation = await observeCurrentNativeAgentSessionIdentity({
|
|
8538
9575
|
options,
|
|
8539
9576
|
agent: terminalConversation.agent,
|
|
8540
9577
|
pid: terminalConversation.pid,
|
|
@@ -8545,11 +9582,46 @@ async function runSend(options) {
|
|
|
8545
9582
|
allowedCompanionIdentity: knownCodexCompanions.primary,
|
|
8546
9583
|
allowedAdditionalIdentities: knownCodexCompanions.additional
|
|
8547
9584
|
});
|
|
8548
|
-
|
|
9585
|
+
if (nativeIdentityObservation.status === "unavailable") {
|
|
9586
|
+
throw new Error(`native ${terminalConversation.agent} identity observation is ` +
|
|
9587
|
+
`unavailable: ${nativeIdentityObservation.reason}`);
|
|
9588
|
+
}
|
|
9589
|
+
let currentNativeIdentity = nativeIdentityObservation.status === "resolved"
|
|
9590
|
+
? nativeIdentityObservation.identity
|
|
9591
|
+
: undefined;
|
|
9592
|
+
const verifiedEmptyHandoff = await maybeDetachVerifiedEmptyCodexSource({
|
|
9593
|
+
options,
|
|
9594
|
+
terminal: terminalConversation,
|
|
9595
|
+
sourceSession: claimedSession,
|
|
9596
|
+
observation: nativeIdentityObservation
|
|
9597
|
+
});
|
|
9598
|
+
if (verifiedEmptyHandoff) {
|
|
9599
|
+
// The old rollout is conclusively closed. Never carry it forward as
|
|
9600
|
+
// a pre-materialization companion for the new virgin Session.
|
|
9601
|
+
claimedSession = undefined;
|
|
9602
|
+
knownCodexCompanions = { additional: [] };
|
|
9603
|
+
currentNativeIdentity = undefined;
|
|
9604
|
+
}
|
|
9605
|
+
const physicalNativeIdentityBeforeHandoff = currentNativeIdentity;
|
|
9606
|
+
const handoff = await maybeAdoptObservedExternalThread({
|
|
8549
9607
|
options,
|
|
8550
9608
|
terminal: terminalConversation,
|
|
8551
|
-
|
|
9609
|
+
sourceSession: claimedSession,
|
|
9610
|
+
resolvedIdentity: currentNativeIdentity,
|
|
9611
|
+
storeDir: rawStoreDir
|
|
8552
9612
|
});
|
|
9613
|
+
currentNativeIdentity =
|
|
9614
|
+
handoff.adopted &&
|
|
9615
|
+
terminalConversation.agent === "codex" &&
|
|
9616
|
+
!handoff.session?.binding?.native_process.rollout
|
|
9617
|
+
? physicalNativeIdentityBeforeHandoff
|
|
9618
|
+
: handoff.identity;
|
|
9619
|
+
let managedSession = handoff.session ??
|
|
9620
|
+
materializeCurrentManagedSession({
|
|
9621
|
+
options,
|
|
9622
|
+
terminal: terminalConversation,
|
|
9623
|
+
identity: currentNativeIdentity
|
|
9624
|
+
});
|
|
8553
9625
|
if (!managedSession && currentNativeIdentity) {
|
|
8554
9626
|
managedSession = await reattachManagedSessionForNativeIdentity({
|
|
8555
9627
|
options,
|
|
@@ -8593,19 +9665,28 @@ async function runSend(options) {
|
|
|
8593
9665
|
storeDir: rawStoreDir,
|
|
8594
9666
|
session: managedSession,
|
|
8595
9667
|
observedIdentity: currentNativeIdentity
|
|
8596
|
-
}) ??
|
|
8597
|
-
|
|
9668
|
+
}) ?? (currentNativeIdentity === undefined ||
|
|
9669
|
+
nativeIdentityMatchesCodexPreMaterialization(currentNativeIdentity, knownCodexCompanions.primary)
|
|
9670
|
+
? knownCodexCompanions.primary
|
|
9671
|
+
: undefined);
|
|
9672
|
+
const allowedAdditionalIdentities = allowedPreMaterializationIdentity
|
|
9673
|
+
? knownCodexCompanions.additional
|
|
9674
|
+
: [];
|
|
8598
9675
|
const materializedNativeIdentity = currentNativeIdentity?.sessionId === logicalNativeIdentity?.sessionId
|
|
8599
9676
|
? currentNativeIdentity
|
|
8600
9677
|
: undefined;
|
|
8601
|
-
|
|
8602
|
-
|
|
8603
|
-
|
|
8604
|
-
|
|
8605
|
-
|
|
8606
|
-
|
|
8607
|
-
|
|
8608
|
-
|
|
9678
|
+
if (!(handoff.adopted &&
|
|
9679
|
+
terminalConversation.agent === "codex" &&
|
|
9680
|
+
!managedSession.binding?.native_process.rollout)) {
|
|
9681
|
+
await verifyCodexPendingManagedSendStatus({
|
|
9682
|
+
options,
|
|
9683
|
+
terminal: terminalConversation,
|
|
9684
|
+
session: managedSession,
|
|
9685
|
+
logicalIdentity: logicalNativeIdentity,
|
|
9686
|
+
allowedPreMaterializationIdentity,
|
|
9687
|
+
allowedAdditionalIdentities
|
|
9688
|
+
});
|
|
9689
|
+
}
|
|
8609
9690
|
const managedNativeThreadId = logicalNativeIdentity?.sessionId ??
|
|
8610
9691
|
managedSession.binding?.native_thread_id;
|
|
8611
9692
|
if (managedNativeThreadId) {
|
|
@@ -8683,7 +9764,14 @@ async function runSend(options) {
|
|
|
8683
9764
|
}
|
|
8684
9765
|
: undefined,
|
|
8685
9766
|
allowedPreMaterializationIdentity,
|
|
8686
|
-
allowedAdditionalIdentities
|
|
9767
|
+
allowedAdditionalIdentities,
|
|
9768
|
+
observedHandoff: handoff.adopted && handoff.transition
|
|
9769
|
+
? {
|
|
9770
|
+
terminal: terminalConversation,
|
|
9771
|
+
transition: handoff.transition
|
|
9772
|
+
}
|
|
9773
|
+
: undefined,
|
|
9774
|
+
verifiedEmptyCodexHandoff: verifiedEmptyHandoff?.boundary
|
|
8687
9775
|
});
|
|
8688
9776
|
}
|
|
8689
9777
|
finally {
|
|
@@ -8696,6 +9784,10 @@ async function runSend(options) {
|
|
|
8696
9784
|
}
|
|
8697
9785
|
return;
|
|
8698
9786
|
}
|
|
9787
|
+
if (stringValue(options.expectedTerminalToken)) {
|
|
9788
|
+
throw new Error("--expected-terminal-token cannot be used with a managed Session; " +
|
|
9789
|
+
"use the exact full terminal selector advertised by AKK list");
|
|
9790
|
+
}
|
|
8699
9791
|
const sessionId = required(stringValue(options.session ?? options.conversation ?? options.conversationId), "--session is required for an ordinary managed send");
|
|
8700
9792
|
const storeDir = storeDirFromOptions(options);
|
|
8701
9793
|
// Read only enough legacy/Session authority to identify the physical
|
|
@@ -9638,7 +10730,7 @@ async function runTerminalConversationApprove({ options, conversationId, agent,
|
|
|
9638
10730
|
releaseTerminalLock();
|
|
9639
10731
|
}
|
|
9640
10732
|
}
|
|
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 }) {
|
|
10733
|
+
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, verifiedEmptyCodexHandoff = undefined, continuingTurnResponse = false }) {
|
|
9642
10734
|
const bridge = terminalBridgeEnabled(conversation);
|
|
9643
10735
|
if (!terminalSendLockHeld) {
|
|
9644
10736
|
const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDirFromOptions(options), terminalControl, { timeoutMs: 30000 });
|
|
@@ -9660,6 +10752,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9660
10752
|
onTerminalPreflightVerified,
|
|
9661
10753
|
allowedPreMaterializationIdentity,
|
|
9662
10754
|
allowedAdditionalIdentities,
|
|
10755
|
+
observedHandoff,
|
|
10756
|
+
verifiedEmptyCodexHandoff,
|
|
9663
10757
|
continuingTurnResponse
|
|
9664
10758
|
});
|
|
9665
10759
|
}
|
|
@@ -9699,6 +10793,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9699
10793
|
onTerminalPreflightVerified,
|
|
9700
10794
|
allowedPreMaterializationIdentity,
|
|
9701
10795
|
allowedAdditionalIdentities,
|
|
10796
|
+
observedHandoff,
|
|
10797
|
+
verifiedEmptyCodexHandoff,
|
|
9702
10798
|
continuingTurnResponse
|
|
9703
10799
|
});
|
|
9704
10800
|
}
|
|
@@ -9725,6 +10821,8 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9725
10821
|
onTerminalPreflightVerified,
|
|
9726
10822
|
allowedPreMaterializationIdentity,
|
|
9727
10823
|
allowedAdditionalIdentities,
|
|
10824
|
+
observedHandoff,
|
|
10825
|
+
verifiedEmptyCodexHandoff,
|
|
9728
10826
|
continuingTurnResponse
|
|
9729
10827
|
}));
|
|
9730
10828
|
}
|
|
@@ -9906,7 +11004,18 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
9906
11004
|
scrollbackLines: Number(options.scrollbackLines ?? 120),
|
|
9907
11005
|
runtime: preSendRuntime
|
|
9908
11006
|
});
|
|
9909
|
-
|
|
11007
|
+
if (verifiedEmptyCodexHandoff) {
|
|
11008
|
+
if (executor.kind !== "codex" ||
|
|
11009
|
+
status.reachable !== true ||
|
|
11010
|
+
status.approval_state.blocked === true ||
|
|
11011
|
+
!["idle", "unknown"].includes(status.activity_state)) {
|
|
11012
|
+
throw new Error(`Codex verified-empty handoff is not at a safe prompt ` +
|
|
11013
|
+
`(${status.activity_state}: ${status.activity_reason})`);
|
|
11014
|
+
}
|
|
11015
|
+
}
|
|
11016
|
+
else {
|
|
11017
|
+
assertSafeTerminalSend(executor.kind, status);
|
|
11018
|
+
}
|
|
9910
11019
|
if (executor.kind === "codex" && needsPostSendNativeBinding) {
|
|
9911
11020
|
if (pendingManagedNativeBinding || allowedPreMaterializationIdentity) {
|
|
9912
11021
|
const expectedForegroundId = stringValue(sendTakeover?.terminal_agent_expected_session_id);
|
|
@@ -10276,6 +11385,44 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10276
11385
|
try {
|
|
10277
11386
|
await terminalBridge.send(executor.kind, terminalControl, terminalPayload, {
|
|
10278
11387
|
runtime: preSendRuntime,
|
|
11388
|
+
requireExactComposerBeforeEnter: observedHandoff !== undefined ||
|
|
11389
|
+
verifiedEmptyCodexHandoff !== undefined,
|
|
11390
|
+
beforeText: observedHandoff
|
|
11391
|
+
? async () => {
|
|
11392
|
+
await assertObservedHandoffTransportBoundary({
|
|
11393
|
+
options,
|
|
11394
|
+
terminal: observedHandoff.terminal,
|
|
11395
|
+
transition: observedHandoff.transition,
|
|
11396
|
+
requireEmptyComposer: true
|
|
11397
|
+
});
|
|
11398
|
+
}
|
|
11399
|
+
: verifiedEmptyCodexHandoff
|
|
11400
|
+
? async () => {
|
|
11401
|
+
await assertVerifiedEmptyCodexTransportBoundary({
|
|
11402
|
+
options,
|
|
11403
|
+
boundary: verifiedEmptyCodexHandoff,
|
|
11404
|
+
requireEmptyComposer: true
|
|
11405
|
+
});
|
|
11406
|
+
}
|
|
11407
|
+
: undefined,
|
|
11408
|
+
beforeEnter: observedHandoff
|
|
11409
|
+
? async () => {
|
|
11410
|
+
await assertObservedHandoffTransportBoundary({
|
|
11411
|
+
options,
|
|
11412
|
+
terminal: observedHandoff.terminal,
|
|
11413
|
+
transition: observedHandoff.transition,
|
|
11414
|
+
requireEmptyComposer: false
|
|
11415
|
+
});
|
|
11416
|
+
}
|
|
11417
|
+
: verifiedEmptyCodexHandoff
|
|
11418
|
+
? async () => {
|
|
11419
|
+
await assertVerifiedEmptyCodexTransportBoundary({
|
|
11420
|
+
options,
|
|
11421
|
+
boundary: verifiedEmptyCodexHandoff,
|
|
11422
|
+
requireEmptyComposer: false
|
|
11423
|
+
});
|
|
11424
|
+
}
|
|
11425
|
+
: undefined,
|
|
10279
11426
|
async onTransportStage(event) {
|
|
10280
11427
|
const stageAt = cliNow().toISOString();
|
|
10281
11428
|
if (event.stage === "text_injected") {
|
|
@@ -10316,6 +11463,21 @@ async function runTerminalControlSend({ options, conversation, nextConversation,
|
|
|
10316
11463
|
previous_generation_id: stringValue(previousDispatchLedger?.generation_id) ??
|
|
10317
11464
|
stringValue(previousDispatchLedger?.message_id)
|
|
10318
11465
|
});
|
|
11466
|
+
if (event.stage === "text_injected" && observedHandoff) {
|
|
11467
|
+
await assertObservedHandoffTransportBoundary({
|
|
11468
|
+
options,
|
|
11469
|
+
terminal: observedHandoff.terminal,
|
|
11470
|
+
transition: observedHandoff.transition,
|
|
11471
|
+
requireEmptyComposer: false
|
|
11472
|
+
});
|
|
11473
|
+
}
|
|
11474
|
+
if (event.stage === "text_injected" && verifiedEmptyCodexHandoff) {
|
|
11475
|
+
await assertVerifiedEmptyCodexTransportBoundary({
|
|
11476
|
+
options,
|
|
11477
|
+
boundary: verifiedEmptyCodexHandoff,
|
|
11478
|
+
requireEmptyComposer: false
|
|
11479
|
+
});
|
|
11480
|
+
}
|
|
10319
11481
|
try {
|
|
10320
11482
|
appendEvent(logPath, {
|
|
10321
11483
|
ts: stageAt,
|
|
@@ -11755,6 +12917,29 @@ async function resolveCurrentNativeAgentSessionIdentity({ options, agent, pid, c
|
|
|
11755
12917
|
evidence: "claude_agents_exact_pid"
|
|
11756
12918
|
};
|
|
11757
12919
|
}
|
|
12920
|
+
/**
|
|
12921
|
+
* Preserve the security-significant difference between a successful native
|
|
12922
|
+
* identity probe that found no current Session and a probe that could not be
|
|
12923
|
+
* completed. `undefined` from the Codex provider is authoritative only after
|
|
12924
|
+
* both the process-incarnation and open-rollout inspections succeeded.
|
|
12925
|
+
*/
|
|
12926
|
+
async function observeCurrentNativeAgentSessionIdentity(request) {
|
|
12927
|
+
try {
|
|
12928
|
+
const identity = await resolveCurrentNativeAgentSessionIdentity(request);
|
|
12929
|
+
return identity
|
|
12930
|
+
? { status: "resolved", identity }
|
|
12931
|
+
: {
|
|
12932
|
+
status: "verified_absent",
|
|
12933
|
+
evidence: "native_identity_resolver_verified_absent"
|
|
12934
|
+
};
|
|
12935
|
+
}
|
|
12936
|
+
catch (error) {
|
|
12937
|
+
return {
|
|
12938
|
+
status: "unavailable",
|
|
12939
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
12940
|
+
};
|
|
12941
|
+
}
|
|
12942
|
+
}
|
|
11758
12943
|
function nativeAgentIdentityMatchesTurn(conversation, currentIdentity) {
|
|
11759
12944
|
const takeover = isRecord(conversation.native_session_takeover)
|
|
11760
12945
|
? conversation.native_session_takeover
|
|
@@ -13263,9 +14448,185 @@ async function runTerminalControlCancel({ options, statePath, logPath, agent, te
|
|
|
13263
14448
|
}
|
|
13264
14449
|
}
|
|
13265
14450
|
}
|
|
14451
|
+
async function runObservedHandoffClose({ options, statePath, logPath, initialConversation }) {
|
|
14452
|
+
const expectedToken = required(stringValue(options.expectedHandoffToken), "--expected-handoff-token is required");
|
|
14453
|
+
if (stringValue(options.expectedMessageId) ||
|
|
14454
|
+
stringValue(options.expectedTransitionId)) {
|
|
14455
|
+
throw new Error("--expected-handoff-token cannot be combined with dispatch or lifecycle recovery tokens");
|
|
14456
|
+
}
|
|
14457
|
+
if (stringValue(options.reason) !== "superseded_by_human_context_switch") {
|
|
14458
|
+
throw new Error("a handoff close requires reason superseded_by_human_context_switch");
|
|
14459
|
+
}
|
|
14460
|
+
const storeDir = storeDirFromOptions(options);
|
|
14461
|
+
const sourceSessionId = sessionIdForConversation(initialConversation);
|
|
14462
|
+
const initialSource = loadManagedSession(storeDir, sourceSessionId);
|
|
14463
|
+
if (!initialSource.binding || initialSource.status !== "bound") {
|
|
14464
|
+
throw new Error("handoff source Session is no longer bound; refresh list");
|
|
14465
|
+
}
|
|
14466
|
+
const terminal = await createTerminalAgentBridge(options).resolveStoredTerminal(initialSource.agent, initialSource.binding.native_process.pid, initialSource.binding.terminal_control, { pid: initialSource.binding.native_process.pid });
|
|
14467
|
+
const releaseTerminalLock = acquireTerminalBridgeSendLock(storeDir, terminal.terminalControl, { timeoutMs: 30000 });
|
|
14468
|
+
try {
|
|
14469
|
+
await withStoreWriterLeaseAsync(storeDir, async () => {
|
|
14470
|
+
const releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
14471
|
+
try {
|
|
14472
|
+
const conversation = loadState(statePath);
|
|
14473
|
+
const turnId = turnIdForConversation(conversation);
|
|
14474
|
+
if (conversation.conversation_id !== initialConversation.conversation_id ||
|
|
14475
|
+
sessionIdForConversation(conversation) !== sourceSessionId ||
|
|
14476
|
+
!SESSION_SEND_BLOCKING_STATUSES.has(conversation.status)) {
|
|
14477
|
+
throw new Error("active handoff Turn changed after it was listed; refresh AKK list");
|
|
14478
|
+
}
|
|
14479
|
+
const source = loadManagedSession(storeDir, sourceSessionId);
|
|
14480
|
+
if (source.status !== "bound" ||
|
|
14481
|
+
!source.binding ||
|
|
14482
|
+
managedSessionHasUnresolvedNativeTransition(storeDir, source)) {
|
|
14483
|
+
throw new Error("handoff source Session changed after it was listed; refresh AKK list");
|
|
14484
|
+
}
|
|
14485
|
+
const companions = codexAllowedCompanionSetForManagedSession({
|
|
14486
|
+
storeDir,
|
|
14487
|
+
session: source
|
|
14488
|
+
});
|
|
14489
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
14490
|
+
options,
|
|
14491
|
+
agent: terminal.agent,
|
|
14492
|
+
pid: terminal.pid,
|
|
14493
|
+
cwd: terminal.terminalControl.currentPath,
|
|
14494
|
+
preferredSessionId: companions.primary
|
|
14495
|
+
? source.binding.native_thread_id
|
|
14496
|
+
: undefined,
|
|
14497
|
+
allowedCompanionIdentity: companions.primary,
|
|
14498
|
+
allowedAdditionalIdentities: companions.additional
|
|
14499
|
+
});
|
|
14500
|
+
const observed = await observedExternalHandoffIdentity({
|
|
14501
|
+
options,
|
|
14502
|
+
terminal,
|
|
14503
|
+
sourceSession: source,
|
|
14504
|
+
resolvedIdentity: resolved
|
|
14505
|
+
});
|
|
14506
|
+
if (!observed.identity ||
|
|
14507
|
+
managedBindingConflictKindForResolvedTerminal({
|
|
14508
|
+
storeDir,
|
|
14509
|
+
session: source,
|
|
14510
|
+
terminal,
|
|
14511
|
+
identity: observed.identity
|
|
14512
|
+
}) !== "live_external_thread_change") {
|
|
14513
|
+
throw new Error("live native thread no longer matches the listed handoff; refresh AKK list");
|
|
14514
|
+
}
|
|
14515
|
+
const targetNativeThreadId = observed.identity.sessionId.toLowerCase();
|
|
14516
|
+
const target = observedHandoffTargetResolution({
|
|
14517
|
+
storeDir,
|
|
14518
|
+
agent: terminal.agent,
|
|
14519
|
+
workspace: terminal.terminalControl.currentPath ?? cliCwd(),
|
|
14520
|
+
nativeThreadId: targetNativeThreadId,
|
|
14521
|
+
sourceSessionId
|
|
14522
|
+
});
|
|
14523
|
+
if (target.status !== "eligible") {
|
|
14524
|
+
throw new Error("handoff target Session changed after it was listed; refresh AKK list");
|
|
14525
|
+
}
|
|
14526
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
14527
|
+
options,
|
|
14528
|
+
agent: terminal.agent,
|
|
14529
|
+
currentPid: terminal.pid,
|
|
14530
|
+
nativeThreadId: targetNativeThreadId,
|
|
14531
|
+
storeDir,
|
|
14532
|
+
terminalControl: terminal.terminalControl,
|
|
14533
|
+
excludedManagedSessionId: target.snapshot.state === "detached"
|
|
14534
|
+
? target.snapshot.session_id
|
|
14535
|
+
: undefined
|
|
14536
|
+
});
|
|
14537
|
+
const handoffToken = observedHandoffAuthorityToken({
|
|
14538
|
+
terminal,
|
|
14539
|
+
identity: observed.identity,
|
|
14540
|
+
sourceSession: source,
|
|
14541
|
+
target: target.snapshot
|
|
14542
|
+
});
|
|
14543
|
+
const takeover = isRecord(conversation.native_session_takeover)
|
|
14544
|
+
? conversation.native_session_takeover
|
|
14545
|
+
: undefined;
|
|
14546
|
+
const expectedMessageId = stringValue(takeover?.terminal_bridge_message_id) ?? stringValue(terminalBridgeSubmission(conversation)?.message_id);
|
|
14547
|
+
const ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
14548
|
+
const exactDispatchGeneration = Boolean(expectedMessageId &&
|
|
14549
|
+
ledger &&
|
|
14550
|
+
!terminalDispatchLedgerLooksLifecycle(ledger) &&
|
|
14551
|
+
stringValue(ledger.conversation_id) ===
|
|
14552
|
+
conversation.conversation_id &&
|
|
14553
|
+
stringValue(ledger.session_id) === sourceSessionId &&
|
|
14554
|
+
stringValue(ledger.turn_id) === turnId &&
|
|
14555
|
+
stringValue(ledger.message_id) === expectedMessageId);
|
|
14556
|
+
const exactNoLedgerGeneration = Boolean(!expectedMessageId &&
|
|
14557
|
+
(!ledger || ledger.status === "resolved"));
|
|
14558
|
+
if (!exactDispatchGeneration && !exactNoLedgerGeneration) {
|
|
14559
|
+
throw new Error("active handoff dispatch generation changed; refresh AKK list");
|
|
14560
|
+
}
|
|
14561
|
+
const freshToken = activeTurnHandoffDecisionToken({
|
|
14562
|
+
handoffToken,
|
|
14563
|
+
turn: conversation,
|
|
14564
|
+
ledger
|
|
14565
|
+
});
|
|
14566
|
+
if (freshToken !== expectedToken) {
|
|
14567
|
+
throw new Error("active handoff snapshot changed; refresh AKK list before closing");
|
|
14568
|
+
}
|
|
14569
|
+
const now = cliNow().toISOString();
|
|
14570
|
+
const closed = {
|
|
14571
|
+
...conversation,
|
|
14572
|
+
status: "closed",
|
|
14573
|
+
closed_at: now,
|
|
14574
|
+
close_reason: "superseded_by_human_context_switch",
|
|
14575
|
+
disposition: "superseded_by_human_context_switch",
|
|
14576
|
+
updated_at: now
|
|
14577
|
+
};
|
|
14578
|
+
saveState(statePath, closed);
|
|
14579
|
+
const dispatchResolved = expectedMessageId
|
|
14580
|
+
? resolveTerminalBridgeDispatchLedger({
|
|
14581
|
+
terminalControl: terminal.terminalControl,
|
|
14582
|
+
conversation: closed,
|
|
14583
|
+
expectedMessageId,
|
|
14584
|
+
reason: "Turn superseded by a verified human context switch"
|
|
14585
|
+
})
|
|
14586
|
+
: false;
|
|
14587
|
+
if (expectedMessageId && !dispatchResolved) {
|
|
14588
|
+
throw new Error("active handoff dispatch changed during close; inspect before retrying");
|
|
14589
|
+
}
|
|
14590
|
+
appendEvent(logPath, {
|
|
14591
|
+
ts: now,
|
|
14592
|
+
conversation_id: conversation.conversation_id,
|
|
14593
|
+
event: "conversation_closed",
|
|
14594
|
+
status: "closed",
|
|
14595
|
+
reason: "superseded_by_human_context_switch",
|
|
14596
|
+
disposition: "superseded_by_human_context_switch",
|
|
14597
|
+
handoff_native_thread_id: targetNativeThreadId
|
|
14598
|
+
});
|
|
14599
|
+
runtimeLog("info", "conversation_closed", {
|
|
14600
|
+
conversation_id: conversation.conversation_id,
|
|
14601
|
+
status: "closed",
|
|
14602
|
+
reason: "superseded_by_human_context_switch",
|
|
14603
|
+
disposition: "superseded_by_human_context_switch",
|
|
14604
|
+
state_path: statePath,
|
|
14605
|
+
event_log_path: logPath
|
|
14606
|
+
});
|
|
14607
|
+
printJson({
|
|
14608
|
+
conversation: closed,
|
|
14609
|
+
closed: true,
|
|
14610
|
+
terminal_dispatch_resolved: dispatchResolved,
|
|
14611
|
+
handoff_disposition: "superseded_by_human_context_switch",
|
|
14612
|
+
next_action: "refresh list and use its follow-current send"
|
|
14613
|
+
});
|
|
14614
|
+
}
|
|
14615
|
+
finally {
|
|
14616
|
+
releaseStateLock();
|
|
14617
|
+
}
|
|
14618
|
+
});
|
|
14619
|
+
}
|
|
14620
|
+
finally {
|
|
14621
|
+
releaseTerminalLock();
|
|
14622
|
+
}
|
|
14623
|
+
}
|
|
13266
14624
|
async function runClose(options) {
|
|
13267
14625
|
const terminalConversation = await resolveTerminalConversationFromOptions(options);
|
|
13268
14626
|
if (terminalConversation) {
|
|
14627
|
+
if (stringValue(options.expectedHandoffToken)) {
|
|
14628
|
+
throw new Error("--expected-handoff-token cannot be used with raw terminal close");
|
|
14629
|
+
}
|
|
13269
14630
|
await runTerminalDispatchClose({
|
|
13270
14631
|
options,
|
|
13271
14632
|
terminalConversation
|
|
@@ -13273,6 +14634,15 @@ async function runClose(options) {
|
|
|
13273
14634
|
return;
|
|
13274
14635
|
}
|
|
13275
14636
|
const loaded = loadConversationFromOptions(options);
|
|
14637
|
+
if (stringValue(options.expectedHandoffToken)) {
|
|
14638
|
+
await runObservedHandoffClose({
|
|
14639
|
+
options,
|
|
14640
|
+
statePath: loaded.statePath,
|
|
14641
|
+
logPath: loaded.logPath,
|
|
14642
|
+
initialConversation: loaded.conversation
|
|
14643
|
+
});
|
|
14644
|
+
return;
|
|
14645
|
+
}
|
|
13276
14646
|
const { statePath, logPath } = loaded;
|
|
13277
14647
|
const nativeTakeover = isRecord(loaded.conversation.native_session_takeover)
|
|
13278
14648
|
? loaded.conversation.native_session_takeover
|
|
@@ -13286,11 +14656,18 @@ async function runClose(options) {
|
|
|
13286
14656
|
releaseStateLock = acquireFileLock(`${statePath}.lock`);
|
|
13287
14657
|
const conversation = loadState(statePath);
|
|
13288
14658
|
const now = cliNow().toISOString();
|
|
14659
|
+
const closeReason = options.reason ?? "closed by request";
|
|
14660
|
+
const handoffDisposition = closeReason === "superseded_by_human_context_switch"
|
|
14661
|
+
? closeReason
|
|
14662
|
+
: undefined;
|
|
13289
14663
|
const closed = {
|
|
13290
14664
|
...conversation,
|
|
13291
14665
|
status: "closed",
|
|
13292
14666
|
closed_at: now,
|
|
13293
|
-
close_reason:
|
|
14667
|
+
close_reason: closeReason,
|
|
14668
|
+
...(handoffDisposition
|
|
14669
|
+
? { disposition: handoffDisposition }
|
|
14670
|
+
: {}),
|
|
13294
14671
|
updated_at: now
|
|
13295
14672
|
};
|
|
13296
14673
|
saveState(statePath, closed);
|
|
@@ -15330,6 +16707,7 @@ function terminalDispatchLedgerLooksLifecycle(ledger) {
|
|
|
15330
16707
|
ledger.transition_id !== undefined ||
|
|
15331
16708
|
ledger.operation === "new_thread" ||
|
|
15332
16709
|
ledger.operation === "resume_thread" ||
|
|
16710
|
+
ledger.operation === "adopt_external_thread" ||
|
|
15333
16711
|
ledger.adapter_version !== undefined ||
|
|
15334
16712
|
ledger.command_fingerprint !== undefined ||
|
|
15335
16713
|
ledger.target_session_id !== undefined ||
|
|
@@ -15345,6 +16723,8 @@ function lifecycleLedgerFields(transition, storeDir) {
|
|
|
15345
16723
|
generation_id: transition.transition_id,
|
|
15346
16724
|
transition_id: transition.transition_id,
|
|
15347
16725
|
operation: transition.operation,
|
|
16726
|
+
origin: transition.origin,
|
|
16727
|
+
terminal_input_sent: transition.terminal_input_sent,
|
|
15348
16728
|
terminal_id: transition.terminal_id,
|
|
15349
16729
|
agent: transition.agent,
|
|
15350
16730
|
workspace: transition.workspace,
|
|
@@ -15778,6 +17158,7 @@ function reconcilePreparedTerminalDispatchLedger(terminalControl, ledger) {
|
|
|
15778
17158
|
const dispatcherPid = Number(ledger.dispatcher_pid);
|
|
15779
17159
|
if (Number.isSafeInteger(dispatcherPid) &&
|
|
15780
17160
|
dispatcherPid > 1 &&
|
|
17161
|
+
dispatcherPid !== cliPid() &&
|
|
15781
17162
|
isProcessAlive(dispatcherPid)) {
|
|
15782
17163
|
return ledger;
|
|
15783
17164
|
}
|
|
@@ -16115,9 +17496,16 @@ function terminalSubmissionProofRank(status, lastProven) {
|
|
|
16115
17496
|
return 0;
|
|
16116
17497
|
}
|
|
16117
17498
|
async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
16118
|
-
|
|
17499
|
+
let ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
16119
17500
|
if (!ledger || ledger.status === "resolved") {
|
|
16120
|
-
|
|
17501
|
+
ledger = rebuildObservedHandoffLedgerFromTransition({
|
|
17502
|
+
options,
|
|
17503
|
+
terminal,
|
|
17504
|
+
previousLedger: ledger
|
|
17505
|
+
});
|
|
17506
|
+
if (!ledger) {
|
|
17507
|
+
return;
|
|
17508
|
+
}
|
|
16121
17509
|
}
|
|
16122
17510
|
if (!terminalDispatchLedgerLooksLifecycle(ledger)) {
|
|
16123
17511
|
return;
|
|
@@ -16136,6 +17524,175 @@ async function recoverLifecycleFenceBeforeMutation({ options, terminal }) {
|
|
|
16136
17524
|
"expected-transition-id recovery action");
|
|
16137
17525
|
}
|
|
16138
17526
|
}
|
|
17527
|
+
function rebuildObservedHandoffLedgerFromTransition({ options, terminal, previousLedger }) {
|
|
17528
|
+
const storeDir = storeDirFromOptions(options);
|
|
17529
|
+
const candidates = listNativeThreadTransitions(storeDir).filter((transition) => transition.operation === "adopt_external_thread" &&
|
|
17530
|
+
transition.origin === "human_observed" &&
|
|
17531
|
+
transition.terminal_input_sent === false &&
|
|
17532
|
+
["prepared", "verified"].includes(transition.status) &&
|
|
17533
|
+
transition.agent === terminal.agent &&
|
|
17534
|
+
transition.before_binding?.native_process.pid === terminal.pid &&
|
|
17535
|
+
terminalControlAliasMatches(transition.terminal_id, transition.before_binding?.terminal_control, terminal.conversationId, terminal.terminalControl) &&
|
|
17536
|
+
matchesConfiguredWorkspace(transition.workspace, terminal.terminalControl.currentPath));
|
|
17537
|
+
if (candidates.length === 0) {
|
|
17538
|
+
return undefined;
|
|
17539
|
+
}
|
|
17540
|
+
if (candidates.length !== 1) {
|
|
17541
|
+
throw new Error(`terminal ${terminal.terminalControl.target} has multiple unresolved ` +
|
|
17542
|
+
"human-observed handoff transitions; refusing to infer one ledger owner");
|
|
17543
|
+
}
|
|
17544
|
+
const transition = candidates[0];
|
|
17545
|
+
if (previousLedger?.status === "resolved" &&
|
|
17546
|
+
stringValue(previousLedger.transition_id) === transition.transition_id) {
|
|
17547
|
+
throw new Error(`human-observed handoff transition ${transition.transition_id} is ` +
|
|
17548
|
+
"unresolved but its terminal ledger is already resolved");
|
|
17549
|
+
}
|
|
17550
|
+
const rebuilt = {
|
|
17551
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17552
|
+
status: transition.status,
|
|
17553
|
+
binding: transition.before_binding,
|
|
17554
|
+
terminal_control: {
|
|
17555
|
+
kind: terminal.terminalControl.kind,
|
|
17556
|
+
target: terminal.terminalControl.target,
|
|
17557
|
+
socket_path: terminal.terminalControl.socketPath ?? null,
|
|
17558
|
+
pane_pid: terminal.terminalControl.panePid ?? null,
|
|
17559
|
+
current_path: terminal.terminalControl.currentPath ?? null
|
|
17560
|
+
},
|
|
17561
|
+
...(hasCanonicalTerminalEndpoint(terminal.terminalControl)
|
|
17562
|
+
? {
|
|
17563
|
+
terminal_endpoint: terminalControlEvidence(terminal.terminalControl)
|
|
17564
|
+
}
|
|
17565
|
+
: {}),
|
|
17566
|
+
previous_generation_id: stringValue(previousLedger?.generation_id) ??
|
|
17567
|
+
stringValue(previousLedger?.message_id)
|
|
17568
|
+
};
|
|
17569
|
+
// Validate the complete transition/terminal/adapter relationship before
|
|
17570
|
+
// replacing a missing or older resolved ledger. Rebuilding a fence is a
|
|
17571
|
+
// Store-side recovery operation only and never sends terminal input.
|
|
17572
|
+
assertLifecycleLedgerMatchesTransition({
|
|
17573
|
+
options,
|
|
17574
|
+
terminal,
|
|
17575
|
+
ledger: rebuilt,
|
|
17576
|
+
transition,
|
|
17577
|
+
storeDir
|
|
17578
|
+
});
|
|
17579
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, rebuilt, {
|
|
17580
|
+
expectedTransitionId: null
|
|
17581
|
+
});
|
|
17582
|
+
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
17583
|
+
}
|
|
17584
|
+
async function recoverPreparedObservedHandoff({ options, terminal, ledger, transition, storeDir, now }) {
|
|
17585
|
+
if (transition.operation !== "adopt_external_thread" ||
|
|
17586
|
+
transition.status !== "prepared" ||
|
|
17587
|
+
ledger.status !== "prepared" ||
|
|
17588
|
+
!transition.source_session_id ||
|
|
17589
|
+
transition.source_expected_revision === undefined ||
|
|
17590
|
+
!transition.before_binding ||
|
|
17591
|
+
!transition.target_native_thread_id) {
|
|
17592
|
+
return transition;
|
|
17593
|
+
}
|
|
17594
|
+
let source = loadManagedSession(storeDir, transition.source_session_id);
|
|
17595
|
+
if (source.status === "bound" &&
|
|
17596
|
+
source.revision === transition.source_expected_revision &&
|
|
17597
|
+
JSON.stringify(source.binding) === JSON.stringify(transition.before_binding)) {
|
|
17598
|
+
source = saveManagedSession(storeDir, {
|
|
17599
|
+
...source,
|
|
17600
|
+
status: "transitioning",
|
|
17601
|
+
last_transition_id: transition.transition_id,
|
|
17602
|
+
updated_at: now
|
|
17603
|
+
}, { expectedRevision: managedSessionRevision(source) });
|
|
17604
|
+
}
|
|
17605
|
+
else if (source.status !== "transitioning" ||
|
|
17606
|
+
source.last_transition_id !== transition.transition_id ||
|
|
17607
|
+
source.revision !== transition.source_expected_revision + 1 ||
|
|
17608
|
+
JSON.stringify(source.binding) !== JSON.stringify(transition.before_binding)) {
|
|
17609
|
+
throw new Error("human-observed handoff source changed before recovery");
|
|
17610
|
+
}
|
|
17611
|
+
const before = transition.before_binding;
|
|
17612
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
17613
|
+
options,
|
|
17614
|
+
agent: terminal.agent,
|
|
17615
|
+
pid: terminal.pid,
|
|
17616
|
+
cwd: terminal.terminalControl.currentPath,
|
|
17617
|
+
preferredSessionId: transition.target_native_thread_id,
|
|
17618
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
17619
|
+
sessionId: transition.before_native_thread_id,
|
|
17620
|
+
processUuid: transition.before_process_uuid,
|
|
17621
|
+
processBirth: transition.before_process_birth,
|
|
17622
|
+
rollout: transition.before_process_rollout,
|
|
17623
|
+
evidence: before.native_process.evidence
|
|
17624
|
+
}),
|
|
17625
|
+
allowedAdditionalIdentities: []
|
|
17626
|
+
});
|
|
17627
|
+
const observed = await observedExternalHandoffIdentity({
|
|
17628
|
+
options,
|
|
17629
|
+
terminal,
|
|
17630
|
+
sourceSession: source,
|
|
17631
|
+
resolvedIdentity: resolved
|
|
17632
|
+
});
|
|
17633
|
+
const liveId = observed.identity?.sessionId.toLowerCase();
|
|
17634
|
+
if (liveId === transition.before_native_thread_id.toLowerCase()) {
|
|
17635
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
17636
|
+
...transition,
|
|
17637
|
+
status: "aborted",
|
|
17638
|
+
aborted_at: now,
|
|
17639
|
+
error: "recovery observed the exact source native thread"
|
|
17640
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
17641
|
+
restorePreparedLifecycleSource(storeDir, transition, now);
|
|
17642
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
17643
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17644
|
+
status: "resolved",
|
|
17645
|
+
resolved_at: now,
|
|
17646
|
+
reason: "human-observed handoff rolled back to its exact source"
|
|
17647
|
+
}, { expectedTransitionId: transition.transition_id });
|
|
17648
|
+
return transition;
|
|
17649
|
+
}
|
|
17650
|
+
if (!observed.identity ||
|
|
17651
|
+
liveId !== transition.target_native_thread_id.toLowerCase()) {
|
|
17652
|
+
throw new Error("human-observed handoff recovery found neither its exact source nor target");
|
|
17653
|
+
}
|
|
17654
|
+
const target = tryLoadManagedSession(storeDir, transition.target_session_id);
|
|
17655
|
+
if ((target?.revision ?? null) !== transition.target_expected_revision) {
|
|
17656
|
+
throw new Error("human-observed handoff target changed before recovery");
|
|
17657
|
+
}
|
|
17658
|
+
await assertNativeThreadHasExclusiveOwnership({
|
|
17659
|
+
options,
|
|
17660
|
+
agent: terminal.agent,
|
|
17661
|
+
currentPid: terminal.pid,
|
|
17662
|
+
nativeThreadId: transition.target_native_thread_id,
|
|
17663
|
+
storeDir,
|
|
17664
|
+
terminalControl: terminal.terminalControl,
|
|
17665
|
+
excludedManagedSessionId: target?.session_id
|
|
17666
|
+
});
|
|
17667
|
+
const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
|
|
17668
|
+
const afterBinding = terminalBindingFrom({
|
|
17669
|
+
terminalId: terminal.conversationId,
|
|
17670
|
+
terminalControl: terminal.terminalControl,
|
|
17671
|
+
pid: terminal.pid,
|
|
17672
|
+
nativeThreadId: transition.target_native_thread_id,
|
|
17673
|
+
processUuid: exact.processUuid,
|
|
17674
|
+
processBirth: exact.processBirth,
|
|
17675
|
+
rollout: exact.rollout,
|
|
17676
|
+
evidence: `${exact.evidence}+human_observed_recovery`,
|
|
17677
|
+
generation: (target?.binding?.generation ?? 0) + 1,
|
|
17678
|
+
now: new Date(now)
|
|
17679
|
+
});
|
|
17680
|
+
transition = saveNativeThreadTransition(storeDir, {
|
|
17681
|
+
...transition,
|
|
17682
|
+
status: "verified",
|
|
17683
|
+
verified_at: now,
|
|
17684
|
+
after_binding: afterBinding
|
|
17685
|
+
}, { expectedRevision: nativeThreadTransitionRevision(transition) });
|
|
17686
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
17687
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17688
|
+
status: "verified",
|
|
17689
|
+
binding: before
|
|
17690
|
+
}, {
|
|
17691
|
+
expectedTransitionId: transition.transition_id,
|
|
17692
|
+
expectedStatus: "prepared"
|
|
17693
|
+
});
|
|
17694
|
+
return transition;
|
|
17695
|
+
}
|
|
16139
17696
|
async function reconcileLifecycleDispatchLedger(options, terminal, ledger, authority = {
|
|
16140
17697
|
kind: "automatic"
|
|
16141
17698
|
}) {
|
|
@@ -16179,6 +17736,34 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
|
|
|
16179
17736
|
transition,
|
|
16180
17737
|
storeDir
|
|
16181
17738
|
});
|
|
17739
|
+
if (transition.operation === "adopt_external_thread" &&
|
|
17740
|
+
transition.status === "verified" &&
|
|
17741
|
+
ledger.status === "prepared") {
|
|
17742
|
+
saveLifecycleTerminalDispatchLedger(terminal.terminalControl, {
|
|
17743
|
+
...lifecycleLedgerFields(transition, storeDir),
|
|
17744
|
+
status: "verified",
|
|
17745
|
+
binding: transition.before_binding,
|
|
17746
|
+
previous_generation_id: stringValue(ledger.previous_generation_id)
|
|
17747
|
+
}, {
|
|
17748
|
+
expectedTransitionId: transition.transition_id,
|
|
17749
|
+
expectedStatus: "prepared"
|
|
17750
|
+
});
|
|
17751
|
+
ledger = loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
17752
|
+
}
|
|
17753
|
+
if (transition.operation === "adopt_external_thread" &&
|
|
17754
|
+
transition.status === "prepared") {
|
|
17755
|
+
transition = await recoverPreparedObservedHandoff({
|
|
17756
|
+
options,
|
|
17757
|
+
terminal,
|
|
17758
|
+
ledger,
|
|
17759
|
+
transition,
|
|
17760
|
+
storeDir,
|
|
17761
|
+
now
|
|
17762
|
+
});
|
|
17763
|
+
if (["aborted", "committed"].includes(transition.status)) {
|
|
17764
|
+
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
17765
|
+
}
|
|
17766
|
+
}
|
|
16182
17767
|
if (transition.status === "prepared" && ledger.status === "prepared") {
|
|
16183
17768
|
await assertNativeThreadHasExclusiveOwnership({
|
|
16184
17769
|
options,
|
|
@@ -16317,9 +17902,9 @@ async function reconcileLifecycleDispatchLedger(options, terminal, ledger, autho
|
|
|
16317
17902
|
}
|
|
16318
17903
|
}
|
|
16319
17904
|
function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, transition, storeDir }) {
|
|
16320
|
-
const operationTarget = transition.operation === "
|
|
16321
|
-
?
|
|
16322
|
-
:
|
|
17905
|
+
const operationTarget = transition.operation === "new_thread"
|
|
17906
|
+
? undefined
|
|
17907
|
+
: transition.target_native_thread_id;
|
|
16323
17908
|
const ledgerControl = isRecord(ledger.terminal_control)
|
|
16324
17909
|
? ledger.terminal_control
|
|
16325
17910
|
: undefined;
|
|
@@ -16328,8 +17913,12 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16328
17913
|
dispatching: ["prepared", "dispatching", "uncertain"],
|
|
16329
17914
|
submitted: ["dispatching", "submitted", "uncertain"],
|
|
16330
17915
|
uncertain: ["dispatching", "submitted", "uncertain"],
|
|
16331
|
-
verified:
|
|
16332
|
-
|
|
17916
|
+
verified: transition.operation === "adopt_external_thread"
|
|
17917
|
+
? ["prepared", "verified", "uncertain"]
|
|
17918
|
+
: ["submitted", "verified", "uncertain"],
|
|
17919
|
+
committed: transition.operation === "adopt_external_thread"
|
|
17920
|
+
? ["verified", "uncertain"]
|
|
17921
|
+
: ["submitted", "verified", "uncertain"],
|
|
16333
17922
|
aborted: transition.reconciled_outcome === "before"
|
|
16334
17923
|
? ["dispatching", "submitted", "uncertain"]
|
|
16335
17924
|
: ["prepared", "dispatching"]
|
|
@@ -16338,6 +17927,8 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16338
17927
|
stringValue(ledger.transition_id) !== transition.transition_id ||
|
|
16339
17928
|
stringValue(ledger.generation_id) !== transition.transition_id ||
|
|
16340
17929
|
stringValue(ledger.operation) !== transition.operation ||
|
|
17930
|
+
stringValue(ledger.origin) !== transition.origin ||
|
|
17931
|
+
ledger.terminal_input_sent !== transition.terminal_input_sent ||
|
|
16341
17932
|
stringValue(ledger.terminal_id) !== transition.terminal_id ||
|
|
16342
17933
|
stringValue(ledger.agent) !== transition.agent ||
|
|
16343
17934
|
stringValue(ledger.workspace) !== transition.workspace ||
|
|
@@ -16383,6 +17974,16 @@ function assertLifecycleLedgerMatchesTransition({ options, terminal, ledger, tra
|
|
|
16383
17974
|
const adapter = createRuntimeTerminalAgentRegistry(options)
|
|
16384
17975
|
.require(terminal.agent);
|
|
16385
17976
|
const capability = adapter.probeThreadLifecycle?.(currentVersion);
|
|
17977
|
+
if (transition.operation === "adopt_external_thread") {
|
|
17978
|
+
if (currentVersion !== transition.adapter_version ||
|
|
17979
|
+
capability?.status !== "supported" ||
|
|
17980
|
+
transition.origin !== "human_observed" ||
|
|
17981
|
+
transition.terminal_input_sent !== false ||
|
|
17982
|
+
transition.command_fingerprint !== HUMAN_OBSERVED_HANDOFF_FINGERPRINT) {
|
|
17983
|
+
throw new Error("human-observed handoff adapter profile changed during recovery");
|
|
17984
|
+
}
|
|
17985
|
+
return;
|
|
17986
|
+
}
|
|
16386
17987
|
const operation = transition.operation === "resume_thread"
|
|
16387
17988
|
? {
|
|
16388
17989
|
kind: "resume_thread",
|
|
@@ -16407,6 +18008,49 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
|
|
|
16407
18008
|
!terminalControlAliasMatches(binding.terminal_id, binding.terminal_control, terminal.conversationId, terminal.terminalControl)) {
|
|
16408
18009
|
throw new Error("verified after_binding no longer matches the terminal or pid");
|
|
16409
18010
|
}
|
|
18011
|
+
if (transition.operation === "adopt_external_thread") {
|
|
18012
|
+
if (transition.origin !== "human_observed" ||
|
|
18013
|
+
transition.terminal_input_sent !== false ||
|
|
18014
|
+
!transition.source_session_id ||
|
|
18015
|
+
!transition.before_binding) {
|
|
18016
|
+
throw new Error("human-observed handoff recovery evidence is incomplete");
|
|
18017
|
+
}
|
|
18018
|
+
const storeDir = storeDirFromOptions(options);
|
|
18019
|
+
const source = loadManagedSession(storeDir, transition.source_session_id);
|
|
18020
|
+
const resolved = await resolveCurrentNativeAgentSessionIdentity({
|
|
18021
|
+
options,
|
|
18022
|
+
agent: terminal.agent,
|
|
18023
|
+
pid: terminal.pid,
|
|
18024
|
+
cwd: terminal.terminalControl.currentPath,
|
|
18025
|
+
preferredSessionId: binding.native_thread_id,
|
|
18026
|
+
allowedCompanionIdentity: codexIdentityFence({
|
|
18027
|
+
sessionId: transition.before_native_thread_id,
|
|
18028
|
+
processUuid: transition.before_process_uuid,
|
|
18029
|
+
processBirth: transition.before_process_birth,
|
|
18030
|
+
rollout: transition.before_process_rollout,
|
|
18031
|
+
evidence: transition.before_binding.native_process.evidence
|
|
18032
|
+
}),
|
|
18033
|
+
allowedAdditionalIdentities: []
|
|
18034
|
+
});
|
|
18035
|
+
const observed = await observedExternalHandoffIdentity({
|
|
18036
|
+
options,
|
|
18037
|
+
terminal,
|
|
18038
|
+
sourceSession: source,
|
|
18039
|
+
resolvedIdentity: resolved
|
|
18040
|
+
});
|
|
18041
|
+
if (!observed.identity) {
|
|
18042
|
+
throw new Error("human-observed handoff target identity is unavailable during recovery");
|
|
18043
|
+
}
|
|
18044
|
+
const exact = exactLifecycleProcessIdentity(terminal, observed.identity);
|
|
18045
|
+
if (exact.sessionId.toLowerCase() !== binding.native_thread_id.toLowerCase() ||
|
|
18046
|
+
exact.processUuid !== binding.native_process.process_uuid ||
|
|
18047
|
+
exact.processBirth !== binding.native_process.process_birth ||
|
|
18048
|
+
JSON.stringify(exact.rollout ?? null) !==
|
|
18049
|
+
JSON.stringify(binding.native_process.rollout ?? null)) {
|
|
18050
|
+
throw new Error("human-observed handoff target identity changed during recovery");
|
|
18051
|
+
}
|
|
18052
|
+
return;
|
|
18053
|
+
}
|
|
16410
18054
|
const bridge = createTerminalAgentBridge(options);
|
|
16411
18055
|
const status = await bridge.status(terminal.agent, terminal.terminalControl, { runtime: terminalRuntimeForLiveIdentity({ terminal, physicalOnly: true }) });
|
|
16412
18056
|
if (!status.reachable ||
|
|
@@ -16481,6 +18125,9 @@ async function verifyRecoveredLifecycleAfterBinding({ options, terminal, transit
|
|
|
16481
18125
|
}
|
|
16482
18126
|
}
|
|
16483
18127
|
async function reconcileUncertainLifecycleTransition({ options, terminal, transition, storeDir, now }) {
|
|
18128
|
+
if (transition.operation === "adopt_external_thread") {
|
|
18129
|
+
throw new Error("uncertain human-observed handoff remains quarantined because recovery cannot send terminal input");
|
|
18130
|
+
}
|
|
16484
18131
|
const live = terminal.agent === "claude"
|
|
16485
18132
|
? await probeManualClaudeLifecycleRecoveryIdentity({
|
|
16486
18133
|
options,
|
|
@@ -16523,15 +18170,18 @@ async function reconcileUncertainLifecycleTransition({ options, terminal, transi
|
|
|
16523
18170
|
}, { expectedTransitionId: transition.transition_id });
|
|
16524
18171
|
return loadTerminalBridgeDispatchLedger(terminal.terminalControl);
|
|
16525
18172
|
}
|
|
16526
|
-
if (transition.operation
|
|
18173
|
+
if (!["resume_thread", "adopt_external_thread"].includes(transition.operation) ||
|
|
16527
18174
|
live.sessionId !== transition.target_native_thread_id) {
|
|
16528
18175
|
throw new Error("fresh native thread identity matches neither the recorded before identity " +
|
|
16529
18176
|
"nor a durably known exact after identity");
|
|
16530
18177
|
}
|
|
16531
|
-
if (
|
|
18178
|
+
if (transition.operation === "resume_thread" &&
|
|
18179
|
+
terminal.agent === "codex" &&
|
|
18180
|
+
!live.rollout) {
|
|
16532
18181
|
throw new Error("Codex resume recovery requires an exact live rollout incarnation");
|
|
16533
18182
|
}
|
|
16534
|
-
if (
|
|
18183
|
+
if (transition.operation === "resume_thread" &&
|
|
18184
|
+
terminal.agent === "codex") {
|
|
16535
18185
|
assertResumedCodexRolloutMatchesCandidate(live, transition.target_candidate_file_identity);
|
|
16536
18186
|
}
|
|
16537
18187
|
const targetAtPrepare = tryLoadManagedSession(storeDir, transition.target_session_id);
|
|
@@ -16819,7 +18469,9 @@ async function probeManualCodexLifecycleRecoveryIdentity({ options, terminal, tr
|
|
|
16819
18469
|
? "no_rollout"
|
|
16820
18470
|
: "invalid"
|
|
16821
18471
|
: classifyCodexLifecyclePostcondition({
|
|
16822
|
-
operation: transition.operation
|
|
18472
|
+
operation: transition.operation === "resume_thread"
|
|
18473
|
+
? "resume_thread"
|
|
18474
|
+
: "new_thread",
|
|
16823
18475
|
parsedNativeThreadId: observed.nativeThreadId,
|
|
16824
18476
|
observationSucceeded: true,
|
|
16825
18477
|
observedIdentity: resolved,
|
|
@@ -16997,14 +18649,7 @@ function claudeComposerVisible(screen) {
|
|
|
16997
18649
|
.some((line) => /^\s*❯(?:\s|$)/u.test(line));
|
|
16998
18650
|
}
|
|
16999
18651
|
function claudeComposerEmpty(screen) {
|
|
17000
|
-
|
|
17001
|
-
while (lines.length > 0 && lines.at(-1)?.trim() === "") {
|
|
17002
|
-
lines.pop();
|
|
17003
|
-
}
|
|
17004
|
-
const composers = lines
|
|
17005
|
-
.slice(-8)
|
|
17006
|
-
.filter((line) => /^\s*❯(?:\s|$)/u.test(line));
|
|
17007
|
-
return composers.length === 1 && /^\s*❯\s*$/u.test(composers[0]);
|
|
18652
|
+
return isExactClaudeIdleComposer(String(screen ?? ""));
|
|
17008
18653
|
}
|
|
17009
18654
|
function assertManualLifecycleProcessIncarnation(transition, live) {
|
|
17010
18655
|
if (live.processUuid !== transition.before_process_uuid ||
|
|
@@ -17118,7 +18763,9 @@ function commitVerifiedLifecycleTransition(storeDir, transition, now) {
|
|
|
17118
18763
|
status: "bound",
|
|
17119
18764
|
binding: afterBinding,
|
|
17120
18765
|
lineage: targetExisting?.lineage ?? {
|
|
17121
|
-
created_by: transition.operation
|
|
18766
|
+
created_by: transition.operation === "adopt_external_thread"
|
|
18767
|
+
? "human_observed"
|
|
18768
|
+
: transition.operation,
|
|
17122
18769
|
previous_session_id: transition.source_session_id,
|
|
17123
18770
|
resumed_from_native_thread_id: transition.operation === "resume_thread"
|
|
17124
18771
|
? transition.target_native_thread_id
|
|
@@ -20368,7 +22015,7 @@ function usage() {
|
|
|
20368
22015
|
agent-knock-knock delegate --request <text> [--agent ${agentList}] [--workspace <path>] [--store-dir <dir>]
|
|
20369
22016
|
agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all] [--reconcile] [--no-approval-scan] [--terminal-debug]
|
|
20370
22017
|
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>]
|
|
22018
|
+
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
22019
|
agent-knock-knock new-thread --terminal <exact-terminal-id> --expected-binding-token <token>
|
|
20373
22020
|
agent-knock-knock clear-thread --terminal <exact-terminal-id> --expected-binding-token <token>
|
|
20374
22021
|
agent-knock-knock list-resumable-threads --terminal <exact-terminal-id> [--selection-scope <opaque-scope>]
|
|
@@ -20381,7 +22028,7 @@ function usage() {
|
|
|
20381
22028
|
agent-knock-knock cancel [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20382
22029
|
agent-knock-knock renew [--turn <turn-id|selector>] [--conversation <selector>]
|
|
20383
22030
|
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>]
|
|
22031
|
+
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
22032
|
agent-knock-knock install-openclaw [--verify] [--openclaw-bin <path>] [--skill-path <path>] [--skill-only] [--no-restart]
|
|
20386
22033
|
agent-knock-knock doctor [--openclaw-bin <path>] [--tmux-bin <path>] [--herdr-bin <path>]
|
|
20387
22034
|
agent-knock-knock callback --state <file> --message-json <json> [--record-only]
|