@scotthuang/agent-knock-knock 0.2.1 → 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
@@ -14,6 +14,7 @@ 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);
@@ -242,7 +246,7 @@ async function runAgentDiscover(options) {
242
246
  agent,
243
247
  scope,
244
248
  capabilities,
245
- active: await provider.listActiveSessions()
249
+ active: await listActiveSessionsWithTerminalControl(provider, options)
246
250
  };
247
251
  }
248
252
  throw new Error(`unsupported discover scope: ${scope}`);
@@ -267,7 +271,7 @@ async function runAgentTakeover(options) {
267
271
  };
268
272
  }
269
273
  if (strategy === "safe_resume") {
270
- const plan = planSafeResume(session, await provider.listActiveSessions());
274
+ const plan = planSafeResume(session, await listActiveSessionsWithTerminalControl(provider, options));
271
275
  if (plan.allowed && options.createConversation) {
272
276
  const modelInfo = await provider.getSessionModel(session.id);
273
277
  const attached = createNativeSessionConversation({
@@ -297,7 +301,7 @@ async function runAgentTakeover(options) {
297
301
  };
298
302
  }
299
303
  if (strategy === "terminate_then_resume") {
300
- const activeSessions = await provider.listActiveSessions();
304
+ const activeSessions = await listActiveSessionsWithTerminalControl(provider, options);
301
305
  const plan = planTakeover(session, activeSessions);
302
306
  if (options.confirmTerminate === true) {
303
307
  const expectedPid = Number(required(options.expectedPid, "--expected-pid is required with --confirm-terminate"));
@@ -332,7 +336,7 @@ async function runAgentTakeover(options) {
332
336
  const termination = terminateProcessTarget(target, {
333
337
  timeoutMs: Number(options.terminateTimeoutMs ?? 3000)
334
338
  });
335
- const activeAfterTermination = await provider.listActiveSessions();
339
+ const activeAfterTermination = await listActiveSessionsWithTerminalControl(provider, options);
336
340
  const afterTerminationPlan = planTakeover(session, activeAfterTermination);
337
341
  if (afterTerminationPlan.targets.some((candidate) => candidate.sessionId === session.id || candidate.pid === expectedPid)) {
338
342
  return {
@@ -379,6 +383,60 @@ async function runAgentTakeover(options) {
379
383
  plan
380
384
  };
381
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
+ }
382
440
  if (strategy === "fork") {
383
441
  const contextPackage = await provider.getForkContext({
384
442
  sessionId,
@@ -516,6 +574,120 @@ function selectTerminateTarget({ plan, session, activeSessions, expectedPid, all
516
574
  matchKind: "cwd_only_confirmed"
517
575
  };
518
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
+ }
519
691
  function createForkConversation({ agent, strategy, session, contextPackage, forkSummary, modelInfo, options }) {
520
692
  const workspace = session.cwd;
521
693
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
@@ -609,7 +781,7 @@ function createForkConversation({ agent, strategy, session, contextPackage, fork
609
781
  next: `Use AKK send ${forkedConversation.conversation_id}: <message> to start the forked ${agent} session with the approved summary.`
610
782
  };
611
783
  }
612
- function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy }) {
784
+ function createNativeSessionConversation({ agent, strategy, session, modelInfo, options, takeoverMatchKind = strategy, terminalControl = undefined, needsBootstrap = true }) {
613
785
  const workspace = session.cwd;
614
786
  const storeDir = expandHome(options.storeDir ?? options.logDir ?? defaultStoreDir(workspace));
615
787
  cleanupIdleConversations(storeDir, options);
@@ -666,7 +838,8 @@ function createNativeSessionConversation({ agent, strategy, session, modelInfo,
666
838
  acpx_model: modelInfo?.acpxModel,
667
839
  model_source: modelInfo?.source,
668
840
  takeover_match_kind: takeoverMatchKind,
669
- needs_bootstrap: true
841
+ terminal_control: terminalControl,
842
+ needs_bootstrap: needsBootstrap
670
843
  }
671
844
  }, paths);
672
845
  saveState(paths.statePath, attachedConversation);
@@ -1118,7 +1291,7 @@ function runList(options) {
1118
1291
  cleanup
1119
1292
  });
1120
1293
  }
1121
- function runStatus(options) {
1294
+ async function runStatus(options) {
1122
1295
  cleanupIdleConversations(storeDirFromOptions(options), options);
1123
1296
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
1124
1297
  const events = readExistingEvents(logPath);
@@ -1133,6 +1306,24 @@ function runStatus(options) {
1133
1306
  if (options.trace) {
1134
1307
  result.trace = buildConversationTrace({ conversation, events, logPath });
1135
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
+ }
1136
1327
  printJson(result);
1137
1328
  runtimeLog("info", "task_status_read", {
1138
1329
  conversation_id: conversation.conversation_id,
@@ -1143,7 +1334,7 @@ function runStatus(options) {
1143
1334
  trace: Boolean(options.trace)
1144
1335
  });
1145
1336
  }
1146
- function runSend(options) {
1337
+ async function runSend(options) {
1147
1338
  const messageBody = required(options.message ?? options.request, "--message is required");
1148
1339
  cleanupIdleConversations(storeDirFromOptions(options), options);
1149
1340
  const { conversation, statePath, logPath } = loadConversationFromOptions(options);
@@ -1215,6 +1406,21 @@ function runSend(options) {
1215
1406
  includeForkTakeoverBootstrap: needsForkTakeoverBootstrap,
1216
1407
  forkTakeover: forkTakeoverForSend
1217
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
+ }
1218
1424
  if (nativeTakeoverForSend?.["native_session_id"] && executor.kind === "codex") {
1219
1425
  runNativeCodexResumeSend({
1220
1426
  options,
@@ -1476,6 +1682,94 @@ function runSend(options) {
1476
1682
  budget: budgetAction(deliveredConversation)
1477
1683
  });
1478
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
+ }
1479
1773
  function runNativeCodexResumeSend({ options, conversation, nextConversation, statePath, logPath, executor, executorEnv, message, payload, nativeTakeover, needsNativeTakeoverBootstrap }) {
1480
1774
  const codexPath = resolveExecutable("codex");
1481
1775
  const nativeSessionId = String(nativeTakeover["native_session_id"]);
@@ -3349,6 +3643,9 @@ function required(value, message) {
3349
3643
  function isRecord(value) {
3350
3644
  return value !== null && typeof value === "object" && !Array.isArray(value);
3351
3645
  }
3646
+ function stringValue(value) {
3647
+ return typeof value === "string" && value.trim().length > 0 ? value : undefined;
3648
+ }
3352
3649
  function parseOptionalJson(text) {
3353
3650
  try {
3354
3651
  return JSON.parse(String(text));
@@ -3517,13 +3814,14 @@ function usage() {
3517
3814
  agent-knock-knock list [--store-dir <dir>] [--agent ${agentList}] [--status <status>] [--all]
3518
3815
  agent-knock-knock status --conversation <id> [--store-dir <dir>] [--trace]
3519
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>
3520
3818
  agent-knock-knock cancel --conversation <id> [--all-proxy <url>]
3521
3819
  agent-knock-knock recover --conversation <id> [--session <name>] [--all-proxy <url>]
3522
3820
  agent-knock-knock close --conversation <id> [--reason <text>]
3523
3821
  agent-knock-knock install-openclaw [--openclaw-bin <path>] [--skill-path <path>] [--no-restart]
3524
3822
  agent-knock-knock doctor
3525
3823
  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]
3824
+ agent-knock-knock agent takeover --agent codex --session-id <id> --strategy safe_resume|terminate_then_resume|terminal_control|fork [--create-conversation]
3527
3825
  agent-knock-knock callback --state <file> --message-json <json> [--record-only]
3528
3826
  agent-knock-knock transcript --log <file> [--include-raw]
3529
3827
  agent-knock-knock transcript --conversation <dir> [--include-raw]