@scotthuang/agent-knock-knock 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/src/cli.js CHANGED
@@ -8,12 +8,13 @@ import { fileURLToPath } from "node:url";
8
8
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
9
9
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
10
10
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
11
- import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor, sessionRecoveryStrategyForExecutor } from "./executors.js";
11
+ import { EXECUTOR_KINDS, acpxCommandForExecutor, executorDefinitionForKind, modelEnvForExecutor, normalizeModelForExecutor, proxyEnvForExecutor } from "./executors.js";
12
12
  import { executorBootstrapPrompt } from "./bootstrap.js";
13
13
  import { writeRuntimeLog } from "./runtime-log.js";
14
14
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
15
15
  import { appendEvent, defaultStoreDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
16
16
  import { planFork, planSafeResume, planTakeover } from "./session-takeover-planner.js";
17
+ import { StaticTerminalControlProvider, TmuxTerminalControlProvider, enrichActiveProcessesWithTerminalControl } from "./terminal-control-provider.js";
17
18
  const DEFAULT_IDLE_TIMEOUT_MINUTES = 10080;
18
19
  const DEFAULT_AGENT_TIMEOUT_MINUTES = 60;
19
20
  const DEFAULT_MONITOR_POLL_INTERVAL_MS = 5000;
@@ -89,10 +90,13 @@ async function runCommand(commandName, options) {
89
90
  runList(options);
90
91
  }
91
92
  else if (commandName === "status") {
92
- runStatus(options);
93
+ await runStatus(options);
93
94
  }
94
95
  else if (commandName === "send") {
95
- runSend(options);
96
+ await runSend(options);
97
+ }
98
+ else if (commandName === "approve") {
99
+ await runApprove(options);
96
100
  }
97
101
  else if (commandName === "cancel") {
98
102
  runCancel(options);
@@ -100,9 +104,6 @@ async function runCommand(commandName, options) {
100
104
  else if (commandName === "recover") {
101
105
  runRecover(options);
102
106
  }
103
- else if (commandName === "restart") {
104
- runRestart(options);
105
- }
106
107
  else if (commandName === "close") {
107
108
  runClose(options);
108
109
  }
@@ -245,7 +246,7 @@ async function runAgentDiscover(options) {
245
246
  agent,
246
247
  scope,
247
248
  capabilities,
248
- active: await provider.listActiveSessions()
249
+ active: await listActiveSessionsWithTerminalControl(provider, options)
249
250
  };
250
251
  }
251
252
  throw new Error(`unsupported discover scope: ${scope}`);
@@ -270,7 +271,7 @@ async function runAgentTakeover(options) {
270
271
  };
271
272
  }
272
273
  if (strategy === "safe_resume") {
273
- const plan = planSafeResume(session, await provider.listActiveSessions());
274
+ const plan = planSafeResume(session, await listActiveSessionsWithTerminalControl(provider, options));
274
275
  if (plan.allowed && options.createConversation) {
275
276
  const modelInfo = await provider.getSessionModel(session.id);
276
277
  const attached = createNativeSessionConversation({
@@ -300,7 +301,7 @@ async function runAgentTakeover(options) {
300
301
  };
301
302
  }
302
303
  if (strategy === "terminate_then_resume") {
303
- const activeSessions = await provider.listActiveSessions();
304
+ const activeSessions = await listActiveSessionsWithTerminalControl(provider, options);
304
305
  const plan = planTakeover(session, activeSessions);
305
306
  if (options.confirmTerminate === true) {
306
307
  const expectedPid = Number(required(options.expectedPid, "--expected-pid is required with --confirm-terminate"));
@@ -335,7 +336,7 @@ async function runAgentTakeover(options) {
335
336
  const termination = terminateProcessTarget(target, {
336
337
  timeoutMs: Number(options.terminateTimeoutMs ?? 3000)
337
338
  });
338
- const activeAfterTermination = await provider.listActiveSessions();
339
+ const activeAfterTermination = await listActiveSessionsWithTerminalControl(provider, options);
339
340
  const afterTerminationPlan = planTakeover(session, activeAfterTermination);
340
341
  if (afterTerminationPlan.targets.some((candidate) => candidate.sessionId === session.id || candidate.pid === expectedPid)) {
341
342
  return {
@@ -382,6 +383,60 @@ async function runAgentTakeover(options) {
382
383
  plan
383
384
  };
384
385
  }
386
+ if (strategy === "terminal_control") {
387
+ const activeSessions = await listActiveSessionsWithTerminalControl(provider, options);
388
+ const plan = planTerminalControlTakeover(session, activeSessions);
389
+ if (options.confirmTerminal === true) {
390
+ const terminalTarget = String(required(options.terminalTarget, "--terminal-target is required with --confirm-terminal"));
391
+ if (!options.createConversation) {
392
+ throw new Error("--create-conversation is required with --confirm-terminal");
393
+ }
394
+ const target = plan.targets.find((candidate) => candidate.terminalControl?.target === terminalTarget);
395
+ if (!plan.allowed || !target?.terminalControl) {
396
+ return {
397
+ agent,
398
+ sessionId,
399
+ strategy,
400
+ status: "blocked",
401
+ sideEffectsExecuted: false,
402
+ plan,
403
+ error: {
404
+ code: "terminal_target_unavailable",
405
+ message: `No matching tmux-controlled Codex process was found for ${terminalTarget}`
406
+ }
407
+ };
408
+ }
409
+ const modelInfo = await provider.getSessionModel(session.id);
410
+ const attached = createNativeSessionConversation({
411
+ agent,
412
+ strategy,
413
+ session,
414
+ modelInfo,
415
+ options,
416
+ takeoverMatchKind: "terminal_control",
417
+ terminalControl: target.terminalControl,
418
+ needsBootstrap: false
419
+ });
420
+ return {
421
+ agent,
422
+ sessionId,
423
+ strategy,
424
+ status: "attached",
425
+ sideEffectsExecuted: true,
426
+ plan,
427
+ terminalControl: target.terminalControl,
428
+ ...attached
429
+ };
430
+ }
431
+ return {
432
+ agent,
433
+ sessionId,
434
+ strategy,
435
+ status: plan.allowed ? "requires_confirmation" : "blocked",
436
+ sideEffectsExecuted: false,
437
+ plan
438
+ };
439
+ }
385
440
  if (strategy === "fork") {
386
441
  const contextPackage = await provider.getForkContext({
387
442
  sessionId,
@@ -519,6 +574,120 @@ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, all
519
574
  matchKind: "cwd_only_confirmed"
520
575
  };
521
576
  }
577
+ async function listActiveSessionsWithTerminalControl(provider, options) {
578
+ const activeSessions = await provider.listActiveSessions();
579
+ return enrichActiveProcessesWithTerminalControl(activeSessions, createTerminalControlProvider(options));
580
+ }
581
+ function createTerminalControlProvider(options) {
582
+ if (options.terminalsJson || options.terminalScreensJson) {
583
+ return new StaticTerminalControlProvider({
584
+ panes: parseJsonOption(options.terminalsJson, "--terminals-json"),
585
+ screens: parseJsonOption(options.terminalScreensJson, "--terminal-screens-json")
586
+ });
587
+ }
588
+ return new TmuxTerminalControlProvider();
589
+ }
590
+ function planTerminalControlTakeover(session, activeSessions) {
591
+ const matched = activeSessions
592
+ .filter((process) => process.kind === "codex_cli" &&
593
+ process.terminalControl &&
594
+ (process.sessionId === session.id ||
595
+ (!process.sessionId && process.cwd === session.cwd)));
596
+ const matchedPidSet = new Set(matched.map((process) => process.pid));
597
+ const targets = matched
598
+ .filter((process) => !process.ppid || !matchedPidSet.has(process.ppid))
599
+ .map((process) => ({
600
+ pid: process.pid,
601
+ childPids: matched
602
+ .filter((child) => child.ppid === process.pid)
603
+ .map((child) => child.pid),
604
+ cwd: process.cwd,
605
+ command: process.command,
606
+ sessionId: process.sessionId,
607
+ terminalControl: process.terminalControl
608
+ }));
609
+ const exactTargets = targets.filter((target) => target.sessionId === session.id);
610
+ const selectableTargets = exactTargets.length > 0 ? exactTargets : targets;
611
+ return {
612
+ mode: "terminal_control",
613
+ allowed: selectableTargets.length === 1,
614
+ requiresConfirmation: selectableTargets.length === 1,
615
+ reason: selectableTargets.length === 0
616
+ ? "no_terminal_control_target"
617
+ : selectableTargets.length === 1
618
+ ? "terminal_control_available"
619
+ : "ambiguous_terminal_control_target",
620
+ targets: selectableTargets
621
+ };
622
+ }
623
+ function terminalControlFromTakeover(nativeTakeover) {
624
+ if (!isRecord(nativeTakeover)) {
625
+ return undefined;
626
+ }
627
+ const terminalControl = nativeTakeover["terminal_control"];
628
+ if (!isRecord(terminalControl) || terminalControl.kind !== "tmux") {
629
+ return undefined;
630
+ }
631
+ const target = stringValue(terminalControl.target);
632
+ const session = stringValue(terminalControl.session);
633
+ const window = Number(terminalControl.window);
634
+ const pane = Number(terminalControl.pane);
635
+ const panePid = Number(terminalControl.panePid);
636
+ if (!target || !session || !Number.isInteger(window) || !Number.isInteger(pane) || !Number.isInteger(panePid)) {
637
+ return undefined;
638
+ }
639
+ return {
640
+ kind: "tmux",
641
+ target,
642
+ session,
643
+ window,
644
+ pane,
645
+ panePid,
646
+ currentCommand: stringValue(terminalControl.currentCommand),
647
+ currentPath: stringValue(terminalControl.currentPath),
648
+ capabilities: ["capture_screen", "send_keys", "terminal_approval"]
649
+ };
650
+ }
651
+ function detectCodexApprovalPrompt(screen) {
652
+ const approvalMarkers = [
653
+ "Would you like to run the following command?",
654
+ "Would you like to make the following edits?",
655
+ "Would you like to grant these permissions?",
656
+ "needs your approval."
657
+ ];
658
+ if (!approvalMarkers.some((marker) => screen.includes(marker))) {
659
+ return {
660
+ approvable: false,
661
+ reason: "no Codex approval prompt was detected in the terminal screen"
662
+ };
663
+ }
664
+ for (const line of screen.split(/\r?\n/)) {
665
+ const match = /^[\s›]*1\.\s+(Yes,[^(]+)\(([^)]+)\)/u.exec(line.trim());
666
+ if (!match) {
667
+ continue;
668
+ }
669
+ const key = match[2].trim();
670
+ if (key !== "y") {
671
+ return {
672
+ approvable: false,
673
+ reason: `primary approval shortcut is ${key}, not y`
674
+ };
675
+ }
676
+ return {
677
+ approvable: true,
678
+ key,
679
+ label: match[1].trim()
680
+ };
681
+ }
682
+ return {
683
+ approvable: false,
684
+ reason: "no primary approve option with a shortcut was detected"
685
+ };
686
+ }
687
+ function screenExcerpt(screen, maxLength = 4000) {
688
+ const lines = screen.trimEnd().split(/\r?\n/);
689
+ return lines.slice(Math.max(0, lines.length - 80)).join("\n").slice(-maxLength);
690
+ }
522
691
  function createForkConversation({ agent, strategy, session, contextPackage, forkSummary, modelInfo, options }) {
523
692
  const workspace = session.cwd;
524
693
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
@@ -612,7 +781,7 @@ function createForkConversation({ agent, strategy, session, contextPackage, fork
612
781
  next: `Use AKK send ${forkedConversation.conversation_id}: <message> to start the forked ${agent} session with the approved summary.`
613
782
  };
614
783
  }
615
- function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy }) {
784
+ function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy, terminalControl = undefined, needsBootstrap = true }) {
616
785
  const workspace = session.cwd;
617
786
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
618
787
  cleanupIdleConversations(storeDir, options);
@@ -669,7 +838,8 @@ function createNativeSessionConversation({ agent, strategy, session, modelInfo,
669
838
  acpx_model: modelInfo?.acpxModel,
670
839
  model_source: modelInfo?.source,
671
840
  takeover_match_kind: takeoverMatchKind,
672
- needs_bootstrap: true
841
+ terminal_control: terminalControl,
842
+ needs_bootstrap: needsBootstrap
673
843
  }
674
844
  }, paths);
675
845
  saveState(paths.statePath, attachedConversation);
@@ -1121,7 +1291,7 @@ function runList(options) {
1121
1291
  cleanup
1122
1292
  });
1123
1293
  }
1124
- function runStatus(options) {
1294
+ async function runStatus(options) {
1125
1295
  cleanupIdleConversations(storeDirFromOptions(options), options);
1126
1296
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
1127
1297
  const events = readExistingEvents(logPath);
@@ -1136,6 +1306,24 @@ function runStatus(options) {
1136
1306
  if (options.trace) {
1137
1307
  result.trace = buildConversationTrace({ conversation, events, logPath });
1138
1308
  }
1309
+ const terminalControl = terminalControlFromTakeover(isRecord(conversation.native_session_takeover) ? conversation.native_session_takeover : undefined);
1310
+ if (terminalControl) {
1311
+ result.terminal_control = terminalControl;
1312
+ try {
1313
+ const screen = await createTerminalControlProvider(options).capture(terminalControl.target, {
1314
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
1315
+ });
1316
+ result.terminal_screen = {
1317
+ excerpt: screenExcerpt(screen),
1318
+ approval: detectCodexApprovalPrompt(screen)
1319
+ };
1320
+ }
1321
+ catch (error) {
1322
+ result.terminal_screen = {
1323
+ error: error instanceof Error ? error.message : String(error)
1324
+ };
1325
+ }
1326
+ }
1139
1327
  printJson(result);
1140
1328
  runtimeLog("info", "task_status_read", {
1141
1329
  conversation_id: conversation.conversation_id,
@@ -1146,7 +1334,7 @@ function runStatus(options) {
1146
1334
  trace: Boolean(options.trace)
1147
1335
  });
1148
1336
  }
1149
- function runSend(options) {
1337
+ async function runSend(options) {
1150
1338
  const messageBody = required(options.message ?? options.request, "--message is required");
1151
1339
  cleanupIdleConversations(storeDirFromOptions(options), options);
1152
1340
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
@@ -1154,7 +1342,7 @@ function runSend(options) {
1154
1342
  throw new Error(`cannot send to ${conversation.conversation_id}; conversation is ${conversation.status}`);
1155
1343
  }
1156
1344
  if (conversation.status === "needs_recovery") {
1157
- throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, restart, or close first`);
1345
+ throw new Error(`cannot send to ${conversation.conversation_id}; choose recover, close, or delegate a new task first`);
1158
1346
  }
1159
1347
  if (conversation.status === "needs_model_selection" && !options.model) {
1160
1348
  throw new Error(`cannot send to ${conversation.conversation_id}; choose a supported model with --model first`);
@@ -1218,6 +1406,21 @@ function runSend(options) {
1218
1406
  includeForkTakeoverBootstrap: needsForkTakeoverBootstrap,
1219
1407
  forkTakeover: forkTakeoverForSend
1220
1408
  });
1409
+ const terminalControlForSend = terminalControlFromTakeover(nativeTakeoverForSend);
1410
+ if (terminalControlForSend) {
1411
+ await runTerminalControlSend({
1412
+ options,
1413
+ conversation,
1414
+ nextConversation,
1415
+ statePath,
1416
+ logPath,
1417
+ executor,
1418
+ message,
1419
+ terminalControl: terminalControlForSend,
1420
+ needsNativeTakeoverBootstrap
1421
+ });
1422
+ return;
1423
+ }
1221
1424
  if (nativeTakeoverForSend?.["native_session_id"] && executor.kind === "codex") {
1222
1425
  runNativeCodexResumeSend({
1223
1426
  options,
@@ -1264,7 +1467,7 @@ function runSend(options) {
1264
1467
  stderr: textSummary(cleanProcessText(ensureSession.stderr))
1265
1468
  });
1266
1469
  if (ensureSession.error) {
1267
- if (requiresExplicitRecoveryDecision(executor, options)) {
1470
+ if (requiresExplicitRecoveryDecision(options)) {
1268
1471
  printJson(markConversationNeedsRecovery({
1269
1472
  conversation: nextConversation,
1270
1473
  statePath,
@@ -1277,10 +1480,22 @@ function runSend(options) {
1277
1480
  }));
1278
1481
  return;
1279
1482
  }
1280
- throw new Error(`acpx ${executor.kind} session ensure failed to start: ${ensureSession.error.message}`);
1483
+ autoRecoverSendFailure({
1484
+ options,
1485
+ conversation: nextConversation,
1486
+ statePath,
1487
+ logPath,
1488
+ executor,
1489
+ message,
1490
+ failedStage: "session_ensure",
1491
+ result: ensureSession,
1492
+ reason: `acpx ${executor.kind} session ensure failed to start: ${ensureSession.error.message}`
1493
+ });
1494
+ return;
1281
1495
  }
1282
1496
  if (ensureSession.status !== 0) {
1283
- if (requiresExplicitRecoveryDecision(executor, options)) {
1497
+ const reason = cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`);
1498
+ if (requiresExplicitRecoveryDecision(options)) {
1284
1499
  printJson(markConversationNeedsRecovery({
1285
1500
  conversation: nextConversation,
1286
1501
  statePath,
@@ -1289,11 +1504,22 @@ function runSend(options) {
1289
1504
  message,
1290
1505
  failedStage: "session_ensure",
1291
1506
  result: ensureSession,
1292
- reason: cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`)
1507
+ reason
1293
1508
  }));
1294
1509
  return;
1295
1510
  }
1296
- throw new Error(cleanProcessText(ensureSession.stderr || ensureSession.stdout || `acpx ${executor.kind} sessions ensure exited with status ${ensureSession.status}`));
1511
+ autoRecoverSendFailure({
1512
+ options,
1513
+ conversation: nextConversation,
1514
+ statePath,
1515
+ logPath,
1516
+ executor,
1517
+ message,
1518
+ failedStage: "session_ensure",
1519
+ result: ensureSession,
1520
+ reason
1521
+ });
1522
+ return;
1297
1523
  }
1298
1524
  const acpxArgs = buildAcpxPromptArgs({ executor, payload, model: executorModel });
1299
1525
  if (options.background) {
@@ -1386,7 +1612,7 @@ function runSend(options) {
1386
1612
  stderr: textSummary(cleanProcessText(sendResult.stderr))
1387
1613
  });
1388
1614
  if (sendResult.error) {
1389
- if (requiresExplicitRecoveryDecision(executor, options)) {
1615
+ if (requiresExplicitRecoveryDecision(options)) {
1390
1616
  printJson(markConversationNeedsRecovery({
1391
1617
  conversation: nextConversation,
1392
1618
  statePath,
@@ -1399,10 +1625,22 @@ function runSend(options) {
1399
1625
  }));
1400
1626
  return;
1401
1627
  }
1402
- throw new Error(`acpx ${executor.kind} send failed to start: ${sendResult.error.message}`);
1628
+ autoRecoverSendFailure({
1629
+ options,
1630
+ conversation: nextConversation,
1631
+ statePath,
1632
+ logPath,
1633
+ executor,
1634
+ message,
1635
+ failedStage: "message_send",
1636
+ result: sendResult,
1637
+ reason: `acpx ${executor.kind} send failed to start: ${sendResult.error.message}`
1638
+ });
1639
+ return;
1403
1640
  }
1404
1641
  if (sendResult.status !== 0) {
1405
- if (requiresExplicitRecoveryDecision(executor, options)) {
1642
+ const reason = cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`);
1643
+ if (requiresExplicitRecoveryDecision(options)) {
1406
1644
  printJson(markConversationNeedsRecovery({
1407
1645
  conversation: nextConversation,
1408
1646
  statePath,
@@ -1411,11 +1649,22 @@ function runSend(options) {
1411
1649
  message,
1412
1650
  failedStage: "message_send",
1413
1651
  result: sendResult,
1414
- reason: cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`)
1652
+ reason
1415
1653
  }));
1416
1654
  return;
1417
1655
  }
1418
- throw new Error(cleanProcessText(sendResult.stderr || sendResult.stdout || `acpx ${executor.kind} send exited with status ${sendResult.status}`));
1656
+ autoRecoverSendFailure({
1657
+ options,
1658
+ conversation: nextConversation,
1659
+ statePath,
1660
+ logPath,
1661
+ executor,
1662
+ message,
1663
+ failedStage: "message_send",
1664
+ result: sendResult,
1665
+ reason
1666
+ });
1667
+ return;
1419
1668
  }
1420
1669
  const deliveredConversation = markTakeoverBootstrapped({
1421
1670
  conversation: nextConversation,
@@ -1433,6 +1682,94 @@ function runSend(options) {
1433
1682
  budget: budgetAction(deliveredConversation)
1434
1683
  });
1435
1684
  }
1685
+ async function runApprove(options) {
1686
+ cleanupIdleConversations(storeDirFromOptions(options), options);
1687
+ const { conversation, statePath, logPath } = loadConversationFromOptions(options);
1688
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1689
+ ? conversation.native_session_takeover
1690
+ : undefined;
1691
+ const terminalControl = terminalControlFromTakeover(nativeTakeover);
1692
+ if (!terminalControl) {
1693
+ throw new Error(`conversation ${conversation.conversation_id} is not controlled through a terminal`);
1694
+ }
1695
+ const provider = createTerminalControlProvider(options);
1696
+ const screen = await provider.capture(terminalControl.target, {
1697
+ scrollbackLines: Number(options.scrollbackLines ?? 120)
1698
+ });
1699
+ const approval = detectCodexApprovalPrompt(screen);
1700
+ if (!approval.approvable) {
1701
+ printJson({
1702
+ conversation,
1703
+ approved: false,
1704
+ blocked: true,
1705
+ reason: approval.reason,
1706
+ terminal_control: terminalControl,
1707
+ screen_excerpt: screenExcerpt(screen)
1708
+ });
1709
+ return;
1710
+ }
1711
+ await provider.sendKeys(terminalControl.target, [approval.key]);
1712
+ appendEvent(logPath, {
1713
+ ts: new Date().toISOString(),
1714
+ conversation_id: conversation.conversation_id,
1715
+ event: "terminal_approval_send",
1716
+ terminal_control: terminalControl,
1717
+ key: approval.key,
1718
+ label: approval.label
1719
+ });
1720
+ runtimeLog("info", "terminal_approval_send", {
1721
+ conversation_id: conversation.conversation_id,
1722
+ terminal_target: terminalControl.target,
1723
+ key: approval.key,
1724
+ label: approval.label
1725
+ });
1726
+ saveState(statePath, {
1727
+ ...conversation,
1728
+ updated_at: new Date().toISOString()
1729
+ });
1730
+ printJson({
1731
+ conversation,
1732
+ approved: true,
1733
+ terminal_control: terminalControl,
1734
+ key: approval.key,
1735
+ label: approval.label
1736
+ });
1737
+ }
1738
+ async function runTerminalControlSend({ options, conversation, nextConversation, statePath, logPath, executor, message, terminalControl, needsNativeTakeoverBootstrap }) {
1739
+ const provider = createTerminalControlProvider(options);
1740
+ await provider.sendText(terminalControl.target, String(message.body ?? ""));
1741
+ await provider.sendKeys(terminalControl.target, ["Enter"]);
1742
+ appendEvent(logPath, {
1743
+ ts: new Date().toISOString(),
1744
+ conversation_id: conversation.conversation_id,
1745
+ event: "terminal_message_send",
1746
+ executor,
1747
+ terminal_control: terminalControl,
1748
+ message: textSummary(message.body)
1749
+ });
1750
+ runtimeLog("info", "terminal_message_send", {
1751
+ conversation_id: conversation.conversation_id,
1752
+ agent: executor.kind,
1753
+ terminal_target: terminalControl.target,
1754
+ message: textSummary(message.body)
1755
+ });
1756
+ const deliveredConversation = markTakeoverBootstrapped({
1757
+ conversation: nextConversation,
1758
+ statePath,
1759
+ logPath,
1760
+ executor,
1761
+ native: needsNativeTakeoverBootstrap,
1762
+ fork: false
1763
+ });
1764
+ printJson({
1765
+ conversation: deliveredConversation,
1766
+ message,
1767
+ delivered: true,
1768
+ terminal_control: terminalControl,
1769
+ executor,
1770
+ budget: budgetAction(deliveredConversation)
1771
+ });
1772
+ }
1436
1773
  function runNativeCodexResumeSend({ options, conversation, nextConversation, statePath, logPath, executor, executorEnv, message, payload, nativeTakeover, needsNativeTakeoverBootstrap }) {
1437
1774
  const codexPath = resolveExecutable("codex");
1438
1775
  const nativeSessionId = String(nativeTakeover["native_session_id"]);
@@ -1715,11 +2052,35 @@ function markForkSessionBootstrapped({ conversation, statePath, logPath, executo
1715
2052
  });
1716
2053
  return nextConversation;
1717
2054
  }
1718
- function requiresExplicitRecoveryDecision(executor, options = {}) {
2055
+ function requiresExplicitRecoveryDecision(options = {}) {
1719
2056
  if (options.recoveryPolicy === "explicit" || options.recoveryPolicy === "explicit-decision") {
1720
2057
  return true;
1721
2058
  }
1722
- return sessionRecoveryStrategyForExecutor(executor) === "explicit-decision";
2059
+ return false;
2060
+ }
2061
+ function autoRecoverSendFailure({ options, conversation, statePath, logPath, executor, message, failedStage, result, reason }) {
2062
+ markConversationNeedsRecovery({
2063
+ conversation,
2064
+ statePath,
2065
+ logPath,
2066
+ executor,
2067
+ message,
2068
+ failedStage,
2069
+ result,
2070
+ reason
2071
+ });
2072
+ runtimeLog("info", "conversation_auto_recovery_start", {
2073
+ conversation_id: conversation.conversation_id,
2074
+ agent: executor.kind,
2075
+ executor_session: executor.session,
2076
+ failed_stage: failedStage,
2077
+ reason: textSummary(reason)
2078
+ });
2079
+ runRecoveryDecision({
2080
+ ...options,
2081
+ mode: "recover",
2082
+ autoRecovered: true
2083
+ });
1723
2084
  }
1724
2085
  function markConversationNeedsRecovery({ conversation, statePath, logPath, executor, message, failedStage, result, reason }) {
1725
2086
  const now = new Date().toISOString();
@@ -1733,7 +2094,7 @@ function markConversationNeedsRecovery({ conversation, statePath, logPath, execu
1733
2094
  failed_message_id: message.id,
1734
2095
  pending_message: message,
1735
2096
  previous_executor: executor,
1736
- options: ["recover", "restart", "close"]
2097
+ options: ["recover", "close", "delegate"]
1737
2098
  };
1738
2099
  const nextConversation = {
1739
2100
  ...conversation,
@@ -1837,9 +2198,6 @@ function runCancel(options) {
1837
2198
  function runRecover(options) {
1838
2199
  runRecoveryDecision({ ...options, mode: "recover" });
1839
2200
  }
1840
- function runRestart(options) {
1841
- runRecoveryDecision({ ...options, mode: "restart" });
1842
- }
1843
2201
  function runRecoveryDecision(options) {
1844
2202
  cleanupIdleConversations(storeDirFromOptions(options), options);
1845
2203
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
@@ -1871,9 +2229,7 @@ function runRecoveryDecision(options) {
1871
2229
  updated_at: now
1872
2230
  };
1873
2231
  saveState(statePath, recoveredConversation);
1874
- const payload = options.mode === "recover"
1875
- ? buildRecoverPayload({ conversation, pendingMessage, logPath })
1876
- : buildRestartPayload({ pendingMessage });
2232
+ const payload = buildRecoverPayload({ conversation, pendingMessage, logPath });
1877
2233
  const acpxPath = resolveExecutable("acpx");
1878
2234
  const executorEnv = environmentForExecutor(executor, {
1879
2235
  allProxy: options.allProxy ?? conversation.executor_all_proxy
@@ -1935,8 +2291,8 @@ function runRecoveryDecision(options) {
1935
2291
  });
1936
2292
  printJson({
1937
2293
  conversation: recoveredConversation,
1938
- recovered: options.mode === "recover",
1939
- restarted: options.mode === "restart",
2294
+ recovered: true,
2295
+ auto_recovered: Boolean(options.autoRecovered),
1940
2296
  background: true,
1941
2297
  pid: child.pid ?? null,
1942
2298
  monitor_pid: monitor.pid ?? null,
@@ -1970,8 +2326,8 @@ function runRecoveryDecision(options) {
1970
2326
  }
1971
2327
  printJson({
1972
2328
  conversation: recoveredConversation,
1973
- recovered: options.mode === "recover",
1974
- restarted: options.mode === "restart",
2329
+ recovered: true,
2330
+ auto_recovered: Boolean(options.autoRecovered),
1975
2331
  delivered: true,
1976
2332
  executor,
1977
2333
  budget: budgetAction(recoveredConversation)
@@ -1994,16 +2350,6 @@ function buildRecoverPayload({ conversation, pendingMessage, logPath }) {
1994
2350
  JSON.stringify(pendingMessage)
1995
2351
  ].join("\n");
1996
2352
  }
1997
- function buildRestartPayload({ pendingMessage }) {
1998
- return [
1999
- "Restart this Agent Knock Knock task in a new ACPX session.",
2000
- "Do not assume the previous coding-agent session context is available.",
2001
- "Follow only the pending OpenClaw message below.",
2002
- "Continue to report back only through the callback command already provided for this conversation.",
2003
- "",
2004
- JSON.stringify(pendingMessage)
2005
- ].join("\n");
2006
- }
2007
2353
  function formatProtocolHistoryForRecovery(events) {
2008
2354
  const lines = events
2009
2355
  .filter((event) => event.event === "message")
@@ -3297,6 +3643,9 @@ function required(value, message) {
3297
3643
  function isRecord(value) {
3298
3644
  return value !== null && typeof value === "object" && !Array.isArray(value);
3299
3645
  }
3646
+ function stringValue(value) {
3647
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
3648
+ }
3300
3649
  function parseOptionalJson(text) {
3301
3650
  try {
3302
3651
  return JSON.parse(String(text));
@@ -3465,14 +3814,14 @@ function usage() {
3465
3814
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all]
3466
3815
  agent-knock-knock status --conversation <id> [--store-dir <dir>] [--trace]
3467
3816
  agent-knock-knock send --conversation <id> --message <text> [--type answer|task|control] [--all-proxy <url>] [--agent-timeout-minutes <minutes>]
3817
+ agent-knock-knock approve --conversation <id>
3468
3818
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
3469
3819
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
3470
- agent-knock-knock restart --conversation <id> [--session <name>] [--all-proxy <url>]
3471
3820
  agent-knock-knock close --conversation <id> [--reason <text>]
3472
3821
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--no-restart]
3473
3822
  agent-knock-knock doctor
3474
3823
  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]
3824
+ agent-knock-knock agent takeover --agent codex --session-id <id> --strategy safe_resume|terminate_then_resume|terminal_control|fork [--create-conversation]
3476
3825
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]
3477
3826
  agent-knock-knock transcript --log <file> [--include-raw]
3478
3827
  agent-knock-knock transcript --conversation <dir> [--include-raw]