@scotthuang/agent-knock-knock 0.6.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.2 - 2026-07-31
4
+
5
+ ### Changed
6
+
7
+ - Publish `AKK list` action contract v2 with machine-readable field semantics that distinguish managed task lifecycle, terminal process liveness, parsed screen activity, deprecated compatibility flags, and the authoritative `available_actions` snapshot.
8
+
9
+ ### Fixed
10
+
11
+ - Recognize both current `»` and legacy `›` Codex composer markers across idle detection, approval parsing, stale-prompt rejection, completion boundaries, and terminal artifact cleanup while preserving fail-closed working and approval precedence.
12
+
13
+ ## 0.6.1 - 2026-07-30
14
+
15
+ ### Added
16
+
17
+ - 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.
18
+
19
+ ### Fixed
20
+
21
+ - 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.
22
+ - Keep idle cleanup logging safe when it closes an unsupported legacy executor record.
23
+
3
24
  ## 0.6.0 - 2026-07-30
4
25
 
5
26
  ### 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. A delegated row's `status` is its task lifecycle, while a terminal-controlled row's `status` only says whether the coding-agent process is alive and its `activity_state` reports the parsed screen state. The legacy `commands` flags have mixed compatibility semantics, are deprecated, and must never drive tool calls; `available_actions` is the only authoritative current-action source. `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,239 @@ 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: 2,
