@scotthuang/agent-knock-knock 0.1.1 → 0.2.0

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
11
  import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor, sessionRecoveryStrategyForExecutor } 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
  }
@@ -86,6 +121,9 @@ function runCommand(commandName, options) {
86
121
  else if (commandName === "monitor") {
87
122
  runMonitor(options);
88
123
  }
124
+ else if (commandName === "agent") {
125
+ await runAgent(options);
126
+ }
89
127
  else {
90
128
  usage();
91
129
  process.exitCode = commandName ? 1 : 0;
@@ -170,6 +208,495 @@ function runDoctor(options) {
170
208
  options
171
209
  });
172
210
  }
211
+ async function runAgent(options) {
212
+ const agentCommand = required(options.agentCommand, "agent subcommand is required: discover or takeover");
213
+ if (agentCommand === "discover") {
214
+ printJson(await runAgentDiscover(options));
215
+ return;
216
+ }
217
+ if (agentCommand === "takeover") {
218
+ printJson(await runAgentTakeover(options));
219
+ return;
220
+ }
221
+ throw new Error(`unsupported agent subcommand: ${agentCommand}`);
222
+ }
223
+ async function runAgentDiscover(options) {
224
+ const agent = required(options.agent, "--agent is required");
225
+ const scope = required(options.scope, "--scope is required");
226
+ const provider = createAgentSessionProvider(agent, options);
227
+ const capabilities = await provider.getCapabilities();
228
+ if (scope === "capabilities") {
229
+ return {
230
+ agent,
231
+ scope,
232
+ capabilities
233
+ };
234
+ }
235
+ if (scope === "sessions") {
236
+ return {
237
+ agent,
238
+ scope,
239
+ capabilities,
240
+ sessions: await provider.listHistoricalSessions()
241
+ };
242
+ }
243
+ if (scope === "active") {
244
+ return {
245
+ agent,
246
+ scope,
247
+ capabilities,
248
+ active: await provider.listActiveSessions()
249
+ };
250
+ }
251
+ throw new Error(`unsupported discover scope: ${scope}`);
252
+ }
253
+ async function runAgentTakeover(options) {
254
+ const agent = required(options.agent, "--agent is required");
255
+ const sessionId = required(options.sessionId, "--session-id is required");
256
+ const strategy = options.strategy ?? "terminate_then_resume";
257
+ const provider = createAgentSessionProvider(agent, options);
258
+ const session = await provider.getSession(sessionId);
259
+ if (!session) {
260
+ return {
261
+ agent,
262
+ sessionId,
263
+ strategy,
264
+ status: "blocked",
265
+ sideEffectsExecuted: false,
266
+ error: {
267
+ code: "session_not_found",
268
+ message: `No ${agent} session found for ${sessionId}`
269
+ }
270
+ };
271
+ }
272
+ if (strategy === "safe_resume") {
273
+ const plan = planSafeResume(session, await provider.listActiveSessions());
274
+ if (plan.allowed && options.createConversation) {
275
+ const modelInfo = await provider.getSessionModel(session.id);
276
+ const attached = createNativeSessionConversation({
277
+ agent,
278
+ strategy,
279
+ session,
280
+ modelInfo,
281
+ options
282
+ });
283
+ return {
284
+ agent,
285
+ sessionId,
286
+ strategy,
287
+ status: "attached",
288
+ sideEffectsExecuted: true,
289
+ plan,
290
+ ...attached
291
+ };
292
+ }
293
+ return {
294
+ agent,
295
+ sessionId,
296
+ strategy,
297
+ status: plan.allowed ? "ready" : "blocked",
298
+ sideEffectsExecuted: false,
299
+ plan
300
+ };
301
+ }
302
+ if (strategy === "terminate_then_resume") {
303
+ const activeSessions = await provider.listActiveSessions();
304
+ const plan = planTakeover(session, activeSessions);
305
+ if (options.confirmTerminate === true) {
306
+ const expectedPid = Number(required(options.expectedPid, "--expected-pid is required with --confirm-terminate"));
307
+ if (!Number.isInteger(expectedPid) || expectedPid <= 0) {
308
+ throw new Error("--expected-pid must be a positive integer");
309
+ }
310
+ if (!options.createConversation) {
311
+ throw new Error("--create-conversation is required with --confirm-terminate");
312
+ }
313
+ const targetSelection = selectTerminateTarget({
314
+ plan,
315
+ session,
316
+ activeSessions,
317
+ expectedPid,
318
+ allowCwdOnly: options.allowCwdOnly === true
319
+ });
320
+ if (!targetSelection.allowed) {
321
+ return {
322
+ agent,
323
+ sessionId,
324
+ strategy,
325
+ status: "blocked",
326
+ sideEffectsExecuted: false,
327
+ plan,
328
+ error: {
329
+ code: targetSelection.code,
330
+ message: targetSelection.message
331
+ }
332
+ };
333
+ }
334
+ const { target, matchKind } = targetSelection;
335
+ const termination = terminateProcessTarget(target, {
336
+ timeoutMs: Number(options.terminateTimeoutMs ?? 3000)
337
+ });
338
+ const activeAfterTermination = await provider.listActiveSessions();
339
+ const afterTerminationPlan = planTakeover(session, activeAfterTermination);
340
+ if (afterTerminationPlan.targets.some((candidate) => candidate.sessionId === session.id || candidate.pid === expectedPid)) {
341
+ return {
342
+ agent,
343
+ sessionId,
344
+ strategy,
345
+ status: "blocked",
346
+ sideEffectsExecuted: true,
347
+ plan: afterTerminationPlan,
348
+ termination,
349
+ error: {
350
+ code: "target_still_active",
351
+ message: `Codex process ${expectedPid} still appears active after termination.`
352
+ }
353
+ };
354
+ }
355
+ const modelInfo = await provider.getSessionModel(session.id);
356
+ const attached = createNativeSessionConversation({
357
+ agent,
358
+ strategy,
359
+ session,
360
+ modelInfo,
361
+ options,
362
+ takeoverMatchKind: matchKind
363
+ });
364
+ return {
365
+ agent,
366
+ sessionId,
367
+ strategy,
368
+ status: "attached",
369
+ sideEffectsExecuted: true,
370
+ plan,
371
+ termination,
372
+ matchKind,
373
+ ...attached
374
+ };
375
+ }
376
+ return {
377
+ agent,
378
+ sessionId,
379
+ strategy,
380
+ status: plan.requiresConfirmation ? "requires_confirmation" : "blocked",
381
+ sideEffectsExecuted: false,
382
+ plan
383
+ };
384
+ }
385
+ if (strategy === "fork") {
386
+ const contextPackage = await provider.getForkContext({
387
+ sessionId,
388
+ maxMessages: Number(options.maxMessages ?? 12),
389
+ maxCommands: Number(options.maxCommands ?? 8),
390
+ maxTextLength: Number(options.maxTextLength ?? 1200)
391
+ });
392
+ if (!contextPackage) {
393
+ return {
394
+ agent,
395
+ sessionId,
396
+ strategy,
397
+ status: "blocked",
398
+ sideEffectsExecuted: false,
399
+ error: {
400
+ code: "fork_context_unavailable",
401
+ message: `No fork context could be built for ${sessionId}`
402
+ }
403
+ };
404
+ }
405
+ if (options.createConversation) {
406
+ const forkSummary = String(required(options.forkSummary ?? options.summary, "--fork-summary is required when creating a fork conversation"));
407
+ const modelInfo = await provider.getSessionModel(session.id);
408
+ const attached = createForkConversation({
409
+ agent,
410
+ strategy,
411
+ session,
412
+ contextPackage,
413
+ forkSummary,
414
+ modelInfo,
415
+ options
416
+ });
417
+ return {
418
+ agent,
419
+ sessionId,
420
+ strategy,
421
+ status: "forked",
422
+ sideEffectsExecuted: true,
423
+ plan: planFork(session, contextPackage),
424
+ ...attached
425
+ };
426
+ }
427
+ return {
428
+ agent,
429
+ sessionId,
430
+ strategy,
431
+ status: "awaiting_openclaw_summary",
432
+ sideEffectsExecuted: false,
433
+ plan: planFork(session, contextPackage),
434
+ summaryPrompt: buildForkSummaryPrompt({ agent, session, contextPackage }),
435
+ nextAction: {
436
+ actor: "openclaw",
437
+ action: "summarize_and_confirm_fork",
438
+ instructions: [
439
+ "Summarize plan.contextPackage for the user before creating a forked AKK-managed session.",
440
+ "Do not inject the raw rollout or full contextPackage into the new coding agent.",
441
+ "Ask the user to confirm the summary.",
442
+ "After confirmation, call this tool again with strategy=fork, createConversation=true, and forkSummary set to the confirmed summary."
443
+ ],
444
+ followUpTool: "agent_knock_knock_agent_takeover",
445
+ followUpParams: {
446
+ agent,
447
+ sessionId,
448
+ strategy: "fork",
449
+ createConversation: true,
450
+ forkSummary: "<confirmed OpenClaw summary>"
451
+ }
452
+ },
453
+ next: "Use summaryPrompt to summarize the bounded context package for the user, ask for confirmation, then create the forked AKK-managed session with forkSummary."
454
+ };
455
+ }
456
+ throw new Error(`unsupported takeover strategy: ${strategy}`);
457
+ }
458
+ function buildForkSummaryPrompt({ agent, session, contextPackage }) {
459
+ return [
460
+ "You are OpenClaw summarizing a bounded native coding-agent session context before Agent Knock Knock forks it into a new managed session.",
461
+ "",
462
+ "Goal:",
463
+ "- Produce a concise, user-reviewable summary that can be safely injected into a new AKK-managed coding-agent session after the user confirms it.",
464
+ "- The new session must use the summary only; do not pass raw rollout history or the full context package to the coding agent.",
465
+ "",
466
+ "Source:",
467
+ `- Agent: ${agent}`,
468
+ `- Session id: ${session.id}`,
469
+ `- Workspace: ${session.cwd}`,
470
+ `- Title: ${session.title ?? session.preview ?? session.firstUserMessage ?? "(unknown)"}`,
471
+ `- Context messages included: ${contextPackage.messages.length}`,
472
+ `- Commands included: ${contextPackage.commands.length}`,
473
+ `- Context truncated: ${contextPackage.truncated ? "yes" : "no"}`,
474
+ "",
475
+ "Summary format:",
476
+ "1. Original user goal",
477
+ "2. Work already completed",
478
+ "3. Current state and important findings",
479
+ "4. Constraints, risks, or files/workspace details the forked agent must preserve",
480
+ "5. Recommended next step for the forked agent",
481
+ "",
482
+ "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."
483
+ ].join("\n");
484
+ }
485
+ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, allowCwdOnly }) {
486
+ const exactTarget = plan.targets.find((candidate) => candidate.pid === expectedPid && candidate.sessionId === session.id);
487
+ if (exactTarget) {
488
+ return {
489
+ allowed: true,
490
+ target: exactTarget,
491
+ matchKind: "exact_session"
492
+ };
493
+ }
494
+ if (!allowCwdOnly) {
495
+ return {
496
+ allowed: false,
497
+ code: plan.allowed && plan.requiresConfirmation ? "expected_pid_mismatch" : "takeover_not_confirmable",
498
+ message: plan.allowed && plan.requiresConfirmation
499
+ ? `Expected pid ${expectedPid} is no longer the exact active Codex process for session ${session.id}.`
500
+ : "The current active Codex process no longer has an exact session match that can be safely terminated."
501
+ };
502
+ }
503
+ const cwdOnlyTarget = plan.targets.find((candidate) => candidate.pid === expectedPid &&
504
+ candidate.cwd === session.cwd &&
505
+ candidate.sessionId === undefined);
506
+ const stillActive = activeSessions.some((candidate) => candidate.pid === expectedPid &&
507
+ candidate.cwd === session.cwd &&
508
+ candidate.sessionId === undefined);
509
+ if (!cwdOnlyTarget || !stillActive) {
510
+ return {
511
+ allowed: false,
512
+ code: "expected_pid_mismatch",
513
+ message: `Expected pid ${expectedPid} is no longer an active Codex process in ${session.cwd}.`
514
+ };
515
+ }
516
+ return {
517
+ allowed: true,
518
+ target: cwdOnlyTarget,
519
+ matchKind: "cwd_only_confirmed"
520
+ };
521
+ }
522
+ function createForkConversation({ agent, strategy, session, contextPackage, forkSummary, modelInfo, options }) {
523
+ const workspace = session.cwd;
524
+ const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
525
+ cleanupIdleConversations(storeDir, options);
526
+ const executor = resolveExecutor({
527
+ kind: agent,
528
+ session: options.session ?? options.executorSession ?? uniqueDelegateSessionName(agent)
529
+ });
530
+ const now = new Date();
531
+ const conversation = createConversation({
532
+ userRequest: options.request ?? `Fork native ${agent} session ${session.id}`,
533
+ workspace,
534
+ openclawSession: options.openclawSession ?? "agent:main:main",
535
+ executorKind: executor.kind,
536
+ executorSession: executor.session,
537
+ softLimit: Number(options.softLimit ?? 50),
538
+ hardLimit: Number(options.hardLimit ?? 100),
539
+ now
540
+ });
541
+ const paths = pathsForConversation(conversation.conversation_id, storeDir);
542
+ const callbackCommand = options.callbackCommand
543
+ ? expandCallbackCommandTemplate(options.callbackCommand, { statePath: paths.statePath })
544
+ : buildCallbackCommand({
545
+ statePath: paths.statePath,
546
+ gatewayUrl: options.gatewayUrl ?? "ws://127.0.0.1:18789",
547
+ token: options.token,
548
+ openclawSession: options.openclawSession ?? "agent:main:main",
549
+ gatewayMethod: options.gatewayMethod,
550
+ gatewaySession: options.gatewaySession,
551
+ openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
552
+ });
553
+ const explicitModel = options.model ?? options.codexModel;
554
+ const executorModel = explicitModel ?? modelInfo?.acpxModel ?? modelEnvForExecutor(executor, process.env);
555
+ const forkedConversation = withStoragePaths({
556
+ ...conversation,
557
+ executor,
558
+ status: "idle",
559
+ idle_since: now.toISOString(),
560
+ updated_at: now.toISOString(),
561
+ callback_command: callbackCommand,
562
+ gateway_url: options.gatewayUrl ?? "ws://127.0.0.1:18789",
563
+ gateway_method: options.gatewayMethod,
564
+ gateway_session: options.gatewaySession ?? options.openclawSession ?? "agent:main:main",
565
+ openclaw_bin: options.openclawBin ?? resolveOptionalExecutable("openclaw"),
566
+ executor_all_proxy: proxyForExecutor(executor, options),
567
+ executor_model: executorModel,
568
+ fork_context_takeover: {
569
+ agent,
570
+ source_session_id: session.id,
571
+ source_cwd: session.cwd,
572
+ source_title: session.title,
573
+ source_updated_at_ms: session.updatedAtMs,
574
+ strategy,
575
+ forked_at: now.toISOString(),
576
+ summary: forkSummary,
577
+ context_message_count: contextPackage.messages.length,
578
+ context_command_count: contextPackage.commands.length,
579
+ context_truncated: contextPackage.truncated,
580
+ native_model: modelInfo?.model,
581
+ acpx_model: modelInfo?.acpxModel,
582
+ model_source: modelInfo?.source,
583
+ needs_bootstrap: true
584
+ }
585
+ }, paths);
586
+ saveState(paths.statePath, forkedConversation);
587
+ appendEvent(paths.logPath, {
588
+ ts: now.toISOString(),
589
+ conversation_id: forkedConversation.conversation_id,
590
+ event: "native_session_forked",
591
+ agent,
592
+ strategy,
593
+ source_session_id: session.id,
594
+ source_cwd: session.cwd,
595
+ executor,
596
+ context_message_count: contextPackage.messages.length,
597
+ context_command_count: contextPackage.commands.length,
598
+ context_truncated: contextPackage.truncated
599
+ });
600
+ runtimeLog("info", "native_session_forked", {
601
+ conversation_id: forkedConversation.conversation_id,
602
+ agent,
603
+ strategy,
604
+ source_session_id: session.id,
605
+ executor_session: executor.session,
606
+ state_path: paths.statePath,
607
+ event_log_path: paths.logPath
608
+ });
609
+ return {
610
+ conversation: forkedConversation,
611
+ paths,
612
+ next: `Use AKK send ${forkedConversation.conversation_id}: <message> to start the forked ${agent} session with the approved summary.`
613
+ };
614
+ }
615
+ function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy }) {
616
+ const workspace = session.cwd;
617
+ const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
618
+ cleanupIdleConversations(storeDir, options);
619
+ const executor = resolveExecutor({
620
+ kind: agent,
621
+ session: session.id
622
+ });
623
+ const now = new Date();
624
+ const conversation = createConversation({
625
+ userRequest: options.request ?? `Attach native ${agent} session ${session.id}`,
626
+ workspace,
627
+ openclawSession: options.openclawSession ?? "agent:main:main",
628
+ executorKind: executor.kind,
629
+ executorSession: executor.session,
630
+ softLimit: Number(options.softLimit ?? 50),
631
+ hardLimit: Number(options.hardLimit ?? 100),
632
+ now
633
+ });
634
+ const paths = pathsForConversation(conversation.conversation_id, storeDir);
635
+ const callbackCommand = options.callbackCommand
636
+ ? expandCallbackCommandTemplate(options.callbackCommand, { statePath: paths.statePath })
637
+ : buildCallbackCommand({
638
+ statePath: paths.statePath,
639
+ gatewayUrl: options.gatewayUrl ?? "ws://127.0.0.1:18789",
640
+ token: options.token,
641
+ openclawSession: options.openclawSession ?? "agent:main:main",
642
+ gatewayMethod: options.gatewayMethod,
643
+ gatewaySession: options.gatewaySession,
644
+ openclawBin: options.openclawBin ?? resolveOptionalExecutable("openclaw")
645
+ });
646
+ const explicitModel = options.model ?? options.codexModel;
647
+ const executorModel = explicitModel ?? modelInfo?.acpxModel ?? modelEnvForExecutor(executor, process.env);
648
+ const attachedConversation = withStoragePaths({
649
+ ...conversation,
650
+ executor,
651
+ status: "idle",
652
+ idle_since: now.toISOString(),
653
+ updated_at: now.toISOString(),
654
+ callback_command: callbackCommand,
655
+ gateway_url: options.gatewayUrl ?? "ws://127.0.0.1:18789",
656
+ gateway_method: options.gatewayMethod,
657
+ gateway_session: options.gatewaySession ?? options.openclawSession ?? "agent:main:main",
658
+ openclaw_bin: options.openclawBin ?? resolveOptionalExecutable("openclaw"),
659
+ executor_all_proxy: proxyForExecutor(executor, options),
660
+ executor_model: executorModel,
661
+ native_session_takeover: {
662
+ agent,
663
+ native_session_id: session.id,
664
+ source_cwd: session.cwd,
665
+ source_title: session.title,
666
+ strategy,
667
+ attached_at: now.toISOString(),
668
+ native_model: modelInfo?.model,
669
+ acpx_model: modelInfo?.acpxModel,
670
+ model_source: modelInfo?.source,
671
+ takeover_match_kind: takeoverMatchKind,
672
+ needs_bootstrap: true
673
+ }
674
+ }, paths);
675
+ saveState(paths.statePath, attachedConversation);
676
+ appendEvent(paths.logPath, {
677
+ ts: now.toISOString(),
678
+ conversation_id: attachedConversation.conversation_id,
679
+ event: "native_session_attached",
680
+ agent,
681
+ strategy,
682
+ native_session_id: session.id,
683
+ source_cwd: session.cwd,
684
+ executor
685
+ });
686
+ runtimeLog("info", "native_session_attached", {
687
+ conversation_id: attachedConversation.conversation_id,
688
+ agent,
689
+ strategy,
690
+ native_session_id: session.id,
691
+ state_path: paths.statePath,
692
+ event_log_path: paths.logPath
693
+ });
694
+ return {
695
+ conversation: attachedConversation,
696
+ paths,
697
+ next: `Use AKK send ${attachedConversation.conversation_id}: <message> to continue this native ${agent} session through AKK.`
698
+ };
699
+ }
173
700
  function runNew(options) {
174
701
  const request = required(options.request, "--request is required");
175
702
  const workspace = options.workspace ?? process.cwd();
@@ -489,8 +1016,15 @@ function runDelegate(options) {
489
1016
  note: "Run again with --send to send this task through acpx."
490
1017
  });
