@scotthuang/agent-knock-knock 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ import { spawn, spawnSync } from "node:child_process";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
4
4
  import { EXECUTOR_KINDS, executorDefinitionForKind } from "./executors.js";
5
- import { AKK_CALLBACK_METHOD, akkUsageText, buildAkkCommandCliArgs, formatAkkListCommandResult, parseAkkCommand, resolvePluginStoreDir } from "./openclaw-plugin-helpers.js";
5
+ import { AKK_CALLBACK_METHOD, akkUsageText, buildAkkCommandCliArgs, formatAkkListCommandResult, formatAkkRespondCommandResult, parseAkkCommand, resolvePluginStoreDir } from "./openclaw-plugin-helpers.js";
6
6
  import { attemptAutoApproval } from "./approval-policy.js";
7
7
  const CALLBACK_METHOD = AKK_CALLBACK_METHOD;
8
8
  const defaultBinPath = fileURLToPath(new URL("./cli.js", import.meta.url));
@@ -11,22 +11,31 @@ const sendParameters = {
11
11
  type: "object",
12
12
  additionalProperties: false,
13
13
  required: ["request"],
14
+ not: { required: ["session_id", "selector"] },
14
15
  properties: {
16
+ session_id: {
17
+ type: "string",
18
+ minLength: 1,
19
+ description: "Authoritative AKK session id returned by list or a previous send. Ordinary sends target a session and create a new turn; a turn id is never a send destination."
20
+ },
15
21
  selector: {
16
22
  type: "string",
17
- description: "Optional existing tmux target selector from AKK list: codex, claude, only, latest, an @short-ref, or an authoritative full id. Omit it to start a new managed turn through the unique eligible idle pane."
23
+ minLength: 1,
24
+ description: "Compatibility/discovery selector: codex, claude, only, latest, an @short-ref, or a live terminal id. Prefer session_id once a session exists. Omit both fields only when AKK should attach the unique eligible idle pane."
18
25
  },
19
26
  request: {
20
27
  type: "string",
21
- description: "New task or follow-up message for the coding agent. For an ordinary send, omit monitoring timeout fields. timeoutSeconds is not a supported argument."
28
+ minLength: 1,
29
+ description: "Message for the coding agent. Each accepted ordinary send creates a new turn inside the selected session without clearing native agent context."
22
30
  },
23
31
  type: {
24
32
  type: "string",
25
- enum: ["answer", "task", "control", "error"]
33
+ enum: ["task"],
34
+ description: "Ordinary sends always create a task turn. Use agent_knock_knock_respond for an answer to an in-flight turn."
26
35
  },
27
36
  idleTimeoutMinutes: {
28
37
  type: "number",
29
- description: "Minutes an idle AKK session remains open before controlled reconciliation closes its managed-turn record."
38
+ description: "Minutes an idle or completed AKK Turn record is retained before controlled reconciliation closes it."
30
39
  },
31
40
  agentTimeoutMinutes: {
32
41
  type: "number",
@@ -39,6 +48,21 @@ const sendParameters = {
39
48
  }
40
49
  }
41
50
  };
51
+ const respondParameters = {
52
+ type: "object",
53
+ additionalProperties: false,
54
+ required: ["turn_id", "request"],
55
+ properties: {
56
+ turn_id: {
57
+ type: "string",
58
+ description: "Authoritative AKK turn id from a question or blocked callback. A response continues this exact in-flight turn and does not create a new turn."
59
+ },
60
+ request: {
61
+ type: "string",
62
+ description: "Answer or decision for the coding agent's exact in-flight turn."
63
+ }
64
+ }
65
+ };
42
66
  const listParameters = {
43
67
  type: "object",
44
68
  additionalProperties: false,
@@ -69,11 +93,20 @@ const listParameters = {
69
93
  const renewParameters = {
70
94
  type: "object",
71
95
  additionalProperties: false,
72
- required: ["conversation_id"],
96
+ not: { required: ["turn_id", "conversation_id"] },
97
+ anyOf: [
98
+ { required: ["turn_id"] },
99
+ { required: ["conversation_id"] }
100
+ ],
73
101
  properties: {
102
+ turn_id: {
103
+ type: "string",
104
+ description: "Authoritative AKK turn id whose monitoring should be renewed."
105
+ },
74
106
  conversation_id: {
75
107
  type: "string",
76
- description: "Stalled AKK-managed terminal bridge conversation id."
108
+ deprecated: true,
109
+ description: "Deprecated compatibility alias for turn_id."
77
110
  },
78
111
  minutes: {
79
112
  type: "number",
@@ -85,22 +118,40 @@ const renewParameters = {
85
118
  const retryCallbackParameters = {
86
119
  type: "object",
87
120
  additionalProperties: false,
88
- required: ["conversation_id"],
121
+ not: { required: ["turn_id", "conversation_id"] },
122
+ anyOf: [
123
+ { required: ["turn_id"] },
124
+ { required: ["conversation_id"] }
125
+ ],
89
126
  properties: {
127
+ turn_id: {
128
+ type: "string",
129
+ description: "Authoritative AKK turn id whose persisted callback should be retried."
130
+ },
90
131
  conversation_id: {
91
132
  type: "string",
92
- description: "AKK-managed conversation whose persisted callback delivery is pending or failed."
133
+ deprecated: true,
134
+ description: "Deprecated compatibility alias for turn_id."
93
135
  }
94
136
  }
95
137
  };
96
138
  const statusParameters = {
97
139
  type: "object",
98
140
  additionalProperties: false,
99
- required: ["conversation_id"],
141
+ not: { required: ["turn_id", "conversation_id"] },
142
+ anyOf: [
143
+ { required: ["turn_id"] },
144
+ { required: ["conversation_id"] }
145
+ ],
100
146
  properties: {
147
+ turn_id: {
148
+ type: "string",
149
+ description: "Authoritative AKK turn id to inspect."
150
+ },
101
151
  conversation_id: {
102
152
  type: "string",
103
- description: "Managed-turn conversation id, or a live terminal id from AKK list such as terminal:v2:tmux:codex:codex-work:0.1:33389."
153
+ deprecated: true,
154
+ description: "Deprecated legacy Turn alias, or the exact raw-terminal selector prefilled by that terminal row's available status action. Managed Turn status must use turn_id; never construct or guess a raw-terminal selector."
104
155
  },
105
156
  idleTimeoutMinutes: {
106
157
  type: "number"
@@ -114,11 +165,20 @@ const statusParameters = {
114
165
  const cancelParameters = {
115
166
  type: "object",
116
167
  additionalProperties: false,
117
- required: ["conversation_id"],
168
+ not: { required: ["turn_id", "conversation_id"] },
169
+ anyOf: [
170
+ { required: ["turn_id"] },
171
+ { required: ["conversation_id"] }
172
+ ],
118
173
  properties: {
174
+ turn_id: {
175
+ type: "string",
176
+ description: "Authoritative AKK turn id to interrupt."
177
+ },
119
178
  conversation_id: {
120
179
  type: "string",
121
- description: "Managed-turn conversation id, or a live terminal id from AKK list such as terminal:v2:tmux:codex:codex-work:0.1:33389."
180
+ deprecated: true,
181
+ description: "Deprecated legacy Turn alias, or the exact raw-terminal selector prefilled by that terminal row's available cancel action. Managed Turn cancellation must use turn_id; never construct or guess a raw-terminal selector."
122
182
  },
123
183
  idleTimeoutMinutes: {
124
184
  type: "number"
@@ -128,10 +188,20 @@ const cancelParameters = {
128
188
  const closeParameters = {
129
189
  type: "object",
130
190
  additionalProperties: false,
131
- required: ["conversation_id"],
191
+ not: { required: ["turn_id", "conversation_id"] },
192
+ anyOf: [
193
+ { required: ["turn_id"] },
194
+ { required: ["conversation_id"] }
195
+ ],
132
196
  properties: {
197
+ turn_id: {
198
+ type: "string",
199
+ description: "Authoritative AKK turn id whose managed record should be closed."
200
+ },
133
201
  conversation_id: {
134
- type: "string"
202
+ type: "string",
203
+ deprecated: true,
204
+ description: "Deprecated legacy Turn alias, or an exact list-prefilled raw-terminal/orphan recovery selector. Managed Turn close must use turn_id; never construct or guess a raw-terminal selector."
135
205
  },
136
206
  reason: {
137
207
  type: "string"
@@ -145,11 +215,21 @@ const closeParameters = {
145
215
  const approveParameters = {
146
216
  type: "object",
147
217
  additionalProperties: false,
148
- required: ["conversation_id", "expected_approval_fingerprint"],
218
+ required: ["expected_approval_fingerprint"],
219
+ not: { required: ["turn_id", "conversation_id"] },
220
+ anyOf: [
221
+ { required: ["turn_id"] },
222
+ { required: ["conversation_id"] }
223
+ ],
149
224
  properties: {
225
+ turn_id: {
226
+ type: "string",
227
+ description: "Authoritative AKK turn id containing the approval prompt."
228
+ },
150
229
  conversation_id: {
151
230
  type: "string",
152
- description: "Managed-turn conversation id, or a live terminal id from AKK list such as terminal:v2:tmux:codex:codex-work:0.1:33389."
231
+ deprecated: true,
232
+ description: "Deprecated legacy Turn alias, or the exact raw-terminal selector prefilled by that terminal row's available approval action. Managed Turn approval must use turn_id; never construct or guess a raw-terminal selector."
153
233
  },
154
234
  expected_approval_fingerprint: {
155
235
  type: "string",
@@ -218,7 +298,7 @@ function createPlugin(relayPath) {
218
298
  });
219
299
  registerCliTool(api, {
220
300
  name: "agent_knock_knock_list",
221
- description: "List existing Codex and Claude Code tmux panes as the primary terminals[] resources. Each terminal may include managed.current_turn or managed.recent_turn; all=true also includes older managed.history and retained unavailable history. By default, unavailable_managed_turns contains attention-needed records whose pane is unavailable. Use only each row's available_actions and its authoritative prefilled arguments: send starts a new turn on a terminal, while follow_up continues a specific managed turn. AKK revalidates every side effect and never starts a coding agent.",
301
+ description: "List existing Codex and Claude Code tmux panes as the primary terminals[] resources. Each terminal may include managed.current_turn or managed.recent_turn; all=true also includes older managed.history and retained unavailable history. By default, unavailable_managed_turns contains attention-needed records whose pane is unavailable. Use only each row's available_actions and authoritative prefilled arguments: send targets a session and starts a new turn; respond targets the exact in-flight turn; managed controls target the exact turn; a raw terminal row may prefill its own compatibility selector for status or recovery controls. Never construct that selector. AKK revalidates every side effect and never starts a coding agent.",
222
302
  parameters: listParameters,
223
303
  buildArgs: (params) => {
224
304
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
@@ -242,7 +322,7 @@ function createPlugin(relayPath) {
242
322
  api.registerTool((_toolContext) => ({
243
323
  label: "AKK Status",
244
324
  name: "agent_knock_knock_status",
245
- description: "Inspect one AKK-managed turn or existing coding-agent tmux terminal. Returns live state, a bounded terminal screen, and available purpose/context with confidence and limitations. AKK never starts a coding agent.",
325
+ description: "Inspect one exact AKK-managed turn by its authoritative turn_id, or use only a raw terminal row's own prefilled compatibility selector. The deprecated conversation_id remains a legacy Turn alias and the list-prefilled raw-terminal input; never construct it. Returns live state, a bounded terminal screen, and available purpose/context with confidence and limitations. AKK never starts a coding agent.",
246
326
  parameters: statusParameters,
247
327
  async execute(_toolCallId, params) {
248
328
  const result = runCli(api, buildStatusCliArgs(api, isRecord(params) ? params : {}));
@@ -252,20 +332,38 @@ function createPlugin(relayPath) {
252
332
  api.registerTool((toolContext) => ({
253
333
  label: "AKK Send",
254
334
  name: "agent_knock_knock_send",
255
- description: "Send through an existing Codex or Claude Code tmux terminal. Use a terminal row's send action to start a new managed turn, or a managed turn's follow_up action to continue that exact turn; both call this tool with the prefilled selector. Omit selector only when AKK should require one unique eligible idle pane. For ordinary use add only request and omit monitoring timeouts unless the user explicitly asks to change them. timeoutSeconds is unsupported. AKK never starts a coding agent. This is asynchronous: after acceptance, yield and wait for the callback or a later explicit status request.",
335
+ description: "Start a new turn in an existing AKK session without clearing the coding agent's native context. Target session_id from list or a prior send; never pass a turn id. selector is compatibility-only for initial live-terminal discovery, and both fields may be omitted only when AKK should require one unique eligible idle pane. To answer an in-flight question, use agent_knock_knock_respond instead. For ordinary use add only request and omit monitoring timeouts unless the user explicitly asks to change them. timeoutSeconds is unsupported. AKK never starts a coding agent. This is asynchronous: after acceptance, yield and wait for the callback or a later explicit status request.",
256
336
  parameters: sendParameters,
257
337
  async execute(_toolCallId, params) {
258
338
  const result = await runSendRequest(api, isRecord(params) ? params : {}, toolContext);
259
- return toolResult(result);
339
+ return toolResult(result, { submissionErrors: true });
260
340
  }
261
341
  }), { name: "agent_knock_knock_send", optional: true });
342
+ registerCliTool(api, {
343
+ name: "agent_knock_knock_respond",
344
+ description: "Respond to a question or blocked callback in one exact in-flight AKK turn. This continues that turn and does not create a new turn; use agent_knock_knock_send with session_id for later ordinary work.",
345
+ parameters: respondParameters,
346
+ buildArgs: (params) => {
347
+ const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
348
+ const args = [
349
+ "respond",
350
+ "--turn",
351
+ requiredString(params.turn_id, "turn_id"),
352
+ "--message",
353
+ requiredString(params.request, "request")
354
+ ];
355
+ pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
356
+ return args;
357
+ }
358
+ });
262
359
  registerCliTool(api, {
263
360
  name: "agent_knock_knock_approve",
264
- description: "Manually approve the current AKK terminal permission request only after the user reviews and explicitly confirms it. Claude Code uses no Hooks: this manual path accepts only an exact one-time Bash permission screen for the current managed turn, then recaptures its short-lived evidence and process identity before sending Enter. Separately, trusted default-disabled plugin configuration can auto-approve an exact Claude command/workspace match without exposing policy control to the model. Hook-free durable completion is independently verified from the local Claude transcript.",
361
+ description: "Manually approve a permission request only after the user reviews and explicitly confirms it. Managed approval uses exact turn_id; an unmanaged raw terminal may be approved only through its own list-prefilled action. Claude Code uses no Hooks: this manual path accepts only an exact one-time Bash permission screen, then recaptures its short-lived evidence and process identity before sending Enter. Separately, trusted default-disabled plugin configuration can auto-approve an exact Claude command/workspace match without exposing policy control to the model. Hook-free durable completion is independently verified from the local Claude transcript.",
265
362
  parameters: approveParameters,
266
363
  buildArgs: (params) => {
267
364
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
268
- const args = ["approve", "--conversation", requiredString(params.conversation_id, "conversation_id")];
365
+ const args = ["approve"];
366
+ pushTurnTarget(args, params);
269
367
  pushOptional(args, "--expected-approval-fingerprint", requiredString(params.expected_approval_fingerprint, "expected_approval_fingerprint"));
270
368
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
271
369
  return args;
@@ -273,11 +371,12 @@ function createPlugin(relayPath) {
273
371
  });
274
372
  registerCliTool(api, {
275
373
  name: "agent_knock_knock_renew",
276
- description: "Renew monitoring for a stalled AKK-managed terminal bridge task without sending text or keys to the coding agent. Use this when the user wants a still-live long-running terminal task to keep monitoring after an inactivity stall.",
374
+ description: "Renew monitoring for one exact stalled turn_id without sending text or keys to the coding agent. Use this when the user wants a still-live long-running terminal task to keep monitoring after an inactivity stall.",
277
375
  parameters: renewParameters,
278
376
  buildArgs: (params) => {
279
377
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
280
- const args = ["renew", "--conversation", requiredString(params.conversation_id, "conversation_id")];
378
+ const args = ["renew"];
379
+ pushTurnTarget(args, params);
281
380
  pushOptional(args, "--minutes", numberString(params.minutes) ?? numberString(config.agentTimeoutMinutes));
282
381
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
283
382
  return args;
@@ -285,22 +384,24 @@ function createPlugin(relayPath) {
285
384
  });
286
385
  registerCliTool(api, {
287
386
  name: "agent_knock_knock_retry_callback",
288
- description: "Retry a persisted AKK callback that failed before reaching OpenClaw. The original callback message id is reused for idempotent delivery; a completed terminal task remains available for follow-up until its idle retention is reconciled.",
387
+ description: "Retry a persisted AKK callback for an exact turn that failed before reaching OpenClaw. The original callback message id and turn identity are reused for idempotent delivery.",
289
388
  parameters: retryCallbackParameters,
290
389
  buildArgs: (params) => {
291
390
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
292
- const args = ["retry-callback", "--conversation", requiredString(params.conversation_id, "conversation_id")];
391
+ const args = ["retry-callback"];
392
+ pushTurnTarget(args, params);
293
393
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
294
394
  return args;
295
395
  }
296
396
  });
297
397
  registerCliTool(api, {
298
398
  name: "agent_knock_knock_cancel",
299
- description: "Interrupt an existing Agent Knock Knock Codex or Claude Code tmux task. Claude sends Escape; Codex uses its declared interrupt key. The shared tmux pane remains open for human takeover.",
399
+ description: "Interrupt one exact AKK turn_id, or use only an unmanaged raw terminal row's own prefilled cancel action. Claude sends Escape; Codex uses its declared interrupt key. The shared tmux pane remains open for human takeover.",
300
400
  parameters: cancelParameters,
301
401
  buildArgs: (params) => {
302
402
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
303
- const args = ["cancel", "--conversation", requiredString(params.conversation_id, "conversation_id")];
403
+ const args = ["cancel"];
404
+ pushTurnTarget(args, params);
304
405
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
305
406
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ?? numberString(config.idleTimeoutMinutes));
306
407
  return args;
@@ -312,7 +413,8 @@ function createPlugin(relayPath) {
312
413
  parameters: closeParameters,
313
414
  buildArgs: (params) => {
314
415
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
315
- const args = ["close", "--conversation", requiredString(params.conversation_id, "conversation_id")];
416
+ const args = ["close"];
417
+ pushTurnTarget(args, params);
316
418
  pushOptional(args, "--reason", stringValue(params.reason));
317
419
  pushOptional(args, "--expected-message-id", stringValue(params.expected_message_id));
318
420
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
@@ -369,8 +471,10 @@ async function handleAkkCommand(api, ctx) {
369
471
  case "send":
370
472
  return {
371
473
  text: formatSendCommandResult(result),
372
- isError: ["uncertain", "aborted"].includes(String(result.submission_outcome ?? ""))
474
+ isError: ["uncertain", "aborted"].includes(String(result.submission_outcome ?? "")) || result.status === "delivered_unfenced"
373
475
  };
476
+ case "respond":
477
+ return formatAkkRespondCommandResult(result);
374
478
  case "approve":
375
479
  return { text: formatApproveCommandResult(result) };
376
480
  case "renew":
@@ -393,26 +497,35 @@ async function handleAkkCommand(api, ctx) {
393
497
  }
394
498
  function formatDelegateCommandResult(result) {
395
499
  const agent = executorDisplayName(result.agent);
500
+ const { sessionId, turnId } = publicTurnIdentity(result);
501
+ if (result.status === "submission_unfenced") {
502
+ return [
503
+ `AKK sent the terminal input to ${agent}, but could not bind later side effects to an exact native session.`,
504
+ `session: ${sessionId}`,
505
+ `turn: ${turnId}`,
506
+ "next: do not retry or continue automatically; inspect the named pane and close this Turn before sending more work."
507
+ ].join("\n");
508
+ }
396
509
  if (result.status === "submission_uncertain") {
397
510
  return [
398
511
  `AKK could not prove whether ${agent} received the terminal task.`,
399
- `conversation: ${result.conversation_id ?? "unknown"}`,
400
- `session: ${result.session ?? "unknown"}`,
512
+ `session: ${sessionId}`,
513
+ `turn: ${turnId}`,
401
514
  "next: do not retry automatically; inspect AKK status and the named tmux pane."
402
515
  ].join("\n");
403
516
  }
404
517
  if (result.status === "submission_aborted") {
405
518
  return [
406
519
  `AKK stopped before sending the terminal task to ${agent}.`,
407
- `conversation: ${result.conversation_id ?? "unknown"}`,
408
- `session: ${result.session ?? "unknown"}`,
520
+ `session: ${sessionId}`,
521
+ `turn: ${turnId}`,
409
522
  "next: no tmux input was sent, so this request may be retried."
410
523
  ].join("\n");
411
524
  }
412
525
  return [
413
526
  `AKK sent the task to ${agent} in the shared terminal.`,
414
- `conversation: ${result.conversation_id ?? "unknown"}`,
415
- `session: ${result.session ?? "unknown"}`,
527
+ `session: ${sessionId}`,
528
+ `turn: ${turnId}`,
416
529
  `status: ${result.conversation_status ?? result.status ?? "unknown"}`,
417
530
  "The result will return to this OpenClaw session through the callback."
418
531
  ].join("\n");
@@ -428,12 +541,13 @@ function executorDisplayName(kind) {
428
541
  function formatStatusCommandResult(result) {
429
542
  const summary = result.summary ?? result.conversation ?? result ?? {};
430
543
  const terminalStatus = isRecord(result.terminal_status) ? result.terminal_status : {};
431
- const terminalControl = isRecord(result.terminal_control) ? result.terminal_control : {};
544
+ const { sessionId, turnId } = publicTurnIdentity(result);
432
545
  const lines = [
433
- `AKK status: ${summary.conversation_id ?? result.conversation_id ?? "unknown"}`,
546
+ "AKK status:",
547
+ `session: ${sessionId}`,
548
+ `turn: ${turnId}`,
434
549
  `agent: ${summary.agent ?? summary.executor?.kind ?? terminalStatus.agent ?? "unknown"}`,
435
- `status: ${summary.status ?? terminalStatus.activity_state ?? "unknown"}`,
436
- `session: ${summary.session ?? summary.executor?.session ?? terminalControl.target ?? "unknown"}`
550
+ `status: ${summary.status ?? terminalStatus.activity_state ?? "unknown"}`
437
551
  ];
438
552
  if (summary.request) {
439
553
  lines.push(`request: ${truncateText(summary.request, 180)}`);
@@ -477,16 +591,22 @@ function formatDoctorCommandResult(result) {
477
591
  ].join("\n");
478
592
  }
479
593
  function formatRenewCommandResult(result) {
594
+ const { sessionId, turnId } = publicTurnIdentity(result);
480
595
  return [
481
- `AKK monitoring renewed: ${result.conversation?.conversation_id ?? "unknown"}`,
596
+ "AKK monitoring renewed.",
597
+ `session: ${sessionId}`,
598
+ `turn: ${turnId}`,
482
599
  `inactivity timeout: ${result.agent_timeout_minutes ?? "unknown"} minutes`,
483
600
  `hard lifetime: ${result.agent_hard_timeout_minutes ?? "unknown"} minutes`,
484
601
  "No message or key was sent to the coding agent."
485
602
  ].join("\n");
486
603
  }
487
604
  function formatRetryCallbackCommandResult(result) {
605
+ const { sessionId, turnId } = publicTurnIdentity(result);
488
606
  return [
489
- `AKK callback delivered: ${result.conversation?.conversation_id ?? "unknown"}`,
607
+ "AKK callback delivered.",
608
+ `session: ${sessionId}`,
609
+ `turn: ${turnId}`,
490
610
  `status: ${result.conversation?.status ?? "unknown"}`,
491
611
  `attempts: ${result.conversation?.callback_delivery?.attempts ?? "unknown"}`
492
612
  ].join("\n");
@@ -494,12 +614,24 @@ function formatRetryCallbackCommandResult(result) {
494
614
  function formatSendCommandResult(result) {
495
615
  const conversation = result.conversation ?? {};
496
616
  const conversationId = conversation.conversation_id ?? result.conversation_id ?? "unknown";
617
+ const sessionId = conversation.session_id ?? result.session_id ?? conversationId;
618
+ const turnId = conversation.turn_id ?? result.turn_id ?? conversationId;
497
619
  const status = conversation.status ?? result.status ?? "unknown";
498
620
  const nextAction = isRecord(result.openclaw_next_action) ? result.openclaw_next_action : undefined;
621
+ if (result.status === "delivered_unfenced") {
622
+ return [
623
+ "AKK sent the terminal input but could not bind an exact native session.",
624
+ `session: ${sessionId}`,
625
+ `turn: ${turnId}`,
626
+ `status: ${status}`,
627
+ "next: do not retry or continue automatically; inspect the shared tmux pane and close this Turn before sending more work."
628
+ ].join("\n");
629
+ }
499
630
  if (result.submission_outcome === "uncertain") {
500
631
  return [
501
632
  "AKK terminal submission outcome is uncertain.",
502
- `conversation: ${conversationId}`,
633
+ `session: ${sessionId}`,
634
+ `turn: ${turnId}`,
503
635
  `status: ${result.status ?? status}`,
504
636
  "next: do not retry automatically; inspect AKK status and the named tmux pane."
505
637
  ].join("\n");
@@ -507,14 +639,16 @@ function formatSendCommandResult(result) {
507
639
  if (result.submission_outcome === "aborted") {
508
640
  return [
509
641
  "AKK terminal submission was aborted before tmux input.",
510
- `conversation: ${conversationId}`,
642
+ `session: ${sessionId}`,
643
+ `turn: ${turnId}`,
511
644
  `status: ${result.status ?? status}`,
512
645
  "next: this request was not sent and may be retried."
513
646
  ].join("\n");
514
647
  }
515
648
  const lines = [
516
- "AKK follow-up sent.",
517
- `conversation: ${conversationId}`,
649
+ "AKK turn sent.",
650
+ `session: ${sessionId}`,
651
+ `turn: ${turnId}`,
518
652
  `status: ${status}`
519
653
  ];
520
654
  if (result.source) {
@@ -529,22 +663,24 @@ function formatSendCommandResult(result) {
529
663
  }
530
664
  function formatCancelCommandResult(result) {
531
665
  const conversation = result.conversation ?? {};
532
- const terminalControl = isRecord(result.terminal_control) ? result.terminal_control : {};
666
+ const { sessionId, turnId } = publicTurnIdentity(result);
533
667
  return [
534
668
  "AKK cancel requested.",
535
- `conversation: ${conversation.conversation_id ?? result.conversation_id ?? "unknown"}`,
669
+ `session: ${sessionId}`,
670
+ `turn: ${turnId}`,
536
671
  `agent: ${result.executor?.kind ?? conversation.executor?.kind ?? "unknown"}`,
537
- `session: ${result.executor?.session ?? conversation.executor?.session ?? terminalControl.target ?? "unknown"}`,
538
672
  `status: ${conversation.status ?? (result.cancel_requested === true ? "cancel requested" : "not cancelled")}`
539
673
  ].join("\n");
540
674
  }
541
675
  function formatApproveCommandResult(result) {
542
676
  const conversation = result.conversation ?? {};
677
+ const { sessionId, turnId } = publicTurnIdentity(result);
543
678
  return [
544
679
  result.approved === true
545
680
  ? "AKK approved the current terminal request."
546
681
  : "AKK did not approve the terminal request.",
547
- `conversation: ${conversation.conversation_id ?? result.conversation_id ?? "unknown"}`,
682
+ `session: ${sessionId}`,
683
+ `turn: ${turnId}`,
548
684
  `status: ${conversation.status ?? "unknown"}`,
549
685
  ...(result.reason ? [`reason: ${result.reason}`] : [])
550
686
  ].join("\n");
@@ -558,25 +694,45 @@ function formatCloseCommandResult(result) {
558
694
  return [
559
695
  "AKK cleared the unresolved terminal dispatch fence.",
560
696
  `terminal: ${terminalControl.target ?? "unknown"}`,
561
- `previous owner: ${result.owner_conversation_id ?? "unknown"}`,
697
+ `previous turn: ${result.owner_turn_id ?? result.owner_conversation_id ?? "unknown"}`,
562
698
  "The coding agent and tmux pane remain open."
563
699
  ].join("\n");
564
700
  }
565
701
  const conversation = result.conversation ?? {};
702
+ const { sessionId, turnId } = publicTurnIdentity(result);
566
703
  return [
567
- "AKK session closed.",
568
- `conversation: ${conversation.conversation_id ?? "unknown"}`,
704
+ "AKK Turn record closed.",
705
+ `session: ${sessionId}`,
706
+ `turn: ${turnId}`,
569
707
  `status: ${conversation.status ?? "unknown"}`
570
708
  ].join("\n");
571
709
  }
710
+ function publicTurnIdentity(result) {
711
+ const conversation = isRecord(result.conversation) ? result.conversation : {};
712
+ const summary = isRecord(result.summary) ? result.summary : {};
713
+ const compatibilityId = stringValue(conversation.conversation_id) ??
714
+ stringValue(summary.conversation_id) ??
715
+ stringValue(result.conversation_id);
716
+ return {
717
+ sessionId: stringValue(conversation.session_id) ??
718
+ stringValue(summary.session_id) ??
719
+ stringValue(result.session_id) ??
720
+ compatibilityId ??
721
+ "unknown",
722
+ turnId: stringValue(conversation.turn_id) ??
723
+ stringValue(summary.turn_id) ??
724
+ stringValue(result.turn_id) ??
725
+ compatibilityId ??
726
+ "unknown"
727
+ };
728
+ }
572
729
  function buildStatusCliArgs(api, params) {
573
730
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
574
731
  const args = [
575
732
  "status",
576
- "--reconcile",
577
- "--conversation",
578
- requiredString(params.conversation_id, "conversation_id")
733
+ "--reconcile"
579
734
  ];
735
+ pushTurnTarget(args, params);
580
736
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
581
737
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ??
582
738
  numberString(api.pluginConfig?.idleTimeoutMinutes));
@@ -602,21 +758,37 @@ function terminalScreenExcerpt(result) {
602
758
  : `…${text.slice(-1599)}`;
603
759
  }
604
760
  async function runSendRequest(api, params, toolContext) {
605
- const selector = stringValue(params.selector);
606
- if (!selector) {
761
+ const requestedType = Object.hasOwn(params, "type")
762
+ ? stringValue(params.type)
763
+ : "task";
764
+ if (requestedType !== "task") {
765
+ throw new Error("ordinary send type must be task; use agent_knock_knock_respond for an in-flight response");
766
+ }
767
+ if (Object.hasOwn(params, "session_id") && Object.hasOwn(params, "selector")) {
768
+ throw new Error("ordinary send accepts only one of session_id or selector");
769
+ }
770
+ const sessionId = Object.hasOwn(params, "session_id")
771
+ ? requiredString(params.session_id, "session_id")
772
+ : undefined;
773
+ const selector = Object.hasOwn(params, "selector")
774
+ ? requiredString(params.selector, "selector")
775
+ : undefined;
776
+ if (!sessionId && !selector) {
607
777
  return runDelegate(api, params, toolContext);
608
778
  }
609
779
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
610
780
  const openclawSession = stringValue(toolContext?.sessionKey) ??
611
781
  "agent:main:main";
612
782
  const args = [
613
- "send",
614
- "--conversation",
615
- selector,
616
- "--message",
617
- requiredString(params.request, "request"),
618
- "--background"
783
+ "send"
619
784
  ];
785
+ if (sessionId) {
786
+ args.push("--session", sessionId);
787
+ }
788
+ else {
789
+ args.push("--conversation", requiredString(selector, "selector"));
790
+ }
791
+ args.push("--message", requiredString(params.request, "request"), "--background");
620
792
  pushOptional(args, "--type", stringValue(params.type));
621
793
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
622
794
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ??
@@ -631,17 +803,92 @@ async function runSendRequest(api, params, toolContext) {
631
803
  pushOptional(args, "--openclaw-bin", stringValue(config.openclawBin));
632
804
  return runCli(api, args);
633
805
  }
634
- function toolResult(result) {
806
+ function toolResult(result, { submissionErrors = false } = {}) {
807
+ const normalized = withTurnIdentity(result);
808
+ const submissionError = submissionErrors && isSubmissionError(normalized);
635
809
  return {
636
810
  content: [
637
811
  {
638
812
  type: "text",
639
- text: JSON.stringify(result, null, 2)
813
+ text: JSON.stringify(normalized, null, 2)
640
814
  }
641
815
  ],
642
- details: result
816
+ details: normalized,
817
+ ...(submissionError ? { isError: true } : {})
643
818
  };
644
819
  }
820
+ function isSubmissionError(result) {
821
+ if (!isRecord(result)) {
822
+ return false;
823
+ }
824
+ return [
825
+ "submission_unfenced",
826
+ "submission_uncertain",
827
+ "submission_aborted"
828
+ ].includes(String(result.status ?? "")) ||
829
+ ["uncertain", "aborted"].includes(String(result.submission_outcome ?? "")) ||
830
+ result.status === "delivered_unfenced";
831
+ }
832
+ function withTurnIdentity(result) {
833
+ if (!isRecord(result)) {
834
+ return result;
835
+ }
836
+ const sources = [
837
+ { label: "result", value: result },
838
+ { label: "result.conversation", value: result.conversation },
839
+ { label: "result.summary", value: result.summary },
840
+ { label: "result.message", value: result.message }
841
+ ];
842
+ const compatibilityId = consistentResultIdentity("conversation_id", sources);
843
+ const explicitSessionId = consistentResultIdentity("session_id", sources);
844
+ const explicitTurnId = consistentResultIdentity("turn_id", sources);
845
+ const hasModernIdentity = Boolean(explicitSessionId || explicitTurnId);
846
+ if (hasModernIdentity && (!explicitSessionId || !explicitTurnId)) {
847
+ throw new Error("agent-knock-knock CLI returned a partial session_id/turn_id identity");
848
+ }
849
+ if (hasModernIdentity && !compatibilityId) {
850
+ throw new Error("agent-knock-knock CLI returned modern identity without conversation_id");
851
+ }
852
+ if (hasModernIdentity && compatibilityId !== explicitTurnId) {
853
+ throw new Error("agent-knock-knock CLI returned conversation_id that differs from turn_id");
854
+ }
855
+ if (!hasModernIdentity && !compatibilityId) {
856
+ return result;
857
+ }
858
+ if (!hasModernIdentity &&
859
+ (compatibilityId?.startsWith("terminal:") ||
860
+ stringValue(result.source) === "terminal" ||
861
+ (isRecord(result.summary) &&
862
+ stringValue(result.summary.source) === "terminal"))) {
863
+ return result;
864
+ }
865
+ const sessionId = explicitSessionId ?? compatibilityId;
866
+ const turnId = explicitTurnId ?? compatibilityId;
867
+ return {
868
+ ...result,
869
+ session_id: sessionId,
870
+ turn_id: turnId
871
+ };
872
+ }
873
+ function consistentResultIdentity(field, sources) {
874
+ const values = sources.flatMap(({ label, value }) => {
875
+ if (!isRecord(value) || !Object.hasOwn(value, field)) {
876
+ return [];
877
+ }
878
+ const identity = stringValue(value[field]);
879
+ if (!identity) {
880
+ throw new Error(`agent-knock-knock CLI returned invalid ${label}.${field}`);
881
+ }
882
+ return [{ label, identity }];
883
+ });
884
+ const expected = values[0];
885
+ for (const candidate of values.slice(1)) {
886
+ if (candidate.identity !== expected.identity) {
887
+ throw new Error(`agent-knock-knock CLI returned conflicting ${field} between ${expected.label} and ${candidate.label}`);
888
+ }
889
+ }
890
+ return expected?.identity;
891
+ }
645
892
  async function runDelegate(api, params, toolContext) {
646
893
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
647
894
  const request = requiredString(params.request, "request");
@@ -661,67 +908,103 @@ async function runDelegate(api, params, toolContext) {
661
908
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ?? numberString(config.idleTimeoutMinutes));
662
909
  pushOptional(args, "--agent-timeout-minutes", numberString(params.agentTimeoutMinutes) ?? numberString(config.agentTimeoutMinutes));
663
910
  pushOptional(args, "--agent-hard-timeout-minutes", numberString(params.agentHardTimeoutMinutes) ?? numberString(config.agentHardTimeoutMinutes));
664
- const parsed = await runCliAsync(api, args);
665
- const conversationId = parsed.conversation?.conversation_id ??
666
- parsed.conversation_id;
667
- const statePath = parsed.conversation?.state_path ??
668
- parsed.paths?.statePath;
669
- const logPath = parsed.conversation?.event_log_path ??
670
- parsed.paths?.logPath;
911
+ const parsed = withTurnIdentity(await runCliAsync(api, args));
912
+ if (!isRecord(parsed)) {
913
+ throw new Error("agent-knock-knock delegate returned a non-object result");
914
+ }
915
+ const conversationId = stringValue(parsed.conversation_id) ??
916
+ (isRecord(parsed.conversation)
917
+ ? stringValue(parsed.conversation.conversation_id)
918
+ : undefined);
919
+ const sessionId = stringValue(parsed.session_id);
920
+ const turnId = stringValue(parsed.turn_id);
921
+ if (!conversationId || !sessionId || !turnId) {
922
+ throw new Error("agent-knock-knock delegate returned incomplete Turn identity");
923
+ }
924
+ const parsedConversation = isRecord(parsed.conversation)
925
+ ? parsed.conversation
926
+ : undefined;
927
+ const parsedPaths = isRecord(parsed.paths) ? parsed.paths : undefined;
928
+ const parsedTerminalControl = isRecord(parsed.terminal_control)
929
+ ? parsed.terminal_control
930
+ : undefined;
931
+ const statePath = parsedConversation?.state_path ??
932
+ parsedPaths?.statePath;
933
+ const logPath = parsedConversation?.event_log_path ??
934
+ parsedPaths?.logPath;
671
935
  const submissionUncertain = parsed.submission_outcome === "uncertain";
672
936
  const submissionAborted = parsed.submission_outcome === "aborted";
673
- const agent = stringValue(parsed.conversation?.executor?.kind) ??
937
+ const submissionUnfenced = parsed.status === "delivered_unfenced";
938
+ const agent = (isRecord(parsedConversation?.executor)
939
+ ? stringValue(parsedConversation.executor.kind)
940
+ : undefined) ??
674
941
  stringValue(parsed.agent);
675
942
  return {
676
- status: submissionUncertain
677
- ? "submission_uncertain"
678
- : submissionAborted
679
- ? "submission_aborted"
680
- : "async_pending",
681
- submission_status: submissionUncertain
682
- ? "uncertain"
683
- : submissionAborted
684
- ? "aborted"
685
- : "accepted",
943
+ status: submissionUnfenced
944
+ ? "submission_unfenced"
945
+ : submissionUncertain
946
+ ? "submission_uncertain"
947
+ : submissionAborted
948
+ ? "submission_aborted"
949
+ : "async_pending",
950
+ submission_status: submissionUnfenced
951
+ ? "submitted_unfenced"
952
+ : submissionUncertain
953
+ ? "uncertain"
954
+ : submissionAborted
955
+ ? "aborted"
956
+ : "accepted",
686
957
  conversation_id: conversationId,
687
- conversation_status: parsed.conversation?.status,
958
+ session_id: sessionId,
959
+ turn_id: turnId,
960
+ conversation_status: parsedConversation?.status,
688
961
  state_path: statePath,
689
962
  event_log_path: logPath,
690
963
  agent,
691
- executor: parsed.conversation?.executor,
692
- session: parsed.conversation?.executor?.session ??
693
- parsed.terminal_control?.target,
964
+ executor: parsedConversation?.executor,
965
+ session: (isRecord(parsedConversation?.executor)
966
+ ? parsedConversation.executor.session
967
+ : undefined) ??
968
+ parsedTerminalControl?.target,
694
969
  openclaw_session: openclawSession,
695
970
  launched: parsed.launched === true,
696
971
  replayed: parsed.replayed === true,
697
972
  background: parsed.background === true,
698
- pid: parsed.pid ?? parsed.terminal_control?.panePid ?? null,
973
+ pid: parsed.pid ?? parsedTerminalControl?.panePid ?? null,
699
974
  callback_method: CALLBACK_METHOD,
700
- ...(submissionUncertain
975
+ ...(submissionUnfenced
701
976
  ? {
702
- submission_outcome: "uncertain",
977
+ submission_outcome: "submitted",
703
978
  do_not_retry: true,
704
979
  reason: parsed.reason,
705
980
  openclaw_next_action: parsed.openclaw_next_action,
706
- note: "AKK could not prove whether tmux accepted Enter. Do not retry automatically; inspect the durable AKK conversation and shared terminal."
981
+ note: "AKK sent the terminal input but could not fence later side effects to an exact native session. Do not retry or continue automatically; inspect the pane and close this Turn."
707
982
  }
708
- : submissionAborted
983
+ : submissionUncertain
709
984
  ? {
710
- submission_outcome: "aborted",
711
- safe_to_retry: true,
985
+ submission_outcome: "uncertain",
986
+ do_not_retry: true,
712
987
  reason: parsed.reason,
713
988
  openclaw_next_action: parsed.openclaw_next_action,
714
- note: "AKK stopped before sending tmux input. The request was not submitted and may be retried."
989
+ note: "AKK could not prove whether tmux accepted Enter. Do not retry automatically; inspect the exact AKK Turn record and shared terminal."
715
990
  }
716
- : {
717
- openclaw_next_action: {
718
- action: "yield",
719
- reason: "The coding agent is working in the shared tmux terminal. End this OpenClaw turn now and wait for an Agent Knock Knock callback.",
720
- do_not: "Do not poll terminal internals while waiting. Follow-up communication must use Agent Knock Knock tools so the same shared terminal remains authoritative.",
721
- expected_callback: "The callback will be injected and scheduled into this OpenClaw session by the agent-knock-knock.callback Gateway method."
722
- },
723
- note: "The task was sent to the shared tmux terminal. OpenClaw should yield now and wait for the scheduled callback turn."
724
- })
991
+ : submissionAborted
992
+ ? {
993
+ submission_outcome: "aborted",
994
+ safe_to_retry: true,
995
+ reason: parsed.reason,
996
+ openclaw_next_action: parsed.openclaw_next_action,
997
+ note: "AKK stopped before sending tmux input. The request was not submitted and may be retried."
998
+ }
999
+ : {
1000
+ openclaw_next_action: {
1001
+ action: "yield",
1002
+ reason: "The coding agent is working in the shared tmux terminal. End this OpenClaw turn now and wait for an Agent Knock Knock callback.",
1003
+ do_not: "Do not poll terminal internals while waiting. Further communication must use Agent Knock Knock tools so the same shared terminal remains authoritative.",
1004
+ expected_callback: "The callback will be injected and scheduled into this OpenClaw session by the agent-knock-knock.callback Gateway method."
1005
+ },
1006
+ note: "The task was sent to the shared tmux terminal. OpenClaw should yield now and wait for the scheduled callback turn."
1007
+ })
725
1008
  };
726
1009
  }
727
1010
  function registerCliTool(api, { name, description, parameters, buildArgs }) {
@@ -732,15 +1015,9 @@ function registerCliTool(api, { name, description, parameters, buildArgs }) {
732
1015
  parameters,
733
1016
  async execute(_toolCallId, params) {
734
1017
  const result = runCli(api, buildArgs(isRecord(params) ? params : {}, toolContext));
735
- return {
736
- content: [
737
- {
738
- type: "text",
739
- text: JSON.stringify(result, null, 2)
740
- }
741
- ],
742
- details: result
743
- };
1018
+ return toolResult(result, {
1019
+ submissionErrors: name === "agent_knock_knock_respond"
1020
+ });
744
1021
  }
745
1022
  }), { name, optional: true });
746
1023
  }
@@ -846,6 +1123,139 @@ function runCliAsync(api, cliArgs, { cwd = process.cwd(), allowNonzeroJson = fal
846
1123
  function relayPathForApi(api) {
847
1124
  return relayPathByApi.get(api) ?? defaultBinPath;
848
1125
  }
1126
+ function callbackIdentity({ params, conversation, message, messageMetadata }) {
1127
+ const messageHasSessionId = isRecord(message) &&
1128
+ Object.hasOwn(message, "session_id");
1129
+ const messageHasTurnId = isRecord(message) &&
1130
+ Object.hasOwn(message, "turn_id");
1131
+ if (messageHasSessionId !== messageHasTurnId) {
1132
+ throw new Error("modern callback messages require both session_id and turn_id");
1133
+ }
1134
+ const identityMode = messageHasSessionId ? "modern" : "legacy";
1135
+ const sources = [
1136
+ { label: "message", value: message },
1137
+ { label: "message.metadata", value: messageMetadata },
1138
+ { label: "conversation", value: conversation },
1139
+ { label: "params", value: params }
1140
+ ];
1141
+ const explicitConversationId = consistentCallbackIdentity("conversation_id", sources);
1142
+ const explicitTurnId = consistentCallbackIdentity("turn_id", sources);
1143
+ const explicitSessionId = consistentCallbackIdentity("session_id", sources);
1144
+ const hasModernIdentity = Boolean(explicitSessionId || explicitTurnId);
1145
+ if (hasModernIdentity && (!explicitSessionId || !explicitTurnId)) {
1146
+ throw new Error("modern callbacks require both session_id and turn_id");
1147
+ }
1148
+ if (hasModernIdentity && !explicitConversationId) {
1149
+ throw new Error("modern callbacks require conversation_id as the Turn Store alias");
1150
+ }
1151
+ if (hasModernIdentity && explicitConversationId !== explicitTurnId) {
1152
+ throw new Error("callback conversation_id must equal turn_id for modern callback identities");
1153
+ }
1154
+ if (!explicitConversationId) {
1155
+ throw new Error("callback identity requires session_id and turn_id, or a legacy conversation_id");
1156
+ }
1157
+ const turnId = explicitTurnId ?? explicitConversationId;
1158
+ const conversationId = explicitConversationId;
1159
+ const sessionId = explicitSessionId ?? explicitConversationId;
1160
+ return {
1161
+ conversationId,
1162
+ sessionId,
1163
+ turnId,
1164
+ identityMode
1165
+ };
1166
+ }
1167
+ function consistentCallbackIdentity(field, sources) {
1168
+ const values = sources.flatMap(({ label, value }) => {
1169
+ if (!isRecord(value) || !Object.hasOwn(value, field)) {
1170
+ return [];
1171
+ }
1172
+ const identity = stringValue(value[field]);
1173
+ if (!identity) {
1174
+ throw new Error(`callback ${label}.${field} must be a non-empty string`);
1175
+ }
1176
+ return [{ label, identity }];
1177
+ });
1178
+ const expected = values[0];
1179
+ for (const candidate of values.slice(1)) {
1180
+ if (candidate.identity !== expected.identity) {
1181
+ throw new Error(`callback ${field} mismatch between ${expected.label} and ${candidate.label}`);
1182
+ }
1183
+ }
1184
+ return expected?.identity;
1185
+ }
1186
+ function callbackSessionKey({ params, conversation, message, messageMetadata }) {
1187
+ const sessionKey = callbackStringField("params.sessionKey", params, "sessionKey");
1188
+ if (!sessionKey) {
1189
+ throw new Error("callback params.sessionKey is required");
1190
+ }
1191
+ const gatewaySession = consistentCallbackTarget("Gateway session", [
1192
+ {
1193
+ label: "conversation.gateway_session",
1194
+ owner: conversation,
1195
+ field: "gateway_session"
1196
+ },
1197
+ {
1198
+ label: "message.gateway_session",
1199
+ owner: message,
1200
+ field: "gateway_session"
1201
+ },
1202
+ {
1203
+ label: "message.metadata.gateway_session",
1204
+ owner: messageMetadata,
1205
+ field: "gateway_session"
1206
+ }
1207
+ ]);
1208
+ const openclawSession = consistentCallbackTarget("OpenClaw session", [
1209
+ {
1210
+ label: "params.openclaw_session",
1211
+ owner: params,
1212
+ field: "openclaw_session"
1213
+ },
1214
+ {
1215
+ label: "conversation.openclaw_session",
1216
+ owner: conversation,
1217
+ field: "openclaw_session"
1218
+ },
1219
+ {
1220
+ label: "message.openclaw_session",
1221
+ owner: message,
1222
+ field: "openclaw_session"
1223
+ },
1224
+ {
1225
+ label: "message.metadata.openclaw_session",
1226
+ owner: messageMetadata,
1227
+ field: "openclaw_session"
1228
+ }
1229
+ ]);
1230
+ const expectedGatewayTarget = gatewaySession ?? openclawSession;
1231
+ if (expectedGatewayTarget && expectedGatewayTarget !== sessionKey) {
1232
+ throw new Error("callback Gateway session mismatch with params.sessionKey");
1233
+ }
1234
+ return sessionKey;
1235
+ }
1236
+ function consistentCallbackTarget(label, sources) {
1237
+ const values = sources.flatMap((source) => {
1238
+ const value = callbackStringField(source.label, source.owner, source.field);
1239
+ return value ? [{ label: source.label, value }] : [];
1240
+ });
1241
+ const expected = values[0];
1242
+ for (const candidate of values.slice(1)) {
1243
+ if (candidate.value !== expected.value) {
1244
+ throw new Error(`callback ${label} mismatch between ${expected.label} and ${candidate.label}`);
1245
+ }
1246
+ }
1247
+ return expected?.value;
1248
+ }
1249
+ function callbackStringField(label, owner, field) {
1250
+ if (!isRecord(owner) || !Object.hasOwn(owner, field)) {
1251
+ return undefined;
1252
+ }
1253
+ const value = stringValue(owner[field]);
1254
+ if (!value) {
1255
+ throw new Error(`callback ${label} must be a non-empty string`);
1256
+ }
1257
+ return value;
1258
+ }
849
1259
  async function handleCallback(api, params) {
850
1260
  if (!isRecord(params)) {
851
1261
  throw new Error("callback params must be an object");
@@ -853,17 +1263,20 @@ async function handleCallback(api, params) {
853
1263
  const message = isRecord(params.message) ? params.message : undefined;
854
1264
  const conversation = isRecord(params.conversation) ? params.conversation : undefined;
855
1265
  const messageMetadata = isRecord(message?.metadata) ? message.metadata : undefined;
856
- const sessionKey = stringValue(params.sessionKey) ??
857
- stringValue(conversation?.openclaw_session) ??
858
- stringValue(messageMetadata?.openclaw_session);
859
- if (!sessionKey) {
860
- throw new Error("callback params.sessionKey is required");
861
- }
862
1266
  if (!message) {
863
1267
  throw new Error("callback params.message is required");
864
1268
  }
865
- const conversationId = stringValue(message.conversation_id) ?? stringValue(conversation?.conversation_id);
866
- const messageId = stringValue(message.id) ?? `${conversationId ?? "unknown"}:${stringValue(message.type) ?? "message"}:${Date.now()}`;
1269
+ const sessionKey = callbackSessionKey({
1270
+ params,
1271
+ conversation,
1272
+ message,
1273
+ messageMetadata
1274
+ });
1275
+ const { conversationId, sessionId, turnId, identityMode } = callbackIdentity({ params, conversation, message, messageMetadata });
1276
+ const messageId = stringValue(message.id);
1277
+ if (!messageId) {
1278
+ throw new Error("callback message.id is required");
1279
+ }
867
1280
  const autoApproval = tryAutoApproveCallback({
868
1281
  api,
869
1282
  message,
@@ -878,6 +1291,8 @@ async function handleCallback(api, params) {
878
1291
  delivery_mode: "none",
879
1292
  session_key: sessionKey,
880
1293
  conversation_id: conversationId,
1294
+ session_id: sessionId,
1295
+ turn_id: turnId,
881
1296
  message_id: messageId,
882
1297
  message_type: stringValue(message.type) ?? "unknown",
883
1298
  auto_approved: autoApproval.approved === true,
@@ -885,16 +1300,26 @@ async function handleCallback(api, params) {
885
1300
  approval: autoApproval
886
1301
  };
887
1302
  }
888
- const formatted = formatCallbackInjection({ conversation, message, statePath: stringValue(params.statePath) });
1303
+ const formatted = formatCallbackInjection({
1304
+ message,
1305
+ sessionId,
1306
+ turnId,
1307
+ statePath: stringValue(params.statePath)
1308
+ });
1309
+ const dedupeIdentity = identityMode === "legacy"
1310
+ ? conversationId
1311
+ : `${sessionId}:${turnId}`;
889
1312
  const injection = await api.session.workflow.enqueueNextTurnInjection({
890
1313
  sessionKey,
891
1314
  text: formatted,
892
- idempotencyKey: `agent-knock-knock:${conversationId ?? "unknown"}:${messageId}`,
1315
+ idempotencyKey: `agent-knock-knock:${dedupeIdentity}:${messageId}`,
893
1316
  placement: "append_context",
894
1317
  ttlMs: 24 * 60 * 60 * 1000,
895
1318
  metadata: {
896
1319
  kind: "agent-knock-knock-callback",
897
1320
  conversation_id: conversationId,
1321
+ session_id: sessionId,
1322
+ turn_id: turnId,
898
1323
  message_id: messageId,
899
1324
  message_type: stringValue(message.type) ?? "unknown",
900
1325
  state_path: stringValue(params.statePath),
@@ -904,6 +1329,9 @@ async function handleCallback(api, params) {
904
1329
  const delivery = buildCallbackDeliveryPlan({
905
1330
  sessionKey,
906
1331
  conversationId,
1332
+ sessionId,
1333
+ turnId,
1334
+ identityMode,
907
1335
  messageId,
908
1336
  message,
909
1337
  formatted
@@ -918,6 +1346,8 @@ async function handleCallback(api, params) {
918
1346
  injection_id: injection?.id,
919
1347
  session_key: injection?.sessionKey ?? sessionKey,
920
1348
  conversation_id: conversationId,
1349
+ session_id: sessionId,
1350
+ turn_id: turnId,
921
1351
  message_id: messageId,
922
1352
  message_type: stringValue(message.type) ?? "unknown"
923
1353
  };
@@ -937,7 +1367,7 @@ function tryAutoApproveCallback({ api, message, conversationId, statePath }) {
937
1367
  }
938
1368
  return result;
939
1369
  }
940
- function buildCallbackDeliveryPlan({ sessionKey, conversationId, messageId, message, formatted }) {
1370
+ function buildCallbackDeliveryPlan({ sessionKey, conversationId, sessionId, turnId, identityMode, messageId, message, formatted }) {
941
1371
  const type = stringValue(message.type) ?? "unknown";
942
1372
  const shouldWake = message.requires_response === true ||
943
1373
  type === "question" ||
@@ -950,6 +1380,9 @@ function buildCallbackDeliveryPlan({ sessionKey, conversationId, messageId, mess
950
1380
  mode: "none"
951
1381
  };
952
1382
  }
1383
+ const dedupeIdentity = identityMode === "legacy"
1384
+ ? conversationId
1385
+ : `${sessionId}:${turnId}`;
953
1386
  return {
954
1387
  required: true,
955
1388
  mode: "chat.send",
@@ -958,27 +1391,31 @@ function buildCallbackDeliveryPlan({ sessionKey, conversationId, messageId, mess
958
1391
  message: [
959
1392
  "Continue this OpenClaw product-manager conversation from the Agent Knock Knock callback below.",
960
1393
  "Treat the callback as a structured message from the coding agent's managed terminal turn, not as a terminal log, status announcement, or instruction to inspect local state.",
961
- "Respond in this conversation as OpenClaw product manager. If the callback is question or blocked, make the product decision and answer the coding agent. If it is done, summarize the result to the user.",
1394
+ "Respond in this conversation as OpenClaw product manager. If the callback is question or blocked, make the product decision and use agent_knock_knock_respond with its exact turn_id. If it is done, summarize the result to the user.",
962
1395
  "Do not poll files, processes, sessions, stdout, or stderr. Use only the structured callback payload below.",
963
1396
  "",
964
1397
  formatted
965
1398
  ].join("\n"),
966
- idempotencyKey: `agent-knock-knock-callback:${conversationId ?? "unknown"}:${messageId ?? "unknown"}`,
1399
+ idempotencyKey: `agent-knock-knock-callback:${dedupeIdentity}:${messageId}`,
967
1400
  deliver: true
968
1401
  }
969
1402
  };
970
1403
  }
971
- function formatCallbackInjection({ conversation, message, statePath }) {
972
- const conversationId = stringValue(message.conversation_id) ?? stringValue(conversation?.conversation_id) ?? "unknown";
1404
+ function formatCallbackInjection({ message, sessionId, turnId, statePath }) {
973
1405
  const type = stringValue(message.type) ?? "unknown";
974
1406
  const body = stringValue(message.body) ?? JSON.stringify(message.body ?? "");
975
1407
  const requiresResponse = message.requires_response === true ? "yes" : "no";
976
1408
  const round = typeof message.round === "number" ? String(message.round) : "unknown";
977
1409
  const stateLine = statePath ? `State: ${statePath}\n` : "";
978
- const shortcuts = type === "done" ? formatDoneShortcuts(conversationId) : "";
1410
+ const shortcuts = type === "done"
1411
+ ? formatDoneShortcuts(sessionId, turnId)
1412
+ : message.requires_response === true || type === "question" || type === "blocked"
1413
+ ? formatRespondShortcut(turnId)
1414
+ : "";
979
1415
  return [
980
1416
  "[Agent Knock Knock callback]",
981
- `Conversation: ${conversationId}`,
1417
+ `Session: ${sessionId}`,
1418
+ `Turn: ${turnId}`,
982
1419
  `Message type: ${type}`,
983
1420
  `Requires OpenClaw response: ${requiresResponse}`,
984
1421
  `Round: ${round}`,
@@ -988,17 +1425,35 @@ function formatCallbackInjection({ conversation, message, statePath }) {
988
1425
  shortcuts
989
1426
  ].filter((line) => line !== "").join("\n");
990
1427
  }
991
- function formatDoneShortcuts(conversationId) {
1428
+ function formatDoneShortcuts(sessionId, turnId) {
992
1429
  return [
993
1430
  "",
994
1431
  "[AKK convenience commands]",
995
1432
  "When summarizing this result to the user, include these short next-step commands:",
996
1433
  "- `AKK list` lists live shared terminals with their current or recent managed turns.",
997
- "- Use the matching `@short-ref` from `AKK list`, then `AKK @short-ref: <message>` to continue in the same shared terminal.",
998
- `- \`AKK status ${conversationId}\` shows this managed turn.`,
1434
+ `- Use \`agent_knock_knock_send\` with \`session_id: ${JSON.stringify(sessionId)}\` to start a later turn in the same coding-agent context.`,
1435
+ `- Use \`agent_knock_knock_status\` with \`turn_id: ${JSON.stringify(turnId)}\` to inspect this exact turn.`,
999
1436
  "- AKK never starts or closes the coding agent or tmux pane."
1000
1437
  ].join("\n");
1001
1438
  }
1439
+ function formatRespondShortcut(turnId) {
1440
+ return [
1441
+ "",
1442
+ "[AKK response command]",
1443
+ `- Use \`agent_knock_knock_respond\` with \`turn_id: ${JSON.stringify(turnId)}\` and your decision in \`request\`. Do not use ordinary send for this response.`
1444
+ ].join("\n");
1445
+ }
1446
+ function pushTurnTarget(args, params) {
1447
+ if (Object.hasOwn(params, "turn_id") && Object.hasOwn(params, "conversation_id")) {
1448
+ throw new Error("turn-target tools accept only one of turn_id or conversation_id");
1449
+ }
1450
+ const turnId = stringValue(params.turn_id);
1451
+ if (turnId) {
1452
+ args.push("--turn", turnId);
1453
+ return;
1454
+ }
1455
+ args.push("--conversation", requiredString(params.conversation_id, "turn_id"));
1456
+ }
1002
1457
  function pushOptional(args, flag, value) {
1003
1458
  if (value !== undefined && value !== "") {
1004
1459
  args.push(flag, value);