@scotthuang/agent-knock-knock 0.1.2 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -5,24 +5,59 @@ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import process from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
+ import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
9
+ import { CodexStoreAdapter } from "./codex-store-adapter.js";
8
10
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
9
- import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor, sessionRecoveryStrategyForExecutor } from "./executors.js";
11
+ import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor } from "./executors.js";
10
12
  import { executorBootstrapPrompt } from "./bootstrap.js";
11
13
  import { writeRuntimeLog } from "./runtime-log.js";
12
14
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
13
15
  import { appendEvent, defaultStoreDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
16
+ import { planFork, planSafeResume, planTakeover } from "./session-takeover-planner.js";
14
17
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
15
18
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
16
19
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
20
+ class InlineCodexSessionAdapter {
21
+ threads;
22
+ processes;
23
+ processBatches;
24
+ processBatchIndex = 0;
25
+ rollouts;
26
+ constructor({ threads, processes, rollouts }) {
27
+ this.threads = Array.isArray(threads) ? threads : [];
28
+ this.processBatches = Array.isArray(processes?.[0])
29
+ ? processes
30
+ : [];
31
+ this.processes = Array.isArray(processes) && !Array.isArray(processes[0]) ? processes : [];
32
+ this.rollouts = new Map(Object.entries(rollouts ?? {}));
33
+ }
34
+ async listThreadRows() {
35
+ return this.threads;
36
+ }
37
+ async readRollout(rolloutPath) {
38
+ return this.rollouts.get(rolloutPath);
39
+ }
40
+ async listProcessSnapshots() {
41
+ if (this.processBatches.length > 0) {
42
+ const batch = this.processBatches[Math.min(this.processBatchIndex, this.processBatches.length - 1)];
43
+ this.processBatchIndex += 1;
44
+ return batch;
45
+ }
46
+ return this.processes;
47
+ }
48
+ }
17
49
  const command = process.argv[2];
18
- const args = parseArgs(process.argv.slice(3));
50
+ const rawArgs = process.argv.slice(3);
51
+ const args = command === "agent"
52
+ ? { agentCommand: rawArgs[0], ...parseArgs(rawArgs.slice(1)) }
53
+ : parseArgs(rawArgs);
19
54
  runtimeLog("info", "cli_start", {
20
55
  command: command ?? "help",
21
56
  cwd: process.cwd(),
22
57
  option_keys: Object.keys(args).sort()
23
58
  });
24
59
  try {
25
- runCommand(command, args);
60
+ await runCommand(command, args);
26
61
  runtimeLog("info", "cli_finish", {
27
62
  command: command ?? "help",
28
63
  exit_code: process.exitCode ?? 0
@@ -37,7 +72,7 @@ catch (error) {
37
72
  console.error(error.message);
38
73
  process.exit(1);
39
74
  }
40
- function runCommand(commandName, options) {
75
+ async function runCommand(commandName, options) {
41
76
  if (commandName === "new") {
42
77
  runNew(options);
43
78
  }
@@ -65,9 +100,6 @@ function runCommand(commandName, options) {
65
100
  else if (commandName === "recover") {
66
101
  runRecover(options);
67
102
  }
68
- else if (commandName === "restart") {
69
- runRestart(options);
70
- }
71
103
  else if (commandName === "close") {
72
104
  runClose(options);
73
105
  }
@@ -86,6 +118,9 @@ function runCommand(commandName, options) {
86
118
  else if (commandName === "monitor") {
87
119
  runMonitor(options);
88
120
  }
121
+ else if (commandName === "agent") {
122
+ await runAgent(options);
123
+ }
89
124
  else {
90
125
  usage();
91
126
  process.exitCode = commandName ? 1 : 0;
@@ -170,6 +205,495 @@ function runDoctor(options) {
170
205
  options
171
206
  });
172
207
  }
208
+ async function runAgent(options) {
209
+ const agentCommand = required(options.agentCommand, "agent subcommand is required: discover or takeover");
210
+ if (agentCommand === "discover") {
211
+ printJson(await runAgentDiscover(options));
212
+ return;
213
+ }
214
+ if (agentCommand === "takeover") {
215
+ printJson(await runAgentTakeover(options));
216
+ return;
217
+ }
218
+ throw new Error(`unsupported agent subcommand: ${agentCommand}`);
219
+ }
220
+ async function runAgentDiscover(options) {
221
+ const agent = required(options.agent, "--agent is required");
222
+ const scope = required(options.scope, "--scope is required");
223
+ const provider = createAgentSessionProvider(agent, options);
224
+ const capabilities = await provider.getCapabilities();
225
+ if (scope === "capabilities") {
226
+ return {
227
+ agent,
228
+ scope,
229
+ capabilities
230
+ };
231
+ }
232
+ if (scope === "sessions") {
233
+ return {
234
+ agent,
235
+ scope,
236
+ capabilities,
237
+ sessions: await provider.listHistoricalSessions()
238
+ };
239
+ }
240
+ if (scope === "active") {
241
+ return {
242
+ agent,
243
+ scope,
244
+ capabilities,
245
+ active: await provider.listActiveSessions()
246
+ };
247
+ }
248
+ throw new Error(`unsupported discover scope: ${scope}`);
249
+ }
250
+ async function runAgentTakeover(options) {
251
+ const agent = required(options.agent, "--agent is required");
252
+ const sessionId = required(options.sessionId, "--session-id is required");
253
+ const strategy = options.strategy ?? "terminate_then_resume";
254
+ const provider = createAgentSessionProvider(agent, options);
255
+ const session = await provider.getSession(sessionId);
256
+ if (!session) {
257
+ return {
258
+ agent,
259
+ sessionId,
260
+ strategy,
261
+ status: "blocked",
262
+ sideEffectsExecuted: false,
263
+ error: {
264
+ code: "session_not_found",
265
+ message: `No ${agent} session found for ${sessionId}`
266
+ }
267
+ };
268
+ }
269
+ if (strategy === "safe_resume") {
270
+ const plan = planSafeResume(session, await provider.listActiveSessions());
271
+ if (plan.allowed && options.createConversation) {
272
+ const modelInfo = await provider.getSessionModel(session.id);
273
+ const attached = createNativeSessionConversation({
274
+ agent,
275
+ strategy,
276
+ session,
277
+ modelInfo,
278
+ options
279
+ });
280
+ return {
281
+ agent,
282
+ sessionId,
283
+ strategy,
284
+ status: "attached",
285
+ sideEffectsExecuted: true,
286
+ plan,
287
+ ...attached
288
+ };
289
+ }
290
+ return {
291
+ agent,
292
+ sessionId,
293
+ strategy,
294
+ status: plan.allowed ? "ready" : "blocked",
295
+ sideEffectsExecuted: false,
296
+ plan
297
+ };
298
+ }
299
+ if (strategy === "terminate_then_resume") {
300
+ const activeSessions = await provider.listActiveSessions();
301
+ const plan = planTakeover(session, activeSessions);
302
+ if (options.confirmTerminate === true) {
303
+ const expectedPid = Number(required(options.expectedPid, "--expected-pid is required with --confirm-terminate"));
304
+ if (!Number.isInteger(expectedPid) || expectedPid <= 0) {
305
+ throw new Error("--expected-pid must be a positive integer");
306
+ }
307
+ if (!options.createConversation) {
308
+ throw new Error("--create-conversation is required with --confirm-terminate");
309
+ }
310
+ const targetSelection = selectTerminateTarget({
311
+ plan,
312
+ session,
313
+ activeSessions,
314
+ expectedPid,
315
+ allowCwdOnly: options.allowCwdOnly === true
316
+ });
317
+ if (!targetSelection.allowed) {
318
+ return {
319
+ agent,
320
+ sessionId,
321
+ strategy,
322
+ status: "blocked",
323
+ sideEffectsExecuted: false,
324
+ plan,
325
+ error: {
326
+ code: targetSelection.code,
327
+ message: targetSelection.message
328
+ }
329
+ };
330
+ }
331
+ const { target, matchKind } = targetSelection;
332
+ const termination = terminateProcessTarget(target, {
333
+ timeoutMs: Number(options.terminateTimeoutMs ?? 3000)
334
+ });
335
+ const activeAfterTermination = await provider.listActiveSessions();
336
+ const afterTerminationPlan = planTakeover(session, activeAfterTermination);
337
+ if (afterTerminationPlan.targets.some((candidate) => candidate.sessionId === session.id || candidate.pid === expectedPid)) {
338
+ return {
339
+ agent,
340
+ sessionId,
341
+ strategy,
342
+ status: "blocked",
343
+ sideEffectsExecuted: true,
344
+ plan: afterTerminationPlan,
345
+ termination,
346
+ error: {
347
+ code: "target_still_active",
348
+ message: `Codex process ${expectedPid} still appears active after termination.`
349
+ }
350
+ };
351
+ }
352
+ const modelInfo = await provider.getSessionModel(session.id);
353
+ const attached = createNativeSessionConversation({
354
+ agent,
355
+ strategy,
356
+ session,
357
+ modelInfo,
358
+ options,
359
+ takeoverMatchKind: matchKind
360
+ });
361
+ return {
362
+ agent,
363
+ sessionId,
364
+ strategy,
365
+ status: "attached",
366
+ sideEffectsExecuted: true,
367
+ plan,
368
+ termination,
369
+ matchKind,
370
+ ...attached
371
+ };
372
+ }
373
+ return {
374
+ agent,
375
+ sessionId,
376
+ strategy,
377
+ status: plan.requiresConfirmation ? "requires_confirmation" : "blocked",
378
+ sideEffectsExecuted: false,
379
+ plan
380
+ };
381
+ }
382
+ if (strategy === "fork") {
383
+ const contextPackage = await provider.getForkContext({
384
+ sessionId,
385
+ maxMessages: Number(options.maxMessages ?? 12),
386
+ maxCommands: Number(options.maxCommands ?? 8),
387
+ maxTextLength: Number(options.maxTextLength ?? 1200)
388
+ });
389
+ if (!contextPackage) {
390
+ return {
391
+ agent,
392
+ sessionId,
393
+ strategy,
394
+ status: "blocked",
395
+ sideEffectsExecuted: false,
396
+ error: {
397
+ code: "fork_context_unavailable",
398
+ message: `No fork context could be built for ${sessionId}`
399
+ }
400
+ };
401
+ }
402
+ if (options.createConversation) {
403
+ const forkSummary = String(required(options.forkSummary ?? options.summary, "--fork-summary is required when creating a fork conversation"));
404
+ const modelInfo = await provider.getSessionModel(session.id);
405
+ const attached = createForkConversation({
406
+ agent,
407
+ strategy,
408
+ session,
409
+ contextPackage,
410
+ forkSummary,
411
+ modelInfo,
412
+ options
413
+ });
414
+ return {
415
+ agent,
416
+ sessionId,
417
+ strategy,
418
+ status: "forked",
419
+ sideEffectsExecuted: true,
420
+ plan: planFork(session, contextPackage),
421
+ ...attached
422
+ };
423
+ }
424
+ return {
425
+ agent,
426
+ sessionId,
427
+ strategy,
428
+ status: "awaiting_openclaw_summary",
429
+ sideEffectsExecuted: false,
430
+ plan: planFork(session, contextPackage),
431
+ summaryPrompt: buildForkSummaryPrompt({ agent, session, contextPackage }),
432
+ nextAction: {
433
+ actor: "openclaw",
434
+ action: "summarize_and_confirm_fork",
435
+ instructions: [
436
+ "Summarize plan.contextPackage for the user before creating a forked AKK-managed session.",
437
+ "Do not inject the raw rollout or full contextPackage into the new coding agent.",
438
+ "Ask the user to confirm the summary.",
439
+ "After confirmation, call this tool again with strategy=fork, createConversation=true, and forkSummary set to the confirmed summary."
440
+ ],
441
+ followUpTool: "agent_knock_knock_agent_takeover",
442
+ followUpParams: {
443
+ agent,
444
+ sessionId,
445
+ strategy: "fork",
446
+ createConversation: true,
447
+ forkSummary: "<confirmed OpenClaw summary>"
448
+ }
449
+ },
450
+ next: "Use summaryPrompt to summarize the bounded context package for the user, ask for confirmation, then create the forked AKK-managed session with forkSummary."
451
+ };
452
+ }
453
+ throw new Error(`unsupported takeover strategy: ${strategy}`);
454
+ }
455
+ function buildForkSummaryPrompt({ agent, session, contextPackage }) {
456
+ return [
457
+ "You are OpenClaw summarizing a bounded native coding-agent session context before Agent Knock Knock forks it into a new managed session.",
458
+ "",
459
+ "Goal:",
460
+ "- Produce a concise, user-reviewable summary that can be safely injected into a new AKK-managed coding-agent session after the user confirms it.",
461
+ "- The new session must use the summary only; do not pass raw rollout history or the full context package to the coding agent.",
462
+ "",
463
+ "Source:",
464
+ `- Agent: ${agent}`,
465
+ `- Session id: ${session.id}`,
466
+ `- Workspace: ${session.cwd}`,
467
+ `- Title: ${session.title ?? session.preview ?? session.firstUserMessage ?? "(unknown)"}`,
468
+ `- Context messages included: ${contextPackage.messages.length}`,
469
+ `- Commands included: ${contextPackage.commands.length}`,
470
+ `- Context truncated: ${contextPackage.truncated ? "yes" : "no"}`,
471
+ "",
472
+ "Summary format:",
473
+ "1. Original user goal",
474
+ "2. Work already completed",
475
+ "3. Current state and important findings",
476
+ "4. Constraints, risks, or files/workspace details the forked agent must preserve",
477
+ "5. Recommended next step for the forked agent",
478
+ "",
479
+ "After writing the summary, ask the user to confirm. If confirmed, call agent_knock_knock_agent_takeover with strategy=\"fork\", createConversation=true, and forkSummary equal to the confirmed summary."
480
+ ].join("\n");
481
+ }
482
+ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, allowCwdOnly }) {
483
+ const exactTarget = plan.targets.find((candidate) => candidate.pid === expectedPid && candidate.sessionId === session.id);
484
+ if (exactTarget) {
485
+ return {
486
+ allowed: true,
487
+ target: exactTarget,
488
+ matchKind: "exact_session"
489
+ };
490
+ }
491
+ if (!allowCwdOnly) {
492
+ return {
493
+ allowed: false,
494
+ code: plan.allowed && plan.requiresConfirmation ? "expected_pid_mismatch" : "takeover_not_confirmable",
495
+ message: plan.allowed && plan.requiresConfirmation
496
+ ? `Expected pid ${expectedPid} is no longer the exact active Codex process for session ${session.id}.`
497
+ : "The current active Codex process no longer has an exact session match that can be safely terminated."
498
+ };
499
+ }
500
+ const cwdOnlyTarget = plan.targets.find((candidate) => candidate.pid === expectedPid &&
501
+ candidate.cwd === session.cwd &&
502
+ candidate.sessionId === undefined);
503
+ const stillActive = activeSessions.some((candidate) => candidate.pid === expectedPid &&
504
+ candidate.cwd === session.cwd &&
505
+ candidate.sessionId === undefined);
506
+ if (!cwdOnlyTarget || !stillActive) {
507
+ return {
508
+ allowed: false,
509
+ code: "expected_pid_mismatch",
510
+ message: `Expected pid ${expectedPid} is no longer an active Codex process in ${session.cwd}.`
511
+ };
512
+ }
513
+ return {
514
+ allowed: true,
515
+ target: cwdOnlyTarget,
516
+ matchKind: "cwd_only_confirmed"
517
+ };
518
+ }
519
+ function createForkConversation({ agent, strategy, session, contextPackage, forkSummary, modelInfo, options }) {
520
+ const workspace = session.cwd;
521
+ const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
522
+ cleanupIdleConversations(storeDir, options);
523
+ const executor = resolveExecutor({
524
+ kind: agent,
525
+ session: options.session ?? options.executorSession ?? uniqueDelegateSessionName(agent)
526
+ });
527
+ const now = new Date();
528
+ const conversation = createConversation({
529
+ userRequest: options.request ?? `Fork native ${agent} session ${session.id}`,
530
+ workspace,
531
+ openclawSession: options.openclawSession ?? "agent:main:main",
532
+ executorKind: executor.kind,
533
+ executorSession: executor.session,
534
+ softLimit: Number(options.softLimit ?? 50),
535
+ hardLimit: Number(options.hardLimit ?? 100),
536
+ now
537
+ });
538
+ const paths = pathsForConversation(conversation.conversation_id, storeDir);
539
+ const callbackCommand = options.callbackCommand
540
+ ? expandCallbackCommandTemplate(options.callbackCommand, { statePath: paths.statePath })
541
+ : buildCallbackCommand({
542
+ statePath: paths.statePath,
543
+ gatewayUrl: options.gatewayUrl ?? "ws://127.0.0.1:18789",
544
+ token: options.token,
545
+ openclawSession: options.openclawSession ?? "agent:main:main",
546
+ gatewayMethod: options.gatewayMethod,
547
+ gatewaySession: options.gatewaySession,
548
+ openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
549
+ });
550
+ const explicitModel = options.model ?? options.codexModel;
551
+ const executorModel = explicitModel ?? modelInfo?.acpxModel ?? modelEnvForExecutor(executor, process.env);
552
+ const forkedConversation = withStoragePaths({
553
+ ...conversation,
554
+ executor,
555
+ status: "idle",
556
+ idle_since: now.toISOString(),
557
+ updated_at: now.toISOString(),
558
+ callback_command: callbackCommand,
559
+ gateway_url: options.gatewayUrl ?? "ws://127.0.0.1:18789",
560
+ gateway_method: options.gatewayMethod,
561
+ gateway_session: options.gatewaySession ?? options.openclawSession ?? "agent:main:main",
562
+ openclaw_bin: options.openclawBin ?? resolveOptionalExecutable("openclaw"),
563
+ executor_all_proxy: proxyForExecutor(executor, options),
564
+ executor_model: executorModel,
565
+ fork_context_takeover: {
566
+ agent,
567
+ source_session_id: session.id,
568
+ source_cwd: session.cwd,
569
+ source_title: session.title,
570
+ source_updated_at_ms: session.updatedAtMs,
571
+ strategy,
572
+ forked_at: now.toISOString(),
573
+ summary: forkSummary,
574
+ context_message_count: contextPackage.messages.length,
575
+ context_command_count: contextPackage.commands.length,
576
+ context_truncated: contextPackage.truncated,
577
+ native_model: modelInfo?.model,
578
+ acpx_model: modelInfo?.acpxModel,
579
+ model_source: modelInfo?.source,
580
+ needs_bootstrap: true
581
+ }
582
+ }, paths);
583
+ saveState(paths.statePath, forkedConversation);
584
+ appendEvent(paths.logPath, {
585
+ ts: now.toISOString(),
586
+ conversation_id: forkedConversation.conversation_id,
587
+ event: "native_session_forked",
588
+ agent,
589
+ strategy,
590
+ source_session_id: session.id,
591
+ source_cwd: session.cwd,
592
+ executor,
593
+ context_message_count: contextPackage.messages.length,
594
+ context_command_count: contextPackage.commands.length,
595
+ context_truncated: contextPackage.truncated
596
+ });
597
+ runtimeLog("info", "native_session_forked", {
598
+ conversation_id: forkedConversation.conversation_id,
599
+ agent,
600
+ strategy,
601
+ source_session_id: session.id,
602
+ executor_session: executor.session,
603
+ state_path: paths.statePath,
604
+ event_log_path: paths.logPath
605
+ });
606
+ return {
607
+ conversation: forkedConversation,
608
+ paths,
609
+ next: `Use AKK send ${forkedConversation.conversation_id}: <message> to start the forked ${agent} session with the approved summary.`
610
+ };
611
+ }
612
+ function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy }) {
613
+ const workspace = session.cwd;
614
+ const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
615
+ cleanupIdleConversations(storeDir, options);
616
+ const executor = resolveExecutor({
617
+ kind: agent,
618
+ session: session.id
619
+ });
620
+ const now = new Date();
621
+ const conversation = createConversation({
622
+ userRequest: options.request ?? `Attach native ${agent} session ${session.id}`,
623
+ workspace,
624
+ openclawSession: options.openclawSession ?? "agent:main:main",
625
+ executorKind: executor.kind,
626
+ executorSession: executor.session,
627
+ softLimit: Number(options.softLimit ?? 50),
628
+ hardLimit: Number(options.hardLimit ?? 100),
629
+ now
630
+ });
631
+ const paths = pathsForConversation(conversation.conversation_id, storeDir);
632
+ const callbackCommand = options.callbackCommand
633
+ ? expandCallbackCommandTemplate(options.callbackCommand, { statePath: paths.statePath })
634
+ : buildCallbackCommand({
635
+ statePath: paths.statePath,
636
+ gatewayUrl: options.gatewayUrl ?? "ws://127.0.0.1:18789",
637
+ token: options.token,
638
+ openclawSession: options.openclawSession ?? "agent:main:main",
639
+ gatewayMethod: options.gatewayMethod,
640
+ gatewaySession: options.gatewaySession,
641
+ openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
642
+ });
643
+ const explicitModel = options.model ?? options.codexModel;
644
+ const executorModel = explicitModel ?? modelInfo?.acpxModel ?? modelEnvForExecutor(executor, process.env);
645
+ const attachedConversation = withStoragePaths({
646
+ ...conversation,
647
+ executor,
648
+ status: "idle",
649
+ idle_since: now.toISOString(),
650
+ updated_at: now.toISOString(),
651
+ callback_command: callbackCommand,
652
+ gateway_url: options.gatewayUrl ?? "ws://127.0.0.1:18789",
653
+ gateway_method: options.gatewayMethod,
654
+ gateway_session: options.gatewaySession ?? options.openclawSession ?? "agent:main:main",
655
+ openclaw_bin: options.openclawBin ?? resolveOptionalExecutable("openclaw"),
656
+ executor_all_proxy: proxyForExecutor(executor, options),
657
+ executor_model: executorModel,
658
+ native_session_takeover: {
659
+ agent,
660
+ native_session_id: session.id,
661
+ source_cwd: session.cwd,
662
+ source_title: session.title,
663
+ strategy,
664
+ attached_at: now.toISOString(),
665
+ native_model: modelInfo?.model,
666
+ acpx_model: modelInfo?.acpxModel,
667
+ model_source: modelInfo?.source,
668
+ takeover_match_kind: takeoverMatchKind,
669
+ needs_bootstrap: true
670
+ }
671
+ }, paths);
672
+ saveState(paths.statePath, attachedConversation);
673
+ appendEvent(paths.logPath, {
674
+ ts: now.toISOString(),
675
+ conversation_id: attachedConversation.conversation_id,
676
+ event: "native_session_attached",
677
+ agent,
678
+ strategy,
679
+ native_session_id: session.id,
680
+ source_cwd: session.cwd,
681
+ executor
682
+ });
683
+ runtimeLog("info", "native_session_attached", {
684
+ conversation_id: attachedConversation.conversation_id,
685
+ agent,
686
+ strategy,
687
+ native_session_id: session.id,
688
+ state_path: paths.statePath,
689
+ event_log_path: paths.logPath
690
+ });
691
+ return {
692
+ conversation: attachedConversation,
693
+ paths,
694
+ next: `Use AKK send ${attachedConversation.conversation_id}: <message> to continue this native ${agent} session through AKK.`
695
+ };
696
+ }
173
697
  function runNew(options) {
174
698
  const request = required(options.request, "--request is required");
175
699
  const workspace = options.workspace ?? process.cwd();
@@ -489,8 +1013,15 @@ function runDelegate(options) {
489
1013
  note: "Run again with --send to send this task through acpx."
490
1014
  });
491
1015
  }
492
- function ensureExecutorSession({ acpxPath, executor, cwd, env }) {
493
- return spawnSync(acpxPath, [acpxCommandForExecutor(executor), "sessions", "ensure", "--name", executor.session], {
1016
+ function ensureExecutorSession({ acpxPath, executor, cwd, env, resumeSessionId }) {
1017
+ const args = [acpxCommandForExecutor(executor), "sessions", "ensure"];
1018
+ if (resumeSessionId) {
1019
+ args.push("--resume-session", resumeSessionId);
1020
+ }
1021
+ else {
1022
+ args.push("--name", executor.session);
1023
+ }
1024
+ return spawnSync(acpxPath, args, {
494
1025
  encoding: "utf8",
495
1026
  cwd,
496
1027
  env
@@ -620,10 +1151,21 @@ function runSend(options) {
620
1151
  throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
621
1152
  }
622
1153
  if (conversation.status === "needs_recovery") {
623
- throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, restart, or close first`);
1154
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, close, or delegate a new task first`);
1155
+ }
1156
+ if (conversation.status === "needs_model_selection" && !options.model) {
1157
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
624
1158
  }
625
1159
  const executor = executorForConversation(conversation);
626
1160
  const type = options.type ?? (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
1161
+ const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
1162
+ ? conversation.native_session_takeover
1163
+ : undefined;
1164
+ const forkTakeoverForSend = isRecord(conversation.fork_context_takeover)
1165
+ ? conversation.fork_context_takeover
1166
+ : undefined;
1167
+ const needsNativeTakeoverBootstrap = nativeTakeoverForSend?.["needs_bootstrap"] === true;
1168
+ const needsForkTakeoverBootstrap = forkTakeoverForSend?.["needs_bootstrap"] === true;
627
1169
  const message = createMessage({
628
1170
  conversation,
629
1171
  from: "openclaw",
@@ -635,10 +1177,21 @@ function runSend(options) {
635
1177
  executor_session: executor.session
636
1178
  }
637
1179
  });
1180
+ const previousModelSelection = isRecord(conversation.model_selection)
1181
+ ? conversation.model_selection
1182
+ : {};
638
1183
  const nextConversation = {
639
1184
  ...applyMessageToConversation(conversation, message),
640
1185
  executor,
641
- claude_session: executor.kind === "claude" ? executor.session : conversation.claude_session
1186
+ claude_session: executor.kind === "claude" ? executor.session : conversation.claude_session,
1187
+ executor_model: options.model ?? conversation.executor_model,
1188
+ model_selection: conversation.status === "needs_model_selection"
1189
+ ? {
1190
+ ...previousModelSelection,
1191
+ resolved_at: new Date().toISOString(),
1192
+ selected_model: options.model
1193
+ }
1194
+ : conversation.model_selection
642
1195
  };
643
1196
  saveState(statePath, nextConversation);
644
1197
  appendEvent(logPath, messageEvent(message));
@@ -651,10 +1204,34 @@ function runSend(options) {
651
1204
  event_log_path: logPath,
652
1205
  message: textSummary(messageBody)
653
1206
  });
654
- const acpxPath = resolveExecutable("acpx");
655
1207
  const executorEnv = environmentForExecutor(executor, {
656
1208
  allProxy: options.allProxy ?? conversation.executor_all_proxy
657
1209
  });
1210
+ const payload = buildAgentSendPayload({
1211
+ conversation,
1212
+ executor,
1213
+ message,
1214
+ includeNativeTakeoverBootstrap: needsNativeTakeoverBootstrap,
1215
+ includeForkTakeoverBootstrap: needsForkTakeoverBootstrap,
1216
+ forkTakeover: forkTakeoverForSend
1217
+ });
1218
+ if (nativeTakeoverForSend?.["native_session_id"] && executor.kind === "codex") {
1219
+ runNativeCodexResumeSend({
1220
+ options,
1221
+ conversation,
1222
+ nextConversation,
1223
+ statePath,
1224
+ logPath,
1225
+ executor,
1226
+ executorEnv,
1227
+ message,
1228
+ payload,
1229
+ nativeTakeover: nativeTakeoverForSend,
1230
+ needsNativeTakeoverBootstrap
1231
+ });
1232
+ return;
1233
+ }
1234
+ const acpxPath = resolveExecutable("acpx");
658
1235
  const executorModel = modelForExecutor(executor, {
659
1236
  model: options.model ?? conversation.executor_model
660
1237
  });
@@ -662,7 +1239,8 @@ function runSend(options) {
662
1239
  acpxPath,
663
1240
  executor,
664
1241
  cwd: conversation.workspace ?? process.cwd(),
665
- env: executorEnv
1242
+ env: executorEnv,
1243
+ resumeSessionId: nativeTakeoverForSend?.["native_session_id"]
666
1244
  });
667
1245
  appendEvent(logPath, {
668
1246
  ts: new Date().toISOString(),
@@ -683,7 +1261,7 @@ function runSend(options) {
683
1261
  stderr: textSummary(cleanProcessText(ensureSession.stderr))
684
1262
  });
685
1263
  if (ensureSession.error) {
686
- if (requiresExplicitRecoveryDecision(executor, options)) {
1264
+ if (requiresExplicitRecoveryDecision(options)) {
687
1265
  printJson(markConversationNeedsRecovery({
688
1266
  conversation: nextConversation,
689
1267
  statePath,
@@ -696,10 +1274,22 @@ function runSend(options) {
696
1274
  }));
697
1275
  return;
698
1276
  }
699
- throw new Error(`acpx ${executor.kind} session ensure failed to start: ${ensureSession.error.message}`);
1277
+ autoRecoverSendFailure({
1278
+ options,
1279
+ conversation: nextConversation,
1280
+ statePath,
1281
+ logPath,
1282
+ executor,
1283
+ message,
1284
+ failedStage: "session_ensure",
1285
+ result: ensureSession,
1286
+ reason: `acpx ${executor.kind} session ensure failed to start: ${ensureSession.error.message}`
1287
+ });
1288
+ return;
700
1289
  }
701
1290
  if (ensureSession.status !== 0) {
702
- if (requiresExplicitRecoveryDecision(executor, options)) {
1291
+ const reason = cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`);
1292
+ if (requiresExplicitRecoveryDecision(options)) {
703
1293
  printJson(markConversationNeedsRecovery({
704
1294
  conversation: nextConversation,
705
1295
  statePath,
@@ -708,19 +1298,23 @@ function runSend(options) {
708
1298
  message,
709
1299
  failedStage: "session_ensure",
710
1300
  result: ensureSession,
711
- reason: cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`)
1301
+ reason
712
1302
  }));
713
1303
  return;
714
1304
  }
715
- throw new Error(cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`));
1305
+ autoRecoverSendFailure({
1306
+ options,
1307
+ conversation: nextConversation,
1308
+ statePath,
1309
+ logPath,
1310
+ executor,
1311
+ message,
1312
+ failedStage: "session_ensure",
1313
+ result: ensureSession,
1314
+ reason
1315
+ });
1316
+ return;
716
1317
  }
717
- const payload = [
718
- "Continue the existing Agent Knock Knock delegation using this structured OpenClaw message.",
719
- "If this message answers a question or blocker, follow it as the product decision.",
720
- "Continue to report back only through the callback command already provided for this conversation.",
721
- "",
722
- JSON.stringify(message)
723
- ].join("\n");
724
1318
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
725
1319
  if (options.background) {
726
1320
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-followup-output.log`);
@@ -766,8 +1360,16 @@ function runSend(options) {
766
1360
  executor_pid: child.pid ?? null,
767
1361
  agent_timeout_minutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES)
768
1362
  });
769
- printJson({
1363
+ const deliveredConversation = markTakeoverBootstrapped({
770
1364
  conversation: nextConversation,
1365
+ statePath,
1366
+ logPath,
1367
+ executor,
1368
+ native: needsNativeTakeoverBootstrap,
1369
+ fork: needsForkTakeoverBootstrap
1370
+ });
1371
+ printJson({
1372
+ conversation: deliveredConversation,
771
1373
  message,
772
1374
  delivered: true,
773
1375
  background: true,
@@ -804,7 +1406,7 @@ function runSend(options) {
804
1406
  stderr: textSummary(cleanProcessText(sendResult.stderr))
805
1407
  });
806
1408
  if (sendResult.error) {
807
- if (requiresExplicitRecoveryDecision(executor, options)) {
1409
+ if (requiresExplicitRecoveryDecision(options)) {
808
1410
  printJson(markConversationNeedsRecovery({
809
1411
  conversation: nextConversation,
810
1412
  statePath,
@@ -817,10 +1419,22 @@ function runSend(options) {
817
1419
  }));
818
1420
  return;
819
1421
  }
820
- throw new Error(`acpx ${executor.kind} send failed to start: ${sendResult.error.message}`);
1422
+ autoRecoverSendFailure({
1423
+ options,
1424
+ conversation: nextConversation,
1425
+ statePath,
1426
+ logPath,
1427
+ executor,
1428
+ message,
1429
+ failedStage: "message_send",
1430
+ result: sendResult,
1431
+ reason: `acpx ${executor.kind} send failed to start: ${sendResult.error.message}`
1432
+ });
1433
+ return;
821
1434
  }
822
1435
  if (sendResult.status !== 0) {
823
- if (requiresExplicitRecoveryDecision(executor, options)) {
1436
+ const reason = cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`);
1437
+ if (requiresExplicitRecoveryDecision(options)) {
824
1438
  printJson(markConversationNeedsRecovery({
825
1439
  conversation: nextConversation,
826
1440
  statePath,
@@ -829,25 +1443,350 @@ function runSend(options) {
829
1443
  message,
830
1444
  failedStage: "message_send",
831
1445
  result: sendResult,
832
- reason: cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`)
1446
+ reason
833
1447
  }));
834
1448
  return;
835
1449
  }
836
- throw new Error(cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`));
1450
+ autoRecoverSendFailure({
1451
+ options,
1452
+ conversation: nextConversation,
1453
+ statePath,
1454
+ logPath,
1455
+ executor,
1456
+ message,
1457
+ failedStage: "message_send",
1458
+ result: sendResult,
1459
+ reason
1460
+ });
1461
+ return;
837
1462
  }
1463
+ const deliveredConversation = markTakeoverBootstrapped({
1464
+ conversation: nextConversation,
1465
+ statePath,
1466
+ logPath,
1467
+ executor,
1468
+ native: needsNativeTakeoverBootstrap,
1469
+ fork: needsForkTakeoverBootstrap
1470
+ });
838
1471
  printJson({
1472
+ conversation: deliveredConversation,
1473
+ message,
1474
+ delivered: true,
1475
+ executor,
1476
+ budget: budgetAction(deliveredConversation)
1477
+ });
1478
+ }
1479
+ function runNativeCodexResumeSend({ options, conversation, nextConversation, statePath, logPath, executor, executorEnv, message, payload, nativeTakeover, needsNativeTakeoverBootstrap }) {
1480
+ const codexPath = resolveExecutable("codex");
1481
+ const nativeSessionId = String(nativeTakeover["native_session_id"]);
1482
+ const nativeModel = nativeCodexModelForSend({ options, conversation, nativeTakeover });
1483
+ const codexArgs = buildCodexExecResumeArgs({
1484
+ nativeSessionId,
1485
+ payload,
1486
+ model: nativeModel
1487
+ });
1488
+ appendEvent(logPath, {
1489
+ ts: new Date().toISOString(),
1490
+ conversation_id: conversation.conversation_id,
1491
+ event: "native_executor_resume_prepare",
1492
+ executor,
1493
+ native_session_id: nativeSessionId,
1494
+ model: nativeModel ?? null
1495
+ });
1496
+ runtimeLog("info", "native_executor_resume_prepare", {
1497
+ conversation_id: conversation.conversation_id,
1498
+ agent: executor.kind,
1499
+ executor_session: executor.session,
1500
+ native_session_id: nativeSessionId,
1501
+ model: nativeModel
1502
+ });
1503
+ if (options.background) {
1504
+ const outputPath = path.join(path.dirname(logPath), `${executor.kind}-native-resume-output.log`);
1505
+ const outputFd = fs.openSync(outputPath, "a");
1506
+ const child = spawn(codexPath, codexArgs, {
1507
+ detached: true,
1508
+ stdio: ["ignore", outputFd, outputFd],
1509
+ cwd: conversation.workspace ?? process.cwd(),
1510
+ env: executorEnv
1511
+ });
1512
+ child.unref();
1513
+ fs.closeSync(outputFd);
1514
+ appendEvent(logPath, {
1515
+ ts: new Date().toISOString(),
1516
+ conversation_id: conversation.conversation_id,
1517
+ event: "native_executor_resume_launch",
1518
+ mode: "background",
1519
+ pid: child.pid ?? null,
1520
+ executor,
1521
+ native_session_id: nativeSessionId,
1522
+ output_path: outputPath
1523
+ });
1524
+ runtimeLog("info", "native_executor_resume_launch", {
1525
+ conversation_id: conversation.conversation_id,
1526
+ agent: executor.kind,
1527
+ executor_session: executor.session,
1528
+ native_session_id: nativeSessionId,
1529
+ mode: "background",
1530
+ pid: child.pid ?? null,
1531
+ output_path: outputPath
1532
+ });
1533
+ const monitor = startExecutorMonitor({
1534
+ statePath,
1535
+ logPath,
1536
+ pid: child.pid,
1537
+ outputPath,
1538
+ agentTimeoutMinutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES),
1539
+ pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS)
1540
+ });
1541
+ appendEvent(logPath, {
1542
+ ts: new Date().toISOString(),
1543
+ conversation_id: conversation.conversation_id,
1544
+ event: "executor_monitor_launch",
1545
+ pid: monitor.pid ?? null,
1546
+ executor_pid: child.pid ?? null,
1547
+ agent_timeout_minutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES)
1548
+ });
1549
+ const deliveredConversation = markTakeoverBootstrapped({
1550
+ conversation: nextConversation,
1551
+ statePath,
1552
+ logPath,
1553
+ executor,
1554
+ native: needsNativeTakeoverBootstrap,
1555
+ fork: false
1556
+ });
1557
+ printJson({
1558
+ conversation: deliveredConversation,
1559
+ message,
1560
+ delivered: true,
1561
+ background: true,
1562
+ native_resume: true,
1563
+ pid: child.pid ?? null,
1564
+ monitor_pid: monitor.pid ?? null,
1565
+ output_path: outputPath,
1566
+ executor,
1567
+ budget: budgetAction(deliveredConversation)
1568
+ });
1569
+ return;
1570
+ }
1571
+ const sendResult = spawnSync(codexPath, codexArgs, {
1572
+ encoding: "utf8",
1573
+ maxBuffer: 1024 * 1024 * 10,
1574
+ cwd: conversation.workspace ?? process.cwd(),
1575
+ env: executorEnv
1576
+ });
1577
+ appendEvent(logPath, {
1578
+ ts: new Date().toISOString(),
1579
+ conversation_id: conversation.conversation_id,
1580
+ event: "native_executor_resume_send",
1581
+ status: sendResult.status ?? null,
1582
+ executor,
1583
+ native_session_id: nativeSessionId,
1584
+ stdout: cleanProcessText(sendResult.stdout),
1585
+ stderr: cleanProcessText(sendResult.stderr)
1586
+ });
1587
+ runtimeLog("info", "native_executor_resume_send", {
1588
+ conversation_id: conversation.conversation_id,
1589
+ agent: executor.kind,
1590
+ executor_session: executor.session,
1591
+ native_session_id: nativeSessionId,
1592
+ status: sendResult.status ?? null,
1593
+ failure_kind: classifyProcessFailure(sendResult),
1594
+ stdout: textSummary(cleanProcessText(sendResult.stdout)),
1595
+ stderr: textSummary(cleanProcessText(sendResult.stderr))
1596
+ });
1597
+ if (sendResult.error) {
1598
+ throw new Error(`codex exec resume failed to start: ${sendResult.error.message}`);
1599
+ }
1600
+ if (sendResult.status !== 0) {
1601
+ throw new Error(cleanProcessText(sendResult.stderr || sendResult.stdout || `codex exec resume exited with status ${sendResult.status}`));
1602
+ }
1603
+ const deliveredConversation = markTakeoverBootstrapped({
839
1604
  conversation: nextConversation,
1605
+ statePath,
1606
+ logPath,
1607
+ executor,
1608
+ native: needsNativeTakeoverBootstrap,
1609
+ fork: false
1610
+ });
1611
+ printJson({
1612
+ conversation: deliveredConversation,
840
1613
  message,
841
1614
  delivered: true,
1615
+ native_resume: true,
842
1616
  executor,
843
- budget: budgetAction(nextConversation)
1617
+ budget: budgetAction(deliveredConversation)
844
1618
  });
845
1619
  }
846
- function requiresExplicitRecoveryDecision(executor, options = {}) {
1620
+ function buildCodexExecResumeArgs({ nativeSessionId, payload, model }) {
1621
+ const args = ["exec", "resume"];
1622
+ if (model) {
1623
+ args.push("--model", model);
1624
+ }
1625
+ args.push("--skip-git-repo-check", nativeSessionId, payload);
1626
+ return args;
1627
+ }
1628
+ function nativeCodexModelForSend({ options, conversation, nativeTakeover }) {
1629
+ const explicit = options.model ?? options.codexModel;
1630
+ if (explicit) {
1631
+ return normalizeNativeCodexModel(explicit);
1632
+ }
1633
+ const nativeModel = isRecord(nativeTakeover) ? nativeTakeover["native_model"] : undefined;
1634
+ if (typeof nativeModel === "string" && nativeModel.trim()) {
1635
+ return normalizeNativeCodexModel(nativeModel);
1636
+ }
1637
+ return normalizeNativeCodexModel(conversation.executor_model);
1638
+ }
1639
+ function normalizeNativeCodexModel(model) {
1640
+ const value = typeof model === "string" ? model.trim() : "";
1641
+ if (!value) {
1642
+ return undefined;
1643
+ }
1644
+ return value.replace(/\[[^\]]+\]$/u, "").replace(/\/(?:low|medium|high|xhigh)$/u, "");
1645
+ }
1646
+ function buildAgentSendPayload({ conversation, executor, message, includeNativeTakeoverBootstrap, includeForkTakeoverBootstrap, forkTakeover }) {
1647
+ const messageJson = JSON.stringify(message);
1648
+ if (!includeNativeTakeoverBootstrap && !includeForkTakeoverBootstrap) {
1649
+ return [
1650
+ "Continue the existing Agent Knock Knock delegation using this structured OpenClaw message.",
1651
+ "If this message answers a question or blocker, follow it as the product decision.",
1652
+ "Continue to report back only through the callback command already provided for this conversation.",
1653
+ "",
1654
+ messageJson
1655
+ ].join("\n");
1656
+ }
1657
+ if (includeForkTakeoverBootstrap) {
1658
+ const summary = forkTakeoverSummaryText(forkTakeover);
1659
+ return [
1660
+ executorBootstrapPrompt({
1661
+ callbackCommand: conversation.callback_command,
1662
+ executorName: executor.display_name,
1663
+ softLimit: Number(conversation.soft_limit ?? 50),
1664
+ hardLimit: Number(conversation.hard_limit ?? 100)
1665
+ }),
1666
+ "",
1667
+ "This AKK conversation is a fork of an existing native coding-agent session. Do not resume the original native session. Treat the approved summary below as the only imported context from the source session, then continue as a new AKK-managed session in this workspace.",
1668
+ "",
1669
+ "Approved source-session summary:",
1670
+ summary || "(No approved summary was provided.)",
1671
+ "",
1672
+ "Initial AKK fork message:",
1673
+ messageJson
1674
+ ].join("\n");
1675
+ }
1676
+ return [
1677
+ executorBootstrapPrompt({
1678
+ callbackCommand: conversation.callback_command,
1679
+ executorName: executor.display_name,
1680
+ softLimit: Number(conversation.soft_limit ?? 50),
1681
+ hardLimit: Number(conversation.hard_limit ?? 100)
1682
+ }),
1683
+ "",
1684
+ "This AKK conversation is attaching to an existing native coding-agent session. Continue from the native session context if it is available, and use the callback command above for all replies to OpenClaw.",
1685
+ "",
1686
+ "Initial AKK takeover message:",
1687
+ messageJson
1688
+ ].join("\n");
1689
+ }
1690
+ function forkTakeoverSummaryText(forkTakeover) {
1691
+ return String(isRecord(forkTakeover) ? forkTakeover.summary ?? "" : "").trim();
1692
+ }
1693
+ function markTakeoverBootstrapped({ conversation, statePath, logPath, executor, native, fork }) {
1694
+ let nextConversation = conversation;
1695
+ if (native) {
1696
+ nextConversation = markNativeSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
1697
+ }
1698
+ if (fork) {
1699
+ nextConversation = markForkSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
1700
+ }
1701
+ return nextConversation;
1702
+ }
1703
+ function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor }) {
1704
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1705
+ ? conversation.native_session_takeover
1706
+ : {};
1707
+ const now = new Date().toISOString();
1708
+ const nextConversation = {
1709
+ ...conversation,
1710
+ native_session_takeover: {
1711
+ ...nativeTakeover,
1712
+ needs_bootstrap: false,
1713
+ bootstrapped_at: now
1714
+ },
1715
+ updated_at: now
1716
+ };
1717
+ saveState(statePath, nextConversation);
1718
+ appendEvent(logPath, {
1719
+ ts: now,
1720
+ conversation_id: conversation.conversation_id,
1721
+ event: "native_session_bootstrapped",
1722
+ executor
1723
+ });
1724
+ runtimeLog("info", "native_session_bootstrapped", {
1725
+ conversation_id: conversation.conversation_id,
1726
+ agent: executor.kind,
1727
+ executor_session: executor.session,
1728
+ state_path: statePath
1729
+ });
1730
+ return nextConversation;
1731
+ }
1732
+ function markForkSessionBootstrapped({ conversation, statePath, logPath, executor }) {
1733
+ const forkTakeover = isRecord(conversation.fork_context_takeover)
1734
+ ? conversation.fork_context_takeover
1735
+ : {};
1736
+ const now = new Date().toISOString();
1737
+ const nextConversation = {
1738
+ ...conversation,
1739
+ fork_context_takeover: {
1740
+ ...forkTakeover,
1741
+ needs_bootstrap: false,
1742
+ bootstrapped_at: now
1743
+ },
1744
+ updated_at: now
1745
+ };
1746
+ saveState(statePath, nextConversation);
1747
+ appendEvent(logPath, {
1748
+ ts: now,
1749
+ conversation_id: conversation.conversation_id,
1750
+ event: "fork_session_bootstrapped",
1751
+ executor
1752
+ });
1753
+ runtimeLog("info", "fork_session_bootstrapped", {
1754
+ conversation_id: conversation.conversation_id,
1755
+ agent: executor.kind,
1756
+ executor_session: executor.session,
1757
+ state_path: statePath
1758
+ });
1759
+ return nextConversation;
1760
+ }
1761
+ function requiresExplicitRecoveryDecision(options = {}) {
847
1762
  if (options.recoveryPolicy === "explicit" || options.recoveryPolicy === "explicit-decision") {
848
1763
  return true;
849
1764
  }
850
- return sessionRecoveryStrategyForExecutor(executor) === "explicit-decision";
1765
+ return false;
1766
+ }
1767
+ function autoRecoverSendFailure({ options, conversation, statePath, logPath, executor, message, failedStage, result, reason }) {
1768
+ markConversationNeedsRecovery({
1769
+ conversation,
1770
+ statePath,
1771
+ logPath,
1772
+ executor,
1773
+ message,
1774
+ failedStage,
1775
+ result,
1776
+ reason
1777
+ });
1778
+ runtimeLog("info", "conversation_auto_recovery_start", {
1779
+ conversation_id: conversation.conversation_id,
1780
+ agent: executor.kind,
1781
+ executor_session: executor.session,
1782
+ failed_stage: failedStage,
1783
+ reason: textSummary(reason)
1784
+ });
1785
+ runRecoveryDecision({
1786
+ ...options,
1787
+ mode: "recover",
1788
+ autoRecovered: true
1789
+ });
851
1790
  }
852
1791
  function markConversationNeedsRecovery({ conversation, statePath, logPath, executor, message, failedStage, result, reason }) {
853
1792
  const now = new Date().toISOString();
@@ -861,7 +1800,7 @@ function markConversationNeedsRecovery({ conversation, statePath, logPath, execu
861
1800
  failed_message_id: message.id,
862
1801
  pending_message: message,
863
1802
  previous_executor: executor,
864
- options: ["recover", "restart", "close"]
1803
+ options: ["recover", "close", "delegate"]
865
1804
  };
866
1805
  const nextConversation = {
867
1806
  ...conversation,
@@ -965,9 +1904,6 @@ function runCancel(options) {
965
1904
  function runRecover(options) {
966
1905
  runRecoveryDecision({ ...options, mode: "recover" });
967
1906
  }
968
- function runRestart(options) {
969
- runRecoveryDecision({ ...options, mode: "restart" });
970
- }
971
1907
  function runRecoveryDecision(options) {
972
1908
  cleanupIdleConversations(storeDirFromOptions(options), options);
973
1909
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
@@ -999,9 +1935,7 @@ function runRecoveryDecision(options) {
999
1935
  updated_at: now
1000
1936
  };
1001
1937
  saveState(statePath, recoveredConversation);
1002
- const payload = options.mode === "recover"
1003
- ? buildRecoverPayload({ conversation, pendingMessage, logPath })
1004
- : buildRestartPayload({ pendingMessage });
1938
+ const payload = buildRecoverPayload({ conversation, pendingMessage, logPath });
1005
1939
  const acpxPath = resolveExecutable("acpx");
1006
1940
  const executorEnv = environmentForExecutor(executor, {
1007
1941
  allProxy: options.allProxy ?? conversation.executor_all_proxy
@@ -1063,8 +1997,8 @@ function runRecoveryDecision(options) {
1063
1997
  });
1064
1998
  printJson({
1065
1999
  conversation: recoveredConversation,
1066
- recovered: options.mode === "recover",
1067
- restarted: options.mode === "restart",
2000
+ recovered: true,
2001
+ auto_recovered: Boolean(options.autoRecovered),
1068
2002
  background: true,
1069
2003
  pid: child.pid ?? null,
1070
2004
  monitor_pid: monitor.pid ?? null,
@@ -1098,8 +2032,8 @@ function runRecoveryDecision(options) {
1098
2032
  }
1099
2033
  printJson({
1100
2034
  conversation: recoveredConversation,
1101
- recovered: options.mode === "recover",
1102
- restarted: options.mode === "restart",
2035
+ recovered: true,
2036
+ auto_recovered: Boolean(options.autoRecovered),
1103
2037
  delivered: true,
1104
2038
  executor,
1105
2039
  budget: budgetAction(recoveredConversation)
@@ -1122,16 +2056,6 @@ function buildRecoverPayload({ conversation, pendingMessage, logPath }) {
1122
2056
  JSON.stringify(pendingMessage)
1123
2057
  ].join("\n");
1124
2058
  }
1125
- function buildRestartPayload({ pendingMessage }) {
1126
- return [
1127
- "Restart this Agent Knock Knock task in a new ACPX session.",
1128
- "Do not assume the previous coding-agent session context is available.",
1129
- "Follow only the pending OpenClaw message below.",
1130
- "Continue to report back only through the callback command already provided for this conversation.",
1131
- "",
1132
- JSON.stringify(pendingMessage)
1133
- ].join("\n");
1134
- }
1135
2059
  function formatProtocolHistoryForRecovery(events) {
1136
2060
  const lines = events
1137
2061
  .filter((event) => event.event === "message")
@@ -1216,6 +2140,27 @@ function runMonitor(options) {
1216
2140
  return;
1217
2141
  }
1218
2142
  if (Number.isFinite(pid) && !isProcessAlive(pid)) {
2143
+ const modelSelection = detectModelSelectionError(readOutputTail(options.outputPath));
2144
+ if (modelSelection) {
2145
+ const modelSelectionConversation = markConversationNeedsModelSelection({
2146
+ statePath,
2147
+ logPath,
2148
+ reason: modelSelection.message,
2149
+ detail: {
2150
+ executor_pid: pid,
2151
+ output_path: options.outputPath,
2152
+ model_selection: modelSelection
2153
+ }
2154
+ });
2155
+ printJson({
2156
+ conversation: modelSelectionConversation,
2157
+ monitored: true,
2158
+ stalled: false,
2159
+ needs_model_selection: true,
2160
+ reason: modelSelectionConversation?.model_selection?.message ?? modelSelection.message
2161
+ });
2162
+ return;
2163
+ }
1219
2164
  const stalledConversation = markConversationStalled({
1220
2165
  statePath,
1221
2166
  logPath,
@@ -1884,12 +2829,65 @@ function isWaitingForAgent(status) {
1884
2829
  function isProcessAlive(pid) {
1885
2830
  try {
1886
2831
  process.kill(pid, 0);
1887
- return true;
2832
+ return !isZombieProcess(pid);
1888
2833
  }
1889
2834
  catch (error) {
1890
2835
  return error?.code === "EPERM";
1891
2836
  }
1892
2837
  }
2838
+ function isZombieProcess(pid) {
2839
+ const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], {
2840
+ encoding: "utf8"
2841
+ });
2842
+ if (result.status !== 0) {
2843
+ return false;
2844
+ }
2845
+ return result.stdout.trim().toUpperCase().startsWith("Z");
2846
+ }
2847
+ function terminateProcessTarget(target, { timeoutMs = 3000 } = {}) {
2848
+ const pids = [...target.childPids, target.pid]
2849
+ .filter((pid, index, all) => Number.isInteger(pid) && pid > 0 && all.indexOf(pid) === index);
2850
+ const signals = [];
2851
+ for (const pid of pids) {
2852
+ signals.push(sendSignalToPid(pid, "SIGTERM"));
2853
+ }
2854
+ const exited = waitForPidsToExit(pids, timeoutMs);
2855
+ return {
2856
+ target,
2857
+ signal: "SIGTERM",
2858
+ signals,
2859
+ exited,
2860
+ remainingPids: pids.filter((pid) => isProcessAlive(pid))
2861
+ };
2862
+ }
2863
+ function sendSignalToPid(pid, signal) {
2864
+ try {
2865
+ process.kill(pid, signal);
2866
+ return {
2867
+ pid,
2868
+ signal,
2869
+ status: "sent"
2870
+ };
2871
+ }
2872
+ catch (error) {
2873
+ return {
2874
+ pid,
2875
+ signal,
2876
+ status: "failed",
2877
+ error: error instanceof Error ? error.message : String(error)
2878
+ };
2879
+ }
2880
+ }
2881
+ function waitForPidsToExit(pids, timeoutMs) {
2882
+ const deadline = Date.now() + timeoutMs;
2883
+ while (Date.now() < deadline) {
2884
+ if (pids.every((pid) => !isProcessAlive(pid))) {
2885
+ return true;
2886
+ }
2887
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
2888
+ }
2889
+ return pids.every((pid) => !isProcessAlive(pid));
2890
+ }
1893
2891
  function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
1894
2892
  const releaseLock = acquireFileLock(`${statePath}.lock`);
1895
2893
  let stalledConversation;
@@ -1944,6 +2942,58 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
1944
2942
  }
1945
2943
  return stalledConversation;
1946
2944
  }
2945
+ function markConversationNeedsModelSelection({ statePath, logPath, reason, detail = {} }) {
2946
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
2947
+ let modelSelectionConversation;
2948
+ try {
2949
+ const conversation = loadState(statePath);
2950
+ if (!isWaitingForAgent(conversation.status)) {
2951
+ runtimeLog("info", "executor_monitor_finished", {
2952
+ conversation_id: conversation.conversation_id,
2953
+ status: conversation.status,
2954
+ reason: "conversation_changed_before_model_selection"
2955
+ });
2956
+ return conversation;
2957
+ }
2958
+ const now = new Date().toISOString();
2959
+ const detailRecord = detail;
2960
+ const modelSelection = isRecord(detailRecord.model_selection)
2961
+ ? detailRecord.model_selection
2962
+ : {};
2963
+ modelSelectionConversation = {
2964
+ ...conversation,
2965
+ status: "needs_model_selection",
2966
+ model_selection: {
2967
+ detected_at: now,
2968
+ message: reason,
2969
+ ...modelSelection
2970
+ },
2971
+ updated_at: now
2972
+ };
2973
+ saveState(statePath, modelSelectionConversation);
2974
+ appendEvent(logPath, {
2975
+ ts: now,
2976
+ conversation_id: conversation.conversation_id,
2977
+ event: "conversation_needs_model_selection",
2978
+ status: "needs_model_selection",
2979
+ reason,
2980
+ ...detailRecord
2981
+ });
2982
+ runtimeLog("warn", "conversation_needs_model_selection", {
2983
+ conversation_id: conversation.conversation_id,
2984
+ agent: executorForConversation(conversation).kind,
2985
+ executor_session: executorForConversation(conversation).session,
2986
+ state_path: statePath,
2987
+ event_log_path: logPath,
2988
+ reason,
2989
+ ...detailRecord
2990
+ });
2991
+ }
2992
+ finally {
2993
+ releaseLock();
2994
+ }
2995
+ return modelSelectionConversation;
2996
+ }
1947
2997
  function deliverStalledNotification({ statePath, logPath, conversation, reason, detail = {} }) {
1948
2998
  if (!conversation.gateway_method) {
1949
2999
  return;
@@ -2307,6 +3357,32 @@ function parseOptionalJson(text) {
2307
3357
  return undefined;
2308
3358
  }
2309
3359
  }
3360
+ function createAgentSessionProvider(agent, options) {
3361
+ if (agent !== "codex") {
3362
+ throw new Error(`unsupported agent session provider: ${agent}`);
3363
+ }
3364
+ if (options.threadsJson || options.processesJson || options.rolloutsJson) {
3365
+ return new CodexLocalSessionProvider(new InlineCodexSessionAdapter({
3366
+ threads: parseJsonOption(options.threadsJson, "--threads-json"),
3367
+ processes: parseJsonOption(options.processesJson, "--processes-json"),
3368
+ rollouts: parseJsonOption(options.rolloutsJson, "--rollouts-json")
3369
+ }));
3370
+ }
3371
+ return new CodexLocalSessionProvider(new CodexStoreAdapter({
3372
+ codexHome: expandHome(options.codexHome)
3373
+ }));
3374
+ }
3375
+ function parseJsonOption(value, optionName) {
3376
+ if (!value) {
3377
+ return undefined;
3378
+ }
3379
+ try {
3380
+ return JSON.parse(String(value));
3381
+ }
3382
+ catch (error) {
3383
+ throw new Error(`${optionName} must be valid JSON: ${error.message}`);
3384
+ }
3385
+ }
2310
3386
  function expandHome(filePath) {
2311
3387
  if (filePath === "~") {
2312
3388
  return process.env.HOME;
@@ -2357,6 +3433,56 @@ function classifyProcessFailure(result) {
2357
3433
  }
2358
3434
  return undefined;
2359
3435
  }
3436
+ function readOutputTail(outputPath, maxBytes = 65536) {
3437
+ if (!outputPath) {
3438
+ return "";
3439
+ }
3440
+ try {
3441
+ const resolvedPath = expandHome(outputPath);
3442
+ const stat = fs.statSync(resolvedPath);
3443
+ const start = Math.max(0, stat.size - maxBytes);
3444
+ const length = stat.size - start;
3445
+ const fd = fs.openSync(resolvedPath, "r");
3446
+ try {
3447
+ const buffer = Buffer.alloc(length);
3448
+ fs.readSync(fd, buffer, 0, length, start);
3449
+ return buffer.toString("utf8");
3450
+ }
3451
+ finally {
3452
+ fs.closeSync(fd);
3453
+ }
3454
+ }
3455
+ catch {
3456
+ return "";
3457
+ }
3458
+ }
3459
+ function detectModelSelectionError(text) {
3460
+ const cleaned = cleanProcessText(text);
3461
+ if (!cleaned) {
3462
+ return undefined;
3463
+ }
3464
+ const unsupportedAccount = /The '([^']+)' model is not supported when using Codex with a ChatGPT account/i.exec(cleaned);
3465
+ if (unsupportedAccount) {
3466
+ return {
3467
+ kind: "unsupported_chatgpt_account_model",
3468
+ attempted_model: unsupportedAccount[1],
3469
+ message: unsupportedAccount[0]
3470
+ };
3471
+ }
3472
+ const unadvertised = /Cannot apply --model "([^"]+)": the ACP agent did not advertise that model\. Available models:\s*([^\n\r]+)/i.exec(cleaned);
3473
+ if (unadvertised) {
3474
+ return {
3475
+ kind: "unadvertised_acpx_model",
3476
+ attempted_model: unadvertised[1],
3477
+ available_models: unadvertised[2]
3478
+ .split(",")
3479
+ .map((model) => model.trim())
3480
+ .filter(Boolean),
3481
+ message: unadvertised[0]
3482
+ };
3483
+ }
3484
+ return undefined;
3485
+ }
2360
3486
  function runtimeLog(level, event, fields = {}) {
2361
3487
  try {
2362
3488
  writeRuntimeLog({
@@ -2393,10 +3519,11 @@ function usage() {
2393
3519
  agent-knock-knock send --conversation <id> --message <text> [--type answer|task|control] [--all-proxy <url>] [--agent-timeout-minutes <minutes>]
2394
3520
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
2395
3521
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
2396
- agent-knock-knock restart --conversation <id> [--session <name>] [--all-proxy <url>]
2397
3522
  agent-knock-knock close --conversation <id> [--reason <text>]
2398
3523
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--no-restart]
2399
3524
  agent-knock-knock doctor
3525
+ agent-knock-knock agent discover --agent codex --scope capabilities|sessions|active
3526
+ agent-knock-knock agent takeover --agent codex --session-id <id> --strategy safe_resume|terminate_then_resume|fork [--create-conversation]
2400
3527
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]
2401
3528
  agent-knock-knock transcript --log <file> [--include-raw]
2402
3529
  agent-knock-knock transcript --conversation <dir> [--include-raw]