491
1018
  }
492
- function ensureExecutorSession({ acpxPath, executor, cwd, env }) {
493
- return spawnSync(acpxPath, [acpxCommandForExecutor(executor), "sessions", "ensure", "--name", executor.session], {
1019
+ function ensureExecutorSession({ acpxPath, executor, cwd, env, resumeSessionId }) {
1020
+ const args = [acpxCommandForExecutor(executor), "sessions", "ensure"];
1021
+ if (resumeSessionId) {
1022
+ args.push("--resume-session", resumeSessionId);
1023
+ }
1024
+ else {
1025
+ args.push("--name", executor.session);
1026
+ }
1027
+ return spawnSync(acpxPath, args, {
494
1028
  encoding: "utf8",
495
1029
  cwd,
496
1030
  env
@@ -622,8 +1156,19 @@ function runSend(options) {
622
1156
  if (conversation.status === "needs_recovery") {
623
1157
  throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, restart, or close first`);
624
1158
  }
1159
+ if (conversation.status === "needs_model_selection" && !options.model) {
1160
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
1161
+ }
625
1162
  const executor = executorForConversation(conversation);
626
1163
  const type = options.type ?? (conversation.status === "waiting_for_openclaw" ? "answer" : "task");
1164
+ const nativeTakeoverForSend = isRecord(conversation.native_session_takeover)
1165
+ ? conversation.native_session_takeover
1166
+ : undefined;
1167
+ const forkTakeoverForSend = isRecord(conversation.fork_context_takeover)
1168
+ ? conversation.fork_context_takeover
1169
+ : undefined;
1170
+ const needsNativeTakeoverBootstrap = nativeTakeoverForSend?.["needs_bootstrap"] === true;
1171
+ const needsForkTakeoverBootstrap = forkTakeoverForSend?.["needs_bootstrap"] === true;
627
1172
  const message = createMessage({
628
1173
  conversation,
629
1174
  from: "openclaw",
@@ -635,10 +1180,21 @@ function runSend(options) {
635
1180
  executor_session: executor.session
636
1181
  }
637
1182
  });
1183
+ const previousModelSelection = isRecord(conversation.model_selection)
1184
+ ? conversation.model_selection
1185
+ : {};
638
1186
  const nextConversation = {
639
1187
  ...applyMessageToConversation(conversation, message),
640
1188
  executor,
641
- claude_session: executor.kind === "claude" ? executor.session : conversation.claude_session
1189
+ claude_session: executor.kind === "claude" ? executor.session : conversation.claude_session,
1190
+ executor_model: options.model ?? conversation.executor_model,
1191
+ model_selection: conversation.status === "needs_model_selection"
1192
+ ? {
1193
+ ...previousModelSelection,
1194
+ resolved_at: new Date().toISOString(),
1195
+ selected_model: options.model
1196
+ }
1197
+ : conversation.model_selection
642
1198
  };
643
1199
  saveState(statePath, nextConversation);
644
1200
  appendEvent(logPath, messageEvent(message));
@@ -651,10 +1207,34 @@ function runSend(options) {
651
1207
  event_log_path: logPath,
652
1208
  message: textSummary(messageBody)
653
1209
  });
654
- const acpxPath = resolveExecutable("acpx");
655
1210
  const executorEnv = environmentForExecutor(executor, {
656
1211
  allProxy: options.allProxy ?? conversation.executor_all_proxy
657
1212
  });
1213
+ const payload = buildAgentSendPayload({
1214
+ conversation,
1215
+ executor,
1216
+ message,
1217
+ includeNativeTakeoverBootstrap: needsNativeTakeoverBootstrap,
1218
+ includeForkTakeoverBootstrap: needsForkTakeoverBootstrap,
1219
+ forkTakeover: forkTakeoverForSend
1220
+ });
1221
+ if (nativeTakeoverForSend?.["native_session_id"] && executor.kind === "codex") {
1222
+ runNativeCodexResumeSend({
1223
+ options,
1224
+ conversation,
1225
+ nextConversation,
1226
+ statePath,
1227
+ logPath,
1228
+ executor,
1229
+ executorEnv,
1230
+ message,
1231
+ payload,
1232
+ nativeTakeover: nativeTakeoverForSend,
1233
+ needsNativeTakeoverBootstrap
1234
+ });
1235
+ return;
1236
+ }
1237
+ const acpxPath = resolveExecutable("acpx");
658
1238
  const executorModel = modelForExecutor(executor, {
659
1239
  model: options.model ?? conversation.executor_model
660
1240
  });
@@ -662,7 +1242,8 @@ function runSend(options) {
662
1242
  acpxPath,
663
1243
  executor,
664
1244
  cwd: conversation.workspace ?? process.cwd(),
665
- env: executorEnv
1245
+ env: executorEnv,
1246
+ resumeSessionId: nativeTakeoverForSend?.["native_session_id"]
666
1247
  });
667
1248
  appendEvent(logPath, {
668
1249
  ts: new Date().toISOString(),
@@ -714,13 +1295,6 @@ function runSend(options) {
714
1295
  }
715
1296
  throw new Error(cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`));
716
1297
  }
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
1298
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
725
1299
  if (options.background) {
726
1300
  const outputPath = path.join(path.dirname(logPath), `${executor.kind}-followup-output.log`);
@@ -766,8 +1340,16 @@ function runSend(options) {
766
1340
  executor_pid: child.pid ?? null,
767
1341
  agent_timeout_minutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES)
768
1342
  });
769
- printJson({
1343
+ const deliveredConversation = markTakeoverBootstrapped({
770
1344
  conversation: nextConversation,
1345
+ statePath,
1346
+ logPath,
1347
+ executor,
1348
+ native: needsNativeTakeoverBootstrap,
1349
+ fork: needsForkTakeoverBootstrap
1350
+ });
1351
+ printJson({
1352
+ conversation: deliveredConversation,
771
1353
  message,
772
1354
  delivered: true,
773
1355
  background: true,
@@ -835,13 +1417,303 @@ function runSend(options) {
835
1417
  }
836
1418
  throw new Error(cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`));
837
1419
  }
1420
+ const deliveredConversation = markTakeoverBootstrapped({
1421
+ conversation: nextConversation,
1422
+ statePath,
1423
+ logPath,
1424
+ executor,
1425
+ native: needsNativeTakeoverBootstrap,
1426
+ fork: needsForkTakeoverBootstrap
1427
+ });
838
1428
  printJson({
1429
+ conversation: deliveredConversation,
1430
+ message,
1431
+ delivered: true,
1432
+ executor,
1433
+ budget: budgetAction(deliveredConversation)
1434
+ });
1435
+ }
1436
+ function runNativeCodexResumeSend({ options, conversation, nextConversation, statePath, logPath, executor, executorEnv, message, payload, nativeTakeover, needsNativeTakeoverBootstrap }) {
1437
+ const codexPath = resolveExecutable("codex");
1438
+ const nativeSessionId = String(nativeTakeover["native_session_id"]);
1439
+ const nativeModel = nativeCodexModelForSend({ options, conversation, nativeTakeover });
1440
+ const codexArgs = buildCodexExecResumeArgs({
1441
+ nativeSessionId,
1442
+ payload,
1443
+ model: nativeModel
1444
+ });
1445
+ appendEvent(logPath, {
1446
+ ts: new Date().toISOString(),
1447
+ conversation_id: conversation.conversation_id,
1448
+ event: "native_executor_resume_prepare",
1449
+ executor,
1450
+ native_session_id: nativeSessionId,
1451
+ model: nativeModel ?? null
1452
+ });
1453
+ runtimeLog("info", "native_executor_resume_prepare", {
1454
+ conversation_id: conversation.conversation_id,
1455
+ agent: executor.kind,
1456
+ executor_session: executor.session,
1457
+ native_session_id: nativeSessionId,
1458
+ model: nativeModel
1459
+ });
1460
+ if (options.background) {
1461
+ const outputPath = path.join(path.dirname(logPath), `${executor.kind}-native-resume-output.log`);
1462
+ const outputFd = fs.openSync(outputPath, "a");
1463
+ const child = spawn(codexPath, codexArgs, {
1464
+ detached: true,
1465
+ stdio: ["ignore", outputFd, outputFd],
1466
+ cwd: conversation.workspace ?? process.cwd(),
1467
+ env: executorEnv
1468
+ });
1469
+ child.unref();
1470
+ fs.closeSync(outputFd);
1471
+ appendEvent(logPath, {
1472
+ ts: new Date().toISOString(),
1473
+ conversation_id: conversation.conversation_id,
1474
+ event: "native_executor_resume_launch",
1475
+ mode: "background",
1476
+ pid: child.pid ?? null,
1477
+ executor,
1478
+ native_session_id: nativeSessionId,
1479
+ output_path: outputPath
1480
+ });
1481
+ runtimeLog("info", "native_executor_resume_launch", {
1482
+ conversation_id: conversation.conversation_id,
1483
+ agent: executor.kind,
1484
+ executor_session: executor.session,
1485
+ native_session_id: nativeSessionId,
1486
+ mode: "background",
1487
+ pid: child.pid ?? null,
1488
+ output_path: outputPath
1489
+ });
1490
+ const monitor = startExecutorMonitor({
1491
+ statePath,
1492
+ logPath,
1493
+ pid: child.pid,
1494
+ outputPath,
1495
+ agentTimeoutMinutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES),
1496
+ pollIntervalMs: Number(options.monitorPollIntervalMs ?? DEFAULT_MONITOR_POLL_INTERVAL_MS)
1497
+ });
1498
+ appendEvent(logPath, {
1499
+ ts: new Date().toISOString(),
1500
+ conversation_id: conversation.conversation_id,
1501
+ event: "executor_monitor_launch",
1502
+ pid: monitor.pid ?? null,
1503
+ executor_pid: child.pid ?? null,
1504
+ agent_timeout_minutes: Number(options.agentTimeoutMinutes ?? DEFAULT_AGENT_TIMEOUT_MINUTES)
1505
+ });
1506
+ const deliveredConversation = markTakeoverBootstrapped({
1507
+ conversation: nextConversation,
1508
+ statePath,
1509
+ logPath,
1510
+ executor,
1511
+ native: needsNativeTakeoverBootstrap,
1512
+ fork: false
1513
+ });
1514
+ printJson({
1515
+ conversation: deliveredConversation,
1516
+ message,
1517
+ delivered: true,
1518
+ background: true,
1519
+ native_resume: true,
1520
+ pid: child.pid ?? null,
1521
+ monitor_pid: monitor.pid ?? null,
1522
+ output_path: outputPath,
1523
+ executor,
1524
+ budget: budgetAction(deliveredConversation)
1525
+ });
1526
+ return;
1527
+ }
1528
+ const sendResult = spawnSync(codexPath, codexArgs, {
1529
+ encoding: "utf8",
1530
+ maxBuffer: 1024 * 1024 * 10,
1531
+ cwd: conversation.workspace ?? process.cwd(),
1532
+ env: executorEnv
1533
+ });
1534
+ appendEvent(logPath, {
1535
+ ts: new Date().toISOString(),
1536
+ conversation_id: conversation.conversation_id,
1537
+ event: "native_executor_resume_send",
1538
+ status: sendResult.status ?? null,
1539
+ executor,
1540
+ native_session_id: nativeSessionId,
1541
+ stdout: cleanProcessText(sendResult.stdout),
1542
+ stderr: cleanProcessText(sendResult.stderr)
1543
+ });
1544
+ runtimeLog("info", "native_executor_resume_send", {
1545
+ conversation_id: conversation.conversation_id,
1546
+ agent: executor.kind,
1547
+ executor_session: executor.session,
1548
+ native_session_id: nativeSessionId,
1549
+ status: sendResult.status ?? null,
1550
+ failure_kind: classifyProcessFailure(sendResult),
1551
+ stdout: textSummary(cleanProcessText(sendResult.stdout)),
1552
+ stderr: textSummary(cleanProcessText(sendResult.stderr))
1553
+ });
1554
+ if (sendResult.error) {
1555
+ throw new Error(`codex exec resume failed to start: ${sendResult.error.message}`);
1556
+ }
1557
+ if (sendResult.status !== 0) {
1558
+ throw new Error(cleanProcessText(sendResult.stderr || sendResult.stdout || `codex exec resume exited with status ${sendResult.status}`));
1559
+ }
1560
+ const deliveredConversation = markTakeoverBootstrapped({
839
1561
  conversation: nextConversation,
1562
+ statePath,
1563
+ logPath,
1564
+ executor,
1565
+ native: needsNativeTakeoverBootstrap,
1566
+ fork: false
1567
+ });
1568
+ printJson({
1569
+ conversation: deliveredConversation,
840
1570
  message,
841
1571
  delivered: true,
1572
+ native_resume: true,
842
1573
  executor,
843
- budget: budgetAction(nextConversation)
1574
+ budget: budgetAction(deliveredConversation)
1575
+ });
1576
+ }
1577
+ function buildCodexExecResumeArgs({ nativeSessionId, payload, model }) {
1578
+ const args = ["exec", "resume"];
1579
+ if (model) {
1580
+ args.push("--model", model);
1581
+ }
1582
+ args.push("--skip-git-repo-check", nativeSessionId, payload);
1583
+ return args;
1584
+ }
1585
+ function nativeCodexModelForSend({ options, conversation, nativeTakeover }) {
1586
+ const explicit = options.model ?? options.codexModel;
1587
+ if (explicit) {
1588
+ return normalizeNativeCodexModel(explicit);
1589
+ }
1590
+ const nativeModel = isRecord(nativeTakeover) ? nativeTakeover["native_model"] : undefined;
1591
+ if (typeof nativeModel === "string" && nativeModel.trim()) {
1592
+ return normalizeNativeCodexModel(nativeModel);
1593
+ }
1594
+ return normalizeNativeCodexModel(conversation.executor_model);
1595
+ }
1596
+ function normalizeNativeCodexModel(model) {
1597
+ const value = typeof model === "string" ? model.trim() : "";
1598
+ if (!value) {
1599
+ return undefined;
1600
+ }
1601
+ return value.replace(/\[[^\]]+\]$/u, "").replace(/\/(?:low|medium|high|xhigh)$/u, "");
1602
+ }
1603
+ function buildAgentSendPayload({ conversation, executor, message, includeNativeTakeoverBootstrap, includeForkTakeoverBootstrap, forkTakeover }) {
1604
+ const messageJson = JSON.stringify(message);
1605
+ if (!includeNativeTakeoverBootstrap && !includeForkTakeoverBootstrap) {
1606
+ return [
1607
+ "Continue the existing Agent Knock Knock delegation using this structured OpenClaw message.",
1608
+ "If this message answers a question or blocker, follow it as the product decision.",
1609
+ "Continue to report back only through the callback command already provided for this conversation.",
1610
+ "",
1611
+ messageJson
1612
+ ].join("\n");
1613
+ }
1614
+ if (includeForkTakeoverBootstrap) {
1615
+ const summary = forkTakeoverSummaryText(forkTakeover);
1616
+ return [
1617
+ executorBootstrapPrompt({
1618
+ callbackCommand: conversation.callback_command,
1619
+ executorName: executor.display_name,
1620
+ softLimit: Number(conversation.soft_limit ?? 50),
1621
+ hardLimit: Number(conversation.hard_limit ?? 100)
1622
+ }),
1623
+ "",
1624
+ "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.",
1625
+ "",
1626
+ "Approved source-session summary:",
1627
+ summary || "(No approved summary was provided.)",
1628
+ "",
1629
+ "Initial AKK fork message:",
1630
+ messageJson
1631
+ ].join("\n");
1632
+ }
1633
+ return [
1634
+ executorBootstrapPrompt({
1635
+ callbackCommand: conversation.callback_command,
1636
+ executorName: executor.display_name,
1637
+ softLimit: Number(conversation.soft_limit ?? 50),
1638
+ hardLimit: Number(conversation.hard_limit ?? 100)
1639
+ }),
1640
+ "",
1641
+ "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.",
1642
+ "",
1643
+ "Initial AKK takeover message:",
1644
+ messageJson
1645
+ ].join("\n");
1646
+ }
1647
+ function forkTakeoverSummaryText(forkTakeover) {
1648
+ return String(isRecord(forkTakeover) ? forkTakeover.summary ?? "" : "").trim();
1649
+ }
1650
+ function markTakeoverBootstrapped({ conversation, statePath, logPath, executor, native, fork }) {
1651
+ let nextConversation = conversation;
1652
+ if (native) {
1653
+ nextConversation = markNativeSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
1654
+ }
1655
+ if (fork) {
1656
+ nextConversation = markForkSessionBootstrapped({ conversation: nextConversation, statePath, logPath, executor });
1657
+ }
1658
+ return nextConversation;
1659
+ }
1660
+ function markNativeSessionBootstrapped({ conversation, statePath, logPath, executor }) {
1661
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1662
+ ? conversation.native_session_takeover
1663
+ : {};
1664
+ const now = new Date().toISOString();
1665
+ const nextConversation = {
1666
+ ...conversation,
1667
+ native_session_takeover: {
1668
+ ...nativeTakeover,
1669
+ needs_bootstrap: false,
1670
+ bootstrapped_at: now
1671
+ },
1672
+ updated_at: now
1673
+ };
1674
+ saveState(statePath, nextConversation);
1675
+ appendEvent(logPath, {
1676
+ ts: now,
1677
+ conversation_id: conversation.conversation_id,
1678
+ event: "native_session_bootstrapped",
1679
+ executor
1680
+ });
1681
+ runtimeLog("info", "native_session_bootstrapped", {
1682
+ conversation_id: conversation.conversation_id,
1683
+ agent: executor.kind,
1684
+ executor_session: executor.session,
1685
+ state_path: statePath
1686
+ });
1687
+ return nextConversation;
1688
+ }
1689
+ function markForkSessionBootstrapped({ conversation, statePath, logPath, executor }) {
1690
+ const forkTakeover = isRecord(conversation.fork_context_takeover)
1691
+ ? conversation.fork_context_takeover
1692
+ : {};
1693
+ const now = new Date().toISOString();
1694
+ const nextConversation = {
1695
+ ...conversation,
1696
+ fork_context_takeover: {
1697
+ ...forkTakeover,
1698
+ needs_bootstrap: false,
1699
+ bootstrapped_at: now
1700
+ },
1701
+ updated_at: now
1702
+ };
1703
+ saveState(statePath, nextConversation);
1704
+ appendEvent(logPath, {
1705
+ ts: now,
1706
+ conversation_id: conversation.conversation_id,
1707
+ event: "fork_session_bootstrapped",
1708
+ executor
1709
+ });
1710
+ runtimeLog("info", "fork_session_bootstrapped", {
1711
+ conversation_id: conversation.conversation_id,
1712
+ agent: executor.kind,
1713
+ executor_session: executor.session,
1714
+ state_path: statePath
844
1715
  });
1716
+ return nextConversation;
845
1717
  }
846
1718
  function requiresExplicitRecoveryDecision(executor, options = {}) {
847
1719
  if (options.recoveryPolicy === "explicit" || options.recoveryPolicy === "explicit-decision") {
@@ -1216,6 +2088,27 @@ function runMonitor(options) {
1216
2088
  return;
1217
2089
  }
1218
2090
  if (Number.isFinite(pid) && !isProcessAlive(pid)) {
2091
+ const modelSelection = detectModelSelectionError(readOutputTail(options.outputPath));
2092
+ if (modelSelection) {
2093
+ const modelSelectionConversation = markConversationNeedsModelSelection({
2094
+ statePath,
2095
+ logPath,
2096
+ reason: modelSelection.message,
2097
+ detail: {
2098
+ executor_pid: pid,
2099
+ output_path: options.outputPath,
2100
+ model_selection: modelSelection
2101
+ }
2102
+ });
2103
+ printJson({
2104
+ conversation: modelSelectionConversation,
2105
+ monitored: true,
2106
+ stalled: false,
2107
+ needs_model_selection: true,
2108
+ reason: modelSelectionConversation?.model_selection?.message ?? modelSelection.message
2109
+ });
2110
+ return;
2111
+ }
1219
2112
  const stalledConversation = markConversationStalled({
1220
2113
  statePath,
1221
2114
  logPath,
@@ -1884,12 +2777,65 @@ function isWaitingForAgent(status) {
1884
2777
  function isProcessAlive(pid) {
1885
2778
  try {
1886
2779
  process.kill(pid, 0);
1887
- return true;
2780
+ return !isZombieProcess(pid);
1888
2781
  }
1889
2782
  catch (error) {
1890
2783
  return error?.code === "EPERM";
1891
2784
  }
1892
2785
  }
2786
+ function isZombieProcess(pid) {
2787
+ const result = spawnSync("ps", ["-o", "stat=", "-p", String(pid)], {
2788
+ encoding: "utf8"
2789
+ });
2790
+ if (result.status !== 0) {
2791
+ return false;
2792
+ }
2793
+ return result.stdout.trim().toUpperCase().startsWith("Z");
2794
+ }
2795
+ function terminateProcessTarget(target, { timeoutMs = 3000 } = {}) {
2796
+ const pids = [...target.childPids, target.pid]
2797
+ .filter((pid, index, all) => Number.isInteger(pid) && pid > 0 && all.indexOf(pid) === index);
2798
+ const signals = [];
2799
+ for (const pid of pids) {
2800
+ signals.push(sendSignalToPid(pid, "SIGTERM"));
2801
+ }
2802
+ const exited = waitForPidsToExit(pids, timeoutMs);
2803
+ return {
2804
+ target,
2805
+ signal: "SIGTERM",
2806
+ signals,
2807
+ exited,
2808
+ remainingPids: pids.filter((pid) => isProcessAlive(pid))
2809
+ };
2810
+ }
2811
+ function sendSignalToPid(pid, signal) {
2812
+ try {
2813
+ process.kill(pid, signal);
2814
+ return {
2815
+ pid,
2816
+ signal,
2817
+ status: "sent"
2818
+ };
2819
+ }
2820
+ catch (error) {
2821
+ return {
2822
+ pid,
2823
+ signal,
2824
+ status: "failed",
2825
+ error: error instanceof Error ? error.message : String(error)
2826
+ };
2827
+ }
2828
+ }
2829
+ function waitForPidsToExit(pids, timeoutMs) {
2830
+ const deadline = Date.now() + timeoutMs;
2831
+ while (Date.now() < deadline) {
2832
+ if (pids.every((pid) => !isProcessAlive(pid))) {
2833
+ return true;
2834
+ }
2835
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50);
2836
+ }
2837
+ return pids.every((pid) => !isProcessAlive(pid));
2838
+ }
1893
2839
  function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
1894
2840
  const releaseLock = acquireFileLock(`${statePath}.lock`);
1895
2841
  let stalledConversation;
@@ -1938,16 +2884,70 @@ function markConversationStalled({ statePath, logPath, reason, detail = {} }) {
1938
2884
  statePath,
1939
2885
  logPath,
1940
2886
  conversation: stalledConversation,
1941
- reason
2887
+ reason,
2888
+ detail
1942
2889
  });
1943
2890
  }
1944
2891
  return stalledConversation;
1945
2892
  }
1946
- function deliverStalledNotification({ statePath, logPath, conversation, reason }) {
2893
+ function markConversationNeedsModelSelection({ statePath, logPath, reason, detail = {} }) {
2894
+ const releaseLock = acquireFileLock(`${statePath}.lock`);
2895
+ let modelSelectionConversation;
2896
+ try {
2897
+ const conversation = loadState(statePath);
2898
+ if (!isWaitingForAgent(conversation.status)) {
2899
+ runtimeLog("info", "executor_monitor_finished", {
2900
+ conversation_id: conversation.conversation_id,
2901
+ status: conversation.status,
2902
+ reason: "conversation_changed_before_model_selection"
2903
+ });
2904
+ return conversation;
2905
+ }
2906
+ const now = new Date().toISOString();
2907
+ const detailRecord = detail;
2908
+ const modelSelection = isRecord(detailRecord.model_selection)
2909
+ ? detailRecord.model_selection
2910
+ : {};
2911
+ modelSelectionConversation = {
2912
+ ...conversation,
2913
+ status: "needs_model_selection",
2914
+ model_selection: {
2915
+ detected_at: now,
2916
+ message: reason,
2917
+ ...modelSelection
2918
+ },
2919
+ updated_at: now
2920
+ };
2921
+ saveState(statePath, modelSelectionConversation);
2922
+ appendEvent(logPath, {
2923
+ ts: now,
2924
+ conversation_id: conversation.conversation_id,
2925
+ event: "conversation_needs_model_selection",
2926
+ status: "needs_model_selection",
2927
+ reason,
2928
+ ...detailRecord
2929
+ });
2930
+ runtimeLog("warn", "conversation_needs_model_selection", {
2931
+ conversation_id: conversation.conversation_id,
2932
+ agent: executorForConversation(conversation).kind,
2933
+ executor_session: executorForConversation(conversation).session,
2934
+ state_path: statePath,
2935
+ event_log_path: logPath,
2936
+ reason,
2937
+ ...detailRecord
2938
+ });
2939
+ }
2940
+ finally {
2941
+ releaseLock();
2942
+ }
2943
+ return modelSelectionConversation;
2944
+ }
2945
+ function deliverStalledNotification({ statePath, logPath, conversation, reason, detail = {} }) {
1947
2946
  if (!conversation.gateway_method) {
1948
2947
  return;
1949
2948
  }
1950
2949
  const executor = executorForConversation(conversation);
2950
+ const trace = buildStalledTraceSummary({ conversation, logPath, detail });
1951
2951
  const message = createMessage({
1952
2952
  conversation,
1953
2953
  from: executor.actor,
@@ -1959,14 +2959,17 @@ function deliverStalledNotification({ statePath, logPath, conversation, reason }
1959
2959
  "",
1960
2960
  `Conversation: ${conversation.conversation_id}`,
1961
2961
  `Session: ${executor.session}`,
2962
+ trace ? `Trace: ${trace}` : "",
1962
2963
  "Use `AKK status` for details, `AKK send` to retry/follow up, or `AKK close` to close it."
1963
- ].join("\n")
2964
+ ].filter(Boolean).join("\n")
1964
2965
  });
2966
+ const gatewayToken = conversation.gateway_token;
2967
+ const gatewayUrl = gatewayToken ? conversation.gateway_url : undefined;
1965
2968
  const delivery = deliverToGatewayMethod({
1966
2969
  method: conversation.gateway_method,
1967
2970
  openclawBin: conversation.openclaw_bin,
1968
- gatewayUrl: conversation.gateway_url,
1969
- token: conversation.gateway_token,
2971
+ gatewayUrl,
2972
+ token: gatewayToken,
1970
2973
  sessionKey: conversation.gateway_session ?? conversation.openclaw_session,
1971
2974
  statePath,
1972
2975
  logPath,
@@ -2000,8 +3003,8 @@ function deliverStalledNotification({ statePath, logPath, conversation, reason }
2000
3003
  }
2001
3004
  const chatSendDelivery = deliverToChatSend({
2002
3005
  openclawBin: conversation.openclaw_bin,
2003
- gatewayUrl: conversation.gateway_url,
2004
- token: conversation.gateway_token,
3006
+ gatewayUrl,
3007
+ token: gatewayToken,
2005
3008
  params: chatSendParams
2006
3009
  });
2007
3010
  appendEvent(logPath, {
@@ -2020,6 +3023,42 @@ function deliverStalledNotification({ statePath, logPath, conversation, reason }
2020
3023
  stderr: textSummary(chatSendDelivery.stderr)
2021
3024
  });
2022
3025
  }
3026
+ function buildStalledTraceSummary({ conversation, logPath, detail = {} }) {
3027
+ const detailRecord = isRecord(detail) ? detail : {};
3028
+ const outputPath = typeof detailRecord.output_path === "string"
3029
+ ? detailRecord.output_path
3030
+ : traceOutputPath({
3031
+ conversation,
3032
+ events: safeReadEvents(logPath),
3033
+ logPath
3034
+ });
3035
+ if (!outputPath || !fs.existsSync(outputPath)) {
3036
+ return undefined;
3037
+ }
3038
+ const output = fs.readFileSync(outputPath, "utf8").slice(-64 * 1024);
3039
+ const parts = output
3040
+ .split(/\r?\n/)
3041
+ .map((line) => sanitizeTraceText(line.trim(), 220))
3042
+ .filter((line) => line &&
3043
+ !line.startsWith("input:") &&
3044
+ !line.startsWith("output:") &&
3045
+ !line.startsWith("{") &&
3046
+ !line.startsWith("}") &&
3047
+ !line.includes("--message-json"))
3048
+ .slice(-6);
3049
+ if (parts.length === 0) {
3050
+ return undefined;
3051
+ }
3052
+ return cleanProcessText(parts.join(" | "))?.slice(0, 500);
3053
+ }
3054
+ function safeReadEvents(logPath) {
3055
+ try {
3056
+ return readNdjsonLog(logPath);
3057
+ }
3058
+ catch {
3059
+ return [];
3060
+ }
3061
+ }
2023
3062
  function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
2024
3063
  const timeoutMinutes = Number(options.idleTimeoutMinutes ?? DEFAULT_IDLE_TIMEOUT_MINUTES);
2025
3064
  if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0) {
@@ -2266,6 +3305,32 @@ function parseOptionalJson(text) {
2266
3305
  return undefined;
2267
3306
  }
2268
3307
  }
3308
+ function createAgentSessionProvider(agent, options) {
3309
+ if (agent !== "codex") {
3310
+ throw new Error(`unsupported agent session provider: ${agent}`);
3311
+ }
3312
+ if (options.threadsJson || options.processesJson || options.rolloutsJson) {
3313
+ return new CodexLocalSessionProvider(new InlineCodexSessionAdapter({
3314
+ threads: parseJsonOption(options.threadsJson, "--threads-json"),
3315
+ processes: parseJsonOption(options.processesJson, "--processes-json"),
3316
+ rollouts: parseJsonOption(options.rolloutsJson, "--rollouts-json")
3317
+ }));
3318
+ }
3319
+ return new CodexLocalSessionProvider(new CodexStoreAdapter({
3320
+ codexHome: expandHome(options.codexHome)
3321
+ }));
3322
+ }
3323
+ function parseJsonOption(value, optionName) {
3324
+ if (!value) {
3325
+ return undefined;
3326
+ }
3327
+ try {
3328
+ return JSON.parse(String(value));
3329
+ }
3330
+ catch (error) {
3331
+ throw new Error(`${optionName} must be valid JSON: ${error.message}`);
3332
+ }
3333
+ }
2269
3334
  function expandHome(filePath) {
2270
3335
  if (filePath === "~") {
2271
3336
  return process.env.HOME;
@@ -2316,6 +3381,56 @@ function classifyProcessFailure(result) {
2316
3381
  }
2317
3382
  return undefined;
2318
3383
  }
3384
+ function readOutputTail(outputPath, maxBytes = 65536) {
3385
+ if (!outputPath) {
3386
+ return "";
3387
+ }
3388
+ try {
3389
+ const resolvedPath = expandHome(outputPath);
3390
+ const stat = fs.statSync(resolvedPath);
3391
+ const start = Math.max(0, stat.size - maxBytes);
3392
+ const length = stat.size - start;
3393
+ const fd = fs.openSync(resolvedPath, "r");
3394
+ try {
3395
+ const buffer = Buffer.alloc(length);
3396
+ fs.readSync(fd, buffer, 0, length, start);
3397
+ return buffer.toString("utf8");
3398
+ }
3399
+ finally {
3400
+ fs.closeSync(fd);
3401
+ }
3402
+ }
3403
+ catch {
3404
+ return "";
3405
+ }
3406
+ }
3407
+ function detectModelSelectionError(text) {
3408
+ const cleaned = cleanProcessText(text);
3409
+ if (!cleaned) {
3410
+ return undefined;
3411
+ }
3412
+ const unsupportedAccount = /The '([^']+)' model is not supported when using Codex with a ChatGPT account/i.exec(cleaned);
3413
+ if (unsupportedAccount) {
3414
+ return {
3415
+ kind: "unsupported_chatgpt_account_model",
3416
+ attempted_model: unsupportedAccount[1],
3417
+ message: unsupportedAccount[0]
3418
+ };
3419
+ }
3420
+ const unadvertised = /Cannot apply --model "([^"]+)": the ACP agent did not advertise that model\. Available models:\s*([^\n\r]+)/i.exec(cleaned);
3421
+ if (unadvertised) {
3422
+ return {
3423
+ kind: "unadvertised_acpx_model",
3424
+ attempted_model: unadvertised[1],
3425
+ available_models: unadvertised[2]
3426
+ .split(",")
3427
+ .map((model) => model.trim())
3428
+ .filter(Boolean),
3429
+ message: unadvertised[0]
3430
+ };
3431
+ }
3432
+ return undefined;
3433
+ }
2319
3434
  function runtimeLog(level, event, fields = {}) {
2320
3435
  try {
2321
3436
  writeRuntimeLog({
@@ -2356,6 +3471,8 @@ function usage() {
2356
3471
  agent-knock-knock close --conversation <id> [--reason <text>]
2357
3472
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--no-restart]
2358
3473
  agent-knock-knock doctor
3474
+ agent-knock-knock agent discover --agent codex --scope capabilities|sessions|active
3475
+ agent-knock-knock agent takeover --agent codex --session-id <id> --strategy safe_resume|terminate_then_resume|fork [--create-conversation]
2359
3476
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]
2360
3477
  agent-knock-knock transcript --log <file> [--include-raw]
2361
3478
  agent-knock-knock transcript --conversation <dir> [--include-raw]