@scotthuang/agent-knock-knock 0.6.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.1 - 2026-07-30
4
+
5
+ ### Added
6
+
7
+ - Return per-session `available_actions` and a versioned action contract from `AKK list`, including exact tool names, authoritative target arguments, fresh-status approval prerequisites, and recovery message IDs when currently available.
8
+
9
+ ### Fixed
10
+
11
+ - Exclude unsupported and retired ACPX executor records from session discovery so historical Cursor or pre-tmux state cannot break valid Codex and Claude Code routing, while active legacy ownership still fences its pane against double dispatch.
12
+ - Keep idle cleanup logging safe when it closes an unsupported legacy executor record.
13
+
3
14
  ## 0.6.0 - 2026-07-30
4
15
 
5
16
  ### Changed
package/README.md CHANGED
@@ -197,6 +197,8 @@ The core command surface is intentionally small:
197
197
 
198
198
  Selectors fail closed: `only` works only with one actionable target, `latest` requires a unique newest target, and `codex` or `claude` must identify exactly one eligible pane. `AKK list` shows stable short references while JSON output retains the authoritative full IDs. Before every terminal operation, AKK revalidates the expected agent PID and tmux pane identity, then confirms that the process and pane working directories still match; every send also revalidates the idle prompt immediately before typing.
199
199
 
200
+ For natural-language tool use, `agent_knock_knock_list` returns an `available_actions` object on rows in `delegated[]` and `terminal_controlled[]`; `tasks[]` remains a compatibility summary. Use only an action shown there, start with its prefilled authoritative arguments, and supply every `missing_required` field. `send` uses `selector`; status, approval, cancellation, renewal, callback retry, and close use `conversation_id`. For an ordinary send, add only `request`—`timeoutSeconds` is not a supported argument, and monitoring limits should be omitted unless the user explicitly asks to change them.
201
+
200
202
  Workspace is not a routing boundary. AKK can list, inspect, and control verified panes across projects; when more than one target matches, use a selector to choose one explicitly.
201
203
 
202
204
  If no eligible pane exists, AKK stops with setup guidance. If a send is ambiguous, run `/akk list` and retry with the returned `@short-ref`.