1644
+ instructions: [
1645
+ "Use only actions present in delegated[].available_actions or terminal_controlled[].available_actions.",
1646
+ "Never use commands for routing or tool calls. It is a deprecated, non-authoritative compatibility field with mixed legacy semantics.",
1647
+ "Start with the action's prefilled arguments, supply every missing_required field, and consult the top-level action's optional fields only when needed.",
1648
+ "Authoritative full IDs are prefilled; short_ref is for display and human input.",
1649
+ "Availability is a snapshot. AKK revalidates process, tmux pane, workspace, activity, approval, and recovery state before side effects."
1650
+ ],
1651
+ field_semantics: {
1652
+ status: {
1653
+ delegated: "task_lifecycle",
1654
+ terminal_controlled: "process_liveness",
1655
+ authoritative_for_tool_calls: false
1656
+ },
1657
+ activity_state: {
1658
+ terminal_controlled: "screen_activity_classification",
1659
+ authoritative_for_tool_calls: false
1660
+ },
1661
+ commands: {
1662
+ meaning: "legacy_compatibility_flags_with_mixed_semantics",
1663
+ deprecated: true,
1664
+ authoritative_for_tool_calls: false
1665
+ },
1666
+ available_actions: {
1667
+ meaning: "currently_safe_actions",
1668
+ authoritative_for_tool_calls: true
1669
+ }
1670
+ },
1671
+ actions: {
1672
+ send: {
1673
+ tool: "agent_knock_knock_send",
1674
+ target_argument: "selector",
1675
+ required: ["request"],
1676
+ optional: [
1677
+ "selector",
1678
+ "type",
1679
+ "idleTimeoutMinutes",
1680
+ "agentTimeoutMinutes",
1681
+ "agentHardTimeoutMinutes"
1682
+ ],
1683
+ unsupported: ["timeoutSeconds"],
1684
+ ordinary_use: "Add request only. Omit timeout fields unless the user explicitly asks to change monitoring limits."
1685
+ },
1686
+ status: {
1687
+ tool: "agent_knock_knock_status",
1688
+ target_argument: "conversation_id",
1689
+ required: ["conversation_id"],
1690
+ optional: ["idleTimeoutMinutes", "trace"]
1691
+ },
1692
+ approve: {
1693
+ tool: "agent_knock_knock_approve",
1694
+ target_argument: "conversation_id",
1695
+ required: ["conversation_id", "expected_approval_fingerprint"],
1696
+ requires_explicit_user_confirmation: true,
1697
+ requires_fresh_status: true
1698
+ },
1699
+ cancel: {
1700
+ tool: "agent_knock_knock_cancel",
1701
+ target_argument: "conversation_id",
1702
+ required: ["conversation_id"],
1703
+ optional: ["idleTimeoutMinutes"],
1704
+ requires_user_intent: true
1705
+ },
1706
+ renew: {
1707
+ tool: "agent_knock_knock_renew",
1708
+ target_argument: "conversation_id",
1709
+ required: ["conversation_id"],
1710
+ optional: ["minutes"]
1711
+ },
1712
+ retry_callback: {
1713
+ tool: "agent_knock_knock_retry_callback",
1714
+ target_argument: "conversation_id",
1715
+ required: ["conversation_id"]
1716
+ },
1717
+ close: {
1718
+ tool: "agent_knock_knock_close",
1719
+ target_argument: "conversation_id",
1720
+ required: ["conversation_id"],
1721
+ optional: ["reason", "expected_message_id"],
1722
+ requires_explicit_user_confirmation: true
1723
+ }
1724
+ }
1725
+ };
1726
+ }
1727
+ function availableListActions(entry, { conversation } = {}) {
1728
+ const id = stringValue(entry.id ?? entry.conversation_id);
1729
+ if (!id) {
1730
+ return {};
1731
+ }
1732
+ const commands = isRecord(entry.commands) ? entry.commands : {};
1733
+ const actions = {
1734
+ status: {
1735
+ tool: "agent_knock_knock_status",
1736
+ arguments: { conversation_id: id }
1737
+ }
1738
+ };
1739
+ const terminalControlled = entry.source === "terminal_control";
1740
+ const managed = entry.source === "akk_delegate";
1741
+ const approvalState = isRecord(entry.approval_state)
1742
+ ? entry.approval_state
1743
+ : {};
1744
+ const nativeTakeover = isRecord(conversation?.native_session_takeover)
1745
+ ? conversation.native_session_takeover
1746
+ : undefined;
1747
+ const managedApprovalPending = isRecord(nativeTakeover?.terminal_bridge_approval);
1748
+ const terminalBridgeReady = managed &&
1749
+ terminalBridgeEnabled(conversation) &&
1750
+ terminalControlFromTakeover(nativeTakeover) !== undefined;
1751
+ if (commands.send === true &&
1752
+ ((terminalBridgeReady &&
1753
+ ["waiting_for_openclaw", "idle"].includes(String(entry.status)) &&
1754
+ !managedApprovalPending &&
1755
+ approvalState.blocked !== true) ||
1756
+ (terminalControlled &&
1757
+ entry.activity_state === "idle" &&
1758
+ approvalState.blocked !== true))) {
1759
+ actions.send = {
1760
+ tool: "agent_knock_knock_send",
1761
+ arguments: { selector: id },
1762
+ missing_required: ["request"]
1763
+ };
1764
+ }
1765
+ const approvalFingerprint = stringValue(approvalState.fingerprint);
1766
+ const managedApprovalEligible = terminalBridgeReady &&
1767
+ entry.status === "waiting_for_openclaw" &&
1768
+ (entry.agent !== "claude" ||
1769
+ approvalState.decision_mode === "keys");
1770
+ if (commands.approve === true &&
1771
+ approvalState.approvable === true &&
1772
+ approvalFingerprint &&
1773
+ ((terminalControlled &&
1774
+ entry.agent === "codex") ||
1775
+ managedApprovalEligible)) {
1776
+ actions.approve = {
1777
+ tool: "agent_knock_knock_approve",
1778
+ arguments: { conversation_id: id },
1779
+ missing_required: ["expected_approval_fingerprint"],
1780
+ before_call: {
1781
+ tool: "agent_knock_knock_status",
1782
+ arguments: { conversation_id: id },
1783
+ use: "After explicit user confirmation, copy the latest terminal_status.approval_state.fingerprint into expected_approval_fingerprint."
1784
+ },
1785
+ requires_explicit_user_confirmation: true,
1786
+ requires_fresh_status: true
1787
+ };
1788
+ }
1789
+ const rawCancellable = terminalControlled &&
1790
+ commands.cancel === true &&
1791
+ (entry.activity_state === "working" ||
1792
+ (approvalState.blocked === true &&
1793
+ approvalState.approvable === true));
1794
+ const managedCancellable = terminalBridgeReady &&
1795
+ ["waiting_for_agent", "waiting_for_openclaw"].includes(String(entry.status)) &&
1796
+ !(managedApprovalPending &&
1797
+ approvalState.approvable !== true);
1798
+ if (rawCancellable || managedCancellable) {
1799
+ actions.cancel = {
1800
+ tool: "agent_knock_knock_cancel",
1801
+ arguments: { conversation_id: id },
1802
+ requires_user_intent: true
1803
+ };
1804
+ }
1805
+ if (terminalBridgeReady && entry.status === "stalled") {
1806
+ actions.renew = {
1807
+ tool: "agent_knock_knock_renew",
1808
+ arguments: { conversation_id: id }
1809
+ };
1810
+ }
1811
+ const callbackDelivery = isRecord(conversation?.callback_delivery)
1812
+ ? conversation.callback_delivery
1813
+ : undefined;
1814
+ if (managed &&
1815
+ isRecord(conversation) &&
1816
+ isRetryableCallbackDelivery(conversation, callbackDelivery)) {
1817
+ actions.retry_callback = {
1818
+ tool: "agent_knock_knock_retry_callback",
1819
+ arguments: { conversation_id: id }
1820
+ };
1821
+ }
1822
+ if (commands.close === true) {
1823
+ const orphanedDispatch = isRecord(entry.orphaned_terminal_dispatch)
1824
+ ? entry.orphaned_terminal_dispatch
1825
+ : undefined;
1826
+ const expectedMessageId = stringValue(orphanedDispatch?.message_id);
1827
+ actions.close = {
1828
+ tool: "agent_knock_knock_close",
1829
+ arguments: {
1830
+ conversation_id: id,
1831
+ ...(expectedMessageId
1832
+ ? { expected_message_id: expectedMessageId }
1833
+ : {})
1834
+ },
1835
+ requires_explicit_user_confirmation: true
1836
+ };
1837
+ }
1838
+ return actions;
1590
1839
  }
1591
1840
  async function resolveConversationSelectorOption(commandName, options) {
1592
1841
  if (!SESSION_SELECTOR_COMMANDS.has(String(commandName ?? "")) ||
@@ -1617,7 +1866,13 @@ async function sessionSelectorCandidates(commandName, options) {
1617
1866
  const storedConversations = listConversations(storeDir);
1618
1867
  const workspaceConversations = storedConversations
1619
1868
  .filter((conversation) => matchesConfiguredWorkspace(options.workspace, conversation.workspace));
1620
- const managed = workspaceConversations.map((conversation) => delegatedListEntry(summarizeConversation(conversation), { terminalBridge: terminalBridgeEnabled(conversation) }));
1869
+ const discoverableWorkspaceConversations = workspaceConversations
1870
+ .filter(isDiscoverableTmuxConversation);
1871
+ const managed = discoverableWorkspaceConversations.map((conversation) => delegatedListEntry(summarizeConversation(conversation), {
1872
+ terminalBridge: terminalBridgeEnabled(conversation),
1873
+ approvalState: managedListApprovalState(conversation),
1874
+ conversation
1875
+ }));
1621
1876
  const managedTerminalKeys = new Set(workspaceConversations
1622
1877
  .filter((conversation) => isActiveStatus(conversation.status))
1623
1878
  .map((conversation) => terminalControlSelectorKey(terminalControlFromTakeover(isRecord(conversation.native_session_takeover)
@@ -7370,6 +7625,34 @@ function summarizeConversation(conversation) {
7370
7625
  event_log_path: conversation.event_log_path
7371
7626
  };
7372
7627
  }
7628
+ function isDiscoverableTmuxConversation(conversation) {
7629
+ if (!isRecord(conversation)) {
7630
+ return false;
7631
+ }
7632
+ if (!isRecord(conversation.executor)) {
7633
+ return false;
7634
+ }
7635
+ const kind = stringValue(conversation.executor.kind)?.toLowerCase();
7636
+ return (kind !== undefined &&
7637
+ isExecutorKind(kind) &&
7638
+ conversation.executor.transport === "tmux");
7639
+ }
7640
+ function persistedExecutorLogFields(conversation) {
7641
+ if (isDiscoverableTmuxConversation(conversation)) {
7642
+ const executor = executorForConversation(conversation);
7643
+ return {
7644
+ agent: executor.kind,
7645
+ executor_session: executor.session
7646
+ };
7647
+ }
7648
+ const rawExecutor = isRecord(conversation?.executor)
7649
+ ? conversation.executor
7650
+ : {};
7651
+ return {
7652
+ agent: stringValue(rawExecutor.kind) ?? "unsupported",
7653
+ executor_session: stringValue(rawExecutor.session)
7654
+ };
7655
+ }
7373
7656
  function summarizeEvent(event) {
7374
7657
  return {
7375
7658
  ts: event.ts,
@@ -7986,10 +8269,10 @@ function cleanupIdleConversations(storeDir, options = {}, now = new Date()) {
7986
8269
  idle_timeout_minutes: timeoutMinutes,
7987
8270
  terminal_bridge: terminalBridge
7988
8271
  });
8272
+ const executorLogFields = persistedExecutorLogFields(conversation);
7989
8273
  runtimeLog("info", "idle_conversation_closed", {
7990
8274
  conversation_id: conversation.conversation_id,
7991
- agent: executorForConversation(conversation).kind,
7992
- executor_session: executorForConversation(conversation).session,
8275
+ ...executorLogFields,
7993
8276
  state_path: statePath,
7994
8277
  event_log_path: logPath,
7995
8278
  idle_since: conversation.idle_since,