package/dist/src/cli.js CHANGED
@@ -11,7 +11,7 @@ import { captureClaudeTranscriptAnchor, defaultClaudeHome, detectClaudeTranscrip
11
11
  import { CodexLocalSessionProvider } from "./codex-local-session-provider.js";
12
12
  import { CodexStoreAdapter } from "./codex-store-adapter.js";
13
13
  import { applyMessageToConversation, budgetAction, createConversation, createMessage, executorForConversation, extractStructuredMessage, parseMessageJson, resolveExecutor } from "./protocol.js";
14
- import { EXECUTOR_KINDS, executorDefinitionForKind } from "./executors.js";
14
+ import { EXECUTOR_KINDS, executorDefinitionForKind, isExecutorKind } from "./executors.js";
15
15
  import { redactString, writeRuntimeLog } from "./runtime-log.js";
16
16
  import { formatTranscript, readNdjsonLog } from "./transcript.js";
17
17
  import { appendEvent, defaultStoreDir, ensureDir, listConversations, logPathForStatePath, loadConversationById, loadState, messageEvent, pathsForConversation, pathsForConversationDir, saveState, statePathForConversationId } from "./store.js";
@@ -1331,16 +1331,23 @@ async function runList(options) {
1331
1331
  const includeAll = Boolean(options.all);
1332
1332
  const agentFilter = options.agent ? resolveExecutor({ kind: options.agent }).kind : undefined;
1333
1333
  const statusFilter = options.status;
1334
- const storedConversations = listConversations(storeDir)
1334
+ const allStoredConversations = listConversations(storeDir);
1335
+ const storedConversations = allStoredConversations
1336
+ .filter(isDiscoverableTmuxConversation)
1335
1337
  .filter((conversation) => includeAll || isActiveStatus(conversation.status))
1336
1338
  .filter((conversation) => matchesConfiguredWorkspace(options.workspace, conversation.workspace))
1337
1339
  .filter((conversation) => !agentFilter || executorForConversation(conversation).kind === agentFilter)
1338
1340
  .filter((conversation) => !statusFilter || conversation.status === statusFilter);
1339
1341
  const conversations = storedConversations.map((conversation) => summarizeConversation(conversation));
1340
- const delegated = storedConversations.map((conversation) => delegatedListEntry(summarizeConversation(conversation), { terminalBridge: terminalBridgeEnabled(conversation) }));
1342
+ const delegated = storedConversations.map((conversation) => delegatedListEntry(summarizeConversation(conversation), {
1343
+ terminalBridge: terminalBridgeEnabled(conversation),
1344
+ approvalState: managedListApprovalState(conversation),
1345
+ conversation
1346
+ }));
1341
1347
  const terminalScan = await buildTerminalListGroup({ options, agentFilter, statusFilter });
1342
- const managedTerminalKeys = new Set(storedConversations
1348
+ const managedTerminalKeys = new Set(allStoredConversations
1343
1349
  .filter((conversation) => isActiveStatus(conversation.status))
1350
+ .filter((conversation) => matchesConfiguredWorkspace(options.workspace, conversation.workspace))
1344
1351
  .map((conversation) => terminalControlSelectorKey(terminalControlFromTakeover(isRecord(conversation.native_session_takeover)
1345
1352
  ? conversation.native_session_takeover
1346
1353
  : undefined)))
@@ -1355,6 +1362,7 @@ async function runList(options) {
1355
1362
  printJson({
1356
1363
  store_dir: storeDir,
1357
1364
  cleanup,
1365
+ action_contracts: listActionContracts(),
1358
1366
  delegated,
1359
1367
  terminal_controlled: terminalControlled,
1360
1368
  terminal_scan: {
@@ -1454,12 +1462,13 @@ async function terminalControlDiagnostics(provider) {
1454
1462
  paneCount: (await provider.listPanes()).length
1455
1463
  };
1456
1464
  }
1457
- function delegatedListEntry(task, { terminalBridge = false } = {}) {
1458
- return {
1465
+ function delegatedListEntry(task, { terminalBridge = false, approvalState, conversation } = {}) {
1466
+ const entry = {
1459
1467
  ...task,
1460
1468
  id: task.conversation_id,
1461
1469
  short_ref: sessionShortRef(task.conversation_id),
1462
1470
  source: "akk_delegate",
1471
+ ...(approvalState ? { approval_state: approvalState } : {}),
1463
1472
  commands: {
1464
1473
  send: canSendDelegated(task.status),
1465
1474
  cancel: isWaitingForAgent(task.status),
@@ -1468,6 +1477,10 @@ function delegatedListEntry(task, { terminalBridge = false } = {}) {
1468
1477
  approve: terminalBridge && isActiveStatus(task.status)
1469
1478
  }
1470
1479
  };
1480
+ return {
1481
+ ...entry,
1482
+ available_actions: availableListActions(entry, { conversation })
1483
+ };
1471
1484
  }
1472
1485
  async function terminalControlledListEntry(session, activeSessions, options, bridge = createTerminalAgentBridge(options)) {
1473
1486
  const terminalControl = session.terminalControl;
@@ -1481,7 +1494,7 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1481
1494
  terminalTarget: terminalControl.target
1482
1495
  });
1483
1496
  const orphanedDispatch = orphanedTerminalDispatchForRecovery(terminalControl);
1484
- return {
1497
+ const entry = {
1485
1498
  id: bridge.terminalConversationId(session),
1486
1499
  short_ref: sessionShortRef(bridge.terminalConversationId(session)),
1487
1500
  source: "terminal_control",
@@ -1520,6 +1533,10 @@ async function terminalControlledListEntry(session, activeSessions, options, bri
1520
1533
  close: orphanedDispatch !== undefined
1521
1534
  }
1522
1535
  };
1536
+ return {
1537
+ ...entry,
1538
+ available_actions: availableListActions(entry)
1539
+ };
1523
1540
  }
1524
1541
  async function listStateForTerminal(agent, terminalControl, options, bridge = createTerminalAgentBridge(options), runtime) {
1525
1542
  if (options.noApprovalScan) {
@@ -1586,7 +1603,218 @@ function childPidsForRoot(root, processes) {
1586
1603
  .map((process) => process.pid);
1587
1604
  }
1588
1605
  function canSendDelegated(status) {
1589
- return !["failed", "closed", "cancelled"].includes(status);
1606
+ return !["done", "failed", "closed", "cancelled"].includes(status);
1607
+ }
1608
+ function managedListApprovalState(conversation) {
1609
+ if (!terminalBridgeEnabled(conversation) ||
1610
+ !["waiting_for_agent", "waiting_for_openclaw"].includes(String(conversation.status))) {
1611
+ return undefined;
1612
+ }
1613
+ const nativeTakeover = isRecord(conversation.native_session_takeover)
1614
+ ? conversation.native_session_takeover
1615
+ : undefined;
1616
+ if (!terminalControlFromTakeover(nativeTakeover) ||
1617
+ !stringValue(nativeTakeover?.terminal_bridge_message_id)) {
1618
+ return undefined;
1619
+ }
1620
+ const approval = isRecord(nativeTakeover?.terminal_bridge_approval)
1621
+ ? nativeTakeover.terminal_bridge_approval
1622
+ : undefined;
1623
+ const approvalState = isRecord(approval?.approval_state)
1624
+ ? approval.approval_state
1625
+ : undefined;
1626
+ const fingerprint = stringValue(approval?.fingerprint);
1627
+ const notifiedAt = stringValue(approval?.notified_at);
1628
+ const notifiedAtMs = validTimestampMs(notifiedAt);
1629
+ if (!approvalState ||
1630
+ !fingerprint ||
1631
+ notifiedAtMs === undefined ||
1632
+ Date.now() - notifiedAtMs > CLAUDE_SCREEN_APPROVAL_TTL_MS) {
1633
+ return undefined;
1634
+ }
1635
+ return {
1636
+ ...approvalState,
1637
+ fingerprint,
1638
+ notified_at: notifiedAt
1639
+ };
1640
+ }
1641
+ function listActionContracts() {
1642
+ return {
1643
+ version: 1,
1644
+ instructions: [
1645
+ "Use only actions present in delegated[].available_actions or terminal_controlled[].available_actions.",
1646
+ "Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
1647
+ "Authoritative full IDs are prefilled; short_ref is for display and human input.",
1648
+ "Availability is a snapshot. AKK revalidates process, tmux pane, workspace, activity, approval, and recovery state before side effects."
1649
+ ],
1650
+ actions: {
1651
+ send: {
1652
+ tool: "agent_knock_knock_send",
1653
+ target_argument: "selector",
1654
+ required: ["request"],
1655
+ optional: [
1656
+ "selector",
1657
+ "type",
1658
+ "idleTimeoutMinutes",
1659
+ "agentTimeoutMinutes",
1660
+ "agentHardTimeoutMinutes"
1661
+ ],
1662
+ unsupported: ["timeoutSeconds"],
1663
+ ordinary_use: "Add request only. Omit timeout fields unless the user explicitly asks to change monitoring limits."
1664
+ },
1665
+ status: {
1666
+ tool: "agent_knock_knock_status",
1667
+ target_argument: "conversation_id",
1668
+ required: ["conversation_id"],
1669
+ optional: ["idleTimeoutMinutes", "trace"]
1670
+ },
1671
+ approve: {
1672
+ tool: "agent_knock_knock_approve",
1673
+ target_argument: "conversation_id",
1674
+ required: ["conversation_id", "expected_approval_fingerprint"],
1675
+ requires_explicit_user_confirmation: true,
1676
+ requires_fresh_status: true
1677
+ },
1678
+ cancel: {
1679
+ tool: "agent_knock_knock_cancel",
1680
+ target_argument: "conversation_id",
1681
+ required: ["conversation_id"],
1682
+ optional: ["idleTimeoutMinutes"],
1683
+ requires_user_intent: true
1684
+ },
1685
+ renew: {
1686
+ tool: "agent_knock_knock_renew",
1687
+ target_argument: "conversation_id",
1688
+ required: ["conversation_id"],
1689
+ optional: ["minutes"]
1690
+ },
1691
+ retry_callback: {
1692
+ tool: "agent_knock_knock_retry_callback",
1693
+ target_argument: "conversation_id",
1694
+ required: ["conversation_id"]
1695
+ },
1696
+ close: {
1697
+ tool: "agent_knock_knock_close",
1698
+ target_argument: "conversation_id",
1699
+ required: ["conversation_id"],
1700
+ optional: ["reason", "expected_message_id"],
1701
+ requires_explicit_user_confirmation: true
1702
+ }
1703
+ }
1704
+ };
1705
+ }
1706
+ function availableListActions(entry, { conversation } = {}) {
1707
+ const id = stringValue(entry.id ?? entry.conversation_id);
1708
+ if (!id) {
1709
+ return {};
1710
+ }
1711
+ const commands = isRecord(entry.commands) ? entry.commands : {};
1712
+ const actions = {
1713
+ status: {
1714
+ tool: "agent_knock_knock_status",
1715
+ arguments: { conversation_id: id }
1716
+ }
1717
+ };
1718
+ const terminalControlled = entry.source === "terminal_control";
1719
+ const managed = entry.source === "akk_delegate";
1720
+ const approvalState = isRecord(entry.approval_state)
1721
+ ? entry.approval_state
1722
+ : {};
1723
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1724
+ ? conversation.native_session_takeover
1725
+ : undefined;
1726
+ const managedApprovalPending = isRecord(nativeTakeover?.terminal_bridge_approval);
1727
+ const terminalBridgeReady = managed &&
1728
+ terminalBridgeEnabled(conversation) &&
1729
+ terminalControlFromTakeover(nativeTakeover) !== undefined;
1730
+ if (commands.send === true &&
1731
+ ((terminalBridgeReady &&
1732
+ ["waiting_for_openclaw", "idle"].includes(String(entry.status)) &&
1733
+ !managedApprovalPending &&
1734
+ approvalState.blocked !== true) ||
1735
+ (terminalControlled &&
1736
+ entry.activity_state === "idle" &&
1737
+ approvalState.blocked !== true))) {
1738
+ actions.send = {
1739
+ tool: "agent_knock_knock_send",
1740
+ arguments: { selector: id },
1741
+ missing_required: ["request"]
1742
+ };
1743
+ }
1744
+ const approvalFingerprint = stringValue(approvalState.fingerprint);
1745
+ const managedApprovalEligible = terminalBridgeReady &&
1746
+ entry.status === "waiting_for_openclaw" &&
1747
+ (entry.agent !== "claude" ||
1748
+ approvalState.decision_mode === "keys");
1749
+ if (commands.approve === true &&
1750
+ approvalState.approvable === true &&
1751
+ approvalFingerprint &&
1752
+ ((terminalControlled &&
1753
+ entry.agent === "codex") ||
1754
+ managedApprovalEligible)) {
1755
+ actions.approve = {
1756
+ tool: "agent_knock_knock_approve",
1757
+ arguments: { conversation_id: id },
1758
+ missing_required: ["expected_approval_fingerprint"],
1759
+ before_call: {
1760
+ tool: "agent_knock_knock_status",
1761
+ arguments: { conversation_id: id },
1762
+ use: "After explicit user confirmation, copy the latest terminal_status.approval_state.fingerprint into expected_approval_fingerprint."
1763
+ },
1764
+ requires_explicit_user_confirmation: true,
1765
+ requires_fresh_status: true
1766
+ };
1767
+ }
1768
+ const rawCancellable = terminalControlled &&
1769
+ commands.cancel === true &&
1770
+ (entry.activity_state === "working" ||
1771
+ (approvalState.blocked === true &&
1772
+ approvalState.approvable === true));
1773
+ const managedCancellable = terminalBridgeReady &&
1774
+ ["waiting_for_agent", "waiting_for_openclaw"].includes(String(entry.status)) &&
1775
+ !(managedApprovalPending &&
1776
+ approvalState.approvable !== true);
1777
+ if (rawCancellable || managedCancellable) {
1778
+ actions.cancel = {
1779
+ tool: "agent_knock_knock_cancel",
1780
+ arguments: { conversation_id: id },
1781
+ requires_user_intent: true
1782
+ };
1783
+ }
1784
+ if (terminalBridgeReady && entry.status === "stalled") {
1785
+ actions.renew = {
1786
+ tool: "agent_knock_knock_renew",
1787
+ arguments: { conversation_id: id }
1788
+ };
1789
+ }
1790
+ const callbackDelivery = isRecord(conversation?.callback_delivery)
1791
+ ? conversation.callback_delivery
1792
+ : undefined;
1793
+ if (managed &&
1794
+ isRecord(conversation) &&
1795
+ isRetryableCallbackDelivery(conversation, callbackDelivery)) {
1796
+ actions.retry_callback = {
1797
+ tool: "agent_knock_knock_retry_callback",
1798
+ arguments: { conversation_id: id }
1799
+ };
1800
+ }
1801
+ if (commands.close === true) {
1802
+ const orphanedDispatch = isRecord(entry.orphaned_terminal_dispatch)
1803
+ ? entry.orphaned_terminal_dispatch
1804
+ : undefined;
1805
+ const expectedMessageId = stringValue(orphanedDispatch?.message_id);
1806
+ actions.close = {
1807
+ tool: "agent_knock_knock_close",
1808
+ arguments: {
1809
+ conversation_id: id,
1810
+ ...(expectedMessageId
1811
+ ? { expected_message_id: expectedMessageId }
1812
+ : {})
1813
+ },
1814
+ requires_explicit_user_confirmation: true
1815
+ };
1816
+ }
1817
+ return actions;
1590
1818
  }
1591
1819
  async function resolveConversationSelectorOption(commandName, options) {
1592
1820
  if (!SESSION_SELECTOR_COMMANDS.has(String(commandName ?? "")) ||
@@ -1617,7 +1845,13 @@ async function sessionSelectorCandidates(commandName, options) {
1617
1845
  const storedConversations = listConversations(storeDir);
1618
1846
  const workspaceConversations = storedConversations
1619
1847
  .filter((conversation) => matchesConfiguredWorkspace(options.workspace, conversation.workspace));
1620
- const managed = workspaceConversations.map((conversation) => delegatedListEntry(summarizeConversation(conversation), { terminalBridge: terminalBridgeEnabled(conversation) }));
1848
+ const discoverableWorkspaceConversations = workspaceConversations
1849
+ .filter(isDiscoverableTmuxConversation);
1850
+ const managed = discoverableWorkspaceConversations.map((conversation) => delegatedListEntry(summarizeConversation(conversation), {
1851
+ terminalBridge: terminalBridgeEnabled(conversation),
1852
+ approvalState: managedListApprovalState(conversation),
1853
+ conversation
1854
+ }));
1621
1855
  const managedTerminalKeys = new Set(workspaceConversations
1622
1856
  .filter((conversation) => isActiveStatus(conversation.status))
1623
1857
  .map((conversation) => terminalControlSelectorKey(terminalControlFromTakeover(isRecord(conversation.native_session_takeover)
@@ -7370,6 +7604,34 @@ function summarizeConversation(conversation) {
7370
7604
  event_log_path: conversation.event_log_path
7371
7605
  };
7372
7606
  }
7607
+ function isDiscoverableTmuxConversation(conversation) {
7608
+ if (!isRecord(conversation)) {
7609
+ return false;
7610
+ }
7611
+ if (!isRecord(conversation.executor)) {
7612
+ return false;
7613
+ }
7614
+ const kind = stringValue(conversation.executor.kind)?.toLowerCase();
7615
+ return (kind !== undefined &&
7616
+ isExecutorKind(kind) &&
7617
+ conversation.executor.transport === "tmux");
7618
+ }
7619
+ function persistedExecutorLogFields(conversation) {
7620
+ if (isDiscoverableTmuxConversation(conversation)) {
7621
+ const executor = executorForConversation(conversation);
7622
+ return {
7623
+ agent: executor.kind,
7624
+ executor_session: executor.session
7625
+ };
7626
+ }
7627
+ const rawExecutor = isRecord(conversation?.executor)
7628
+ ? conversation.executor
7629
+ : {};
7630
+ return {
7631
+ agent: stringValue(rawExecutor.kind) ?? "unsupported",
7632
+ executor_session: stringValue(rawExecutor.session)
7633
+ };
7634
+ }
7373
7635
  function summarizeEvent(event) {
7374
7636
  return {
7375
7637
  ts: event.ts,
@@ -7986,10 +8248,10 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
7986
8248
  idle_timeout_minutes: timeoutMinutes,
7987
8249
  terminal_bridge: terminalBridge
7988
8250
  });
8251
+ const executorLogFields = persistedExecutorLogFields(conversation);
7989
8252
  runtimeLog("info", "idle_conversation_closed", {
7990
8253
  conversation_id: conversation.conversation_id,
7991
- agent: executorForConversation(conversation).kind,
7992
- executor_session: executorForConversation(conversation).session,
8254
+ ...executorLogFields,
7993
8255
  state_path: statePath,
7994
8256
  event_log_path: logPath,
7995
8257
  idle_since: conversation.idle_since,