@scotthuang/agent-knock-knock 0.8.1 → 0.10.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +45 -17
  3. package/dist/src/agent-session-provider.d.ts +13 -0
  4. package/dist/src/claude-local-transcript-provider.d.ts +29 -1
  5. package/dist/src/claude-local-transcript-provider.js +313 -0
  6. package/dist/src/claude-local-transcript-provider.js.map +1 -1
  7. package/dist/src/claude-terminal-agent-adapter.d.ts +6 -1
  8. package/dist/src/claude-terminal-agent-adapter.js +178 -0
  9. package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
  10. package/dist/src/cli.js +5671 -317
  11. package/dist/src/cli.js.map +1 -1
  12. package/dist/src/codex-local-session-provider.d.ts +3 -1
  13. package/dist/src/codex-local-session-provider.js +17 -0
  14. package/dist/src/codex-local-session-provider.js.map +1 -1
  15. package/dist/src/codex-store-adapter.d.ts +26 -1
  16. package/dist/src/codex-store-adapter.js +575 -2
  17. package/dist/src/codex-store-adapter.js.map +1 -1
  18. package/dist/src/codex-terminal-agent-adapter.d.ts +6 -1
  19. package/dist/src/codex-terminal-agent-adapter.js +192 -0
  20. package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
  21. package/dist/src/managed-session.d.ts +163 -0
  22. package/dist/src/managed-session.js +873 -0
  23. package/dist/src/managed-session.js.map +1 -0
  24. package/dist/src/native-thread-lifecycle-policy.d.ts +57 -0
  25. package/dist/src/native-thread-lifecycle-policy.js +116 -0
  26. package/dist/src/native-thread-lifecycle-policy.js.map +1 -0
  27. package/dist/src/openclaw-plugin-helpers.d.ts +45 -12
  28. package/dist/src/openclaw-plugin-helpers.js +386 -47
  29. package/dist/src/openclaw-plugin-helpers.js.map +1 -1
  30. package/dist/src/openclaw-plugin.js +814 -144
  31. package/dist/src/openclaw-plugin.js.map +1 -1
  32. package/dist/src/protocol.d.ts +28 -1
  33. package/dist/src/protocol.js +93 -4
  34. package/dist/src/protocol.js.map +1 -1
  35. package/dist/src/session-store.d.ts +57 -0
  36. package/dist/src/session-store.js +529 -0
  37. package/dist/src/session-store.js.map +1 -0
  38. package/dist/src/store.d.ts +5 -3
  39. package/dist/src/store.js +422 -17
  40. package/dist/src/store.js.map +1 -1
  41. package/dist/src/terminal-agent-adapter.d.ts +175 -0
  42. package/dist/src/terminal-agent-adapter.js +12 -0
  43. package/dist/src/terminal-agent-adapter.js.map +1 -1
  44. package/dist/src/terminal-agent-bridge.d.ts +9 -0
  45. package/dist/src/terminal-agent-bridge.js +17 -1
  46. package/dist/src/terminal-agent-bridge.js.map +1 -1
  47. package/dist/src/terminal-control-provider.d.ts +2 -0
  48. package/dist/src/terminal-control-provider.js +1 -0
  49. package/dist/src/terminal-control-provider.js.map +1 -1
  50. package/docs/quickstart-tmux.md +23 -2
  51. package/openclaw.plugin.json +19 -3
  52. package/package.json +1 -1
  53. package/templates/openclaw-skills/agent-knock-knock/SKILL.md +42 -17
@@ -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, formatAkkThreadsCommandResult, formatAkkThreadTransitionCommandResult, isAkkThreadTransitionSuccess, 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; discovery selectors, terminal ids, and turn ids are never session_id destinations."
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. Use one only when explicitly named by the user or prefilled by list; never infer it. 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, never a discovery selector or terminal id. 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,
@@ -66,14 +90,87 @@ const listParameters = {
66
90
  }
67
91
  }
68
92
  };
93
+ const listResumableThreadsParameters = {
94
+ type: "object",
95
+ additionalProperties: false,
96
+ required: ["terminal_id"],
97
+ properties: {
98
+ terminal_id: {
99
+ type: "string",
100
+ minLength: 1,
101
+ pattern: "^terminal:v[0-9]+:\\S+$",
102
+ description: "Exact full terminal_id from the selected terminal row's current available_actions. Do not use a short ref, session id, turn id, or constructed selector."
103
+ }
104
+ }
105
+ };
106
+ const newThreadParameters = {
107
+ type: "object",
108
+ additionalProperties: false,
109
+ required: ["terminal_id", "expected_binding_token"],
110
+ properties: {
111
+ terminal_id: {
112
+ type: "string",
113
+ minLength: 1,
114
+ pattern: "^terminal:v[0-9]+:\\S+$",
115
+ description: "Exact full terminal_id from the same current lifecycle snapshot as expected_binding_token."
116
+ },
117
+ expected_binding_token: {
118
+ type: "string",
119
+ minLength: 1,
120
+ description: "Fresh compare-and-swap token prefilled by this terminal's available new_thread action or returned by agent_knock_knock_list_resumable_threads. Never guess, construct, or reuse it after another terminal action."
121
+ }
122
+ }
123
+ };
124
+ const resumeThreadParameters = {
125
+ type: "object",
126
+ additionalProperties: false,
127
+ required: [
128
+ "terminal_id",
129
+ "native_thread_id",
130
+ "expected_binding_token",
131
+ "candidate_token"
132
+ ],
133
+ properties: {
134
+ terminal_id: {
135
+ type: "string",
136
+ minLength: 1,
137
+ pattern: "^terminal:v[0-9]+:\\S+$",
138
+ description: "Exact full terminal_id passed to agent_knock_knock_list_resumable_threads."
139
+ },
140
+ native_thread_id: {
141
+ type: "string",
142
+ pattern: "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
143
+ description: "Complete native thread UUID from a resumable=true row returned for this exact terminal. Never truncate, guess, or select an unavailable row."
144
+ },
145
+ expected_binding_token: {
146
+ type: "string",
147
+ minLength: 1,
148
+ description: "Fresh compare-and-swap token from the same agent_knock_knock_list_resumable_threads result as native_thread_id."
149
+ },
150
+ candidate_token: {
151
+ type: "string",
152
+ minLength: 1,
153
+ description: "Opaque fingerprint from the selected resumable thread row in the same current list result. It binds resume to that exact historical file/evidence snapshot; never construct or reuse it."
154
+ }
155
+ }
156
+ };
69
157
  const renewParameters = {
70
158
  type: "object",
71
159
  additionalProperties: false,
72
- required: ["conversation_id"],
160
+ not: { required: ["turn_id", "conversation_id"] },
161
+ anyOf: [
162
+ { required: ["turn_id"] },
163
+ { required: ["conversation_id"] }
164
+ ],
73
165
  properties: {
166
+ turn_id: {
167
+ type: "string",
168
+ description: "Authoritative AKK turn id whose monitoring should be renewed."
169
+ },
74
170
  conversation_id: {
75
171
  type: "string",
76
- description: "Stalled AKK-managed terminal bridge conversation id."
172
+ deprecated: true,
173
+ description: "Deprecated compatibility alias for turn_id."
77
174
  },
78
175
  minutes: {
79
176
  type: "number",
@@ -85,22 +182,40 @@ const renewParameters = {
85
182
  const retryCallbackParameters = {
86
183
  type: "object",
87
184
  additionalProperties: false,
88
- required: ["conversation_id"],
185
+ not: { required: ["turn_id", "conversation_id"] },
186
+ anyOf: [
187
+ { required: ["turn_id"] },
188
+ { required: ["conversation_id"] }
189
+ ],
89
190
  properties: {
191
+ turn_id: {
192
+ type: "string",
193
+ description: "Authoritative AKK turn id whose persisted callback should be retried."
194
+ },
90
195
  conversation_id: {
91
196
  type: "string",
92
- description: "AKK-managed conversation whose persisted callback delivery is pending or failed."
197
+ deprecated: true,
198
+ description: "Deprecated compatibility alias for turn_id."
93
199
  }
94
200
  }
95
201
  };
96
202
  const statusParameters = {
97
203
  type: "object",
98
204
  additionalProperties: false,
99
- required: ["conversation_id"],
205
+ not: { required: ["turn_id", "conversation_id"] },
206
+ anyOf: [
207
+ { required: ["turn_id"] },
208
+ { required: ["conversation_id"] }
209
+ ],
100
210
  properties: {
211
+ turn_id: {
212
+ type: "string",
213
+ description: "Authoritative AKK turn id to inspect."
214
+ },
101
215
  conversation_id: {
102
216
  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."
217
+ deprecated: true,
218
+ 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
219
  },
105
220
  idleTimeoutMinutes: {
106
221
  type: "number"
@@ -114,11 +229,20 @@ const statusParameters = {
114
229
  const cancelParameters = {
115
230
  type: "object",
116
231
  additionalProperties: false,
117
- required: ["conversation_id"],
232
+ not: { required: ["turn_id", "conversation_id"] },
233
+ anyOf: [
234
+ { required: ["turn_id"] },
235
+ { required: ["conversation_id"] }
236
+ ],
118
237
  properties: {
238
+ turn_id: {
239
+ type: "string",
240
+ description: "Authoritative AKK turn id to interrupt."
241
+ },
119
242
  conversation_id: {
120
243
  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."
244
+ deprecated: true,
245
+ 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
246
  },
123
247
  idleTimeoutMinutes: {
124
248
  type: "number"
@@ -128,28 +252,57 @@ const cancelParameters = {
128
252
  const closeParameters = {
129
253
  type: "object",
130
254
  additionalProperties: false,
131
- required: ["conversation_id"],
255
+ not: {
256
+ anyOf: [
257
+ { required: ["turn_id", "conversation_id"] },
258
+ { required: ["expected_message_id", "expected_transition_id"] }
259
+ ]
260
+ },
261
+ anyOf: [
262
+ { required: ["turn_id"] },
263
+ { required: ["conversation_id"] }
264
+ ],
132
265
  properties: {
266
+ turn_id: {
267
+ type: "string",
268
+ description: "Authoritative AKK turn id whose managed record should be closed."
269
+ },
133
270
  conversation_id: {
134
- type: "string"
271
+ type: "string",
272
+ deprecated: true,
273
+ 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
274
  },
136
275
  reason: {
137
276
  type: "string"
138
277
  },
139
278
  expected_message_id: {
140
279
  type: "string",
141
- description: "Required only to clear an orphaned terminal dispatch shown by AKK list. Must exactly match that entry's current message_id."
280
+ description: "Required only to clear an orphaned terminal dispatch shown by AKK list. Must exactly match that entry's current message_id and must not be combined with expected_transition_id."
281
+ },
282
+ expected_transition_id: {
283
+ type: "string",
284
+ description: "Required only to recover an unresolved native-thread lifecycle transition shown by AKK list. Must exactly match that entry's current transition_id and must not be combined with expected_message_id."
142
285
  }
143
286
  }
144
287
  };
145
288
  const approveParameters = {
146
289
  type: "object",
147
290
  additionalProperties: false,
148
- required: ["conversation_id", "expected_approval_fingerprint"],
291
+ required: ["expected_approval_fingerprint"],
292
+ not: { required: ["turn_id", "conversation_id"] },
293
+ anyOf: [
294
+ { required: ["turn_id"] },
295
+ { required: ["conversation_id"] }
296
+ ],
149
297
  properties: {
298
+ turn_id: {
299
+ type: "string",
300
+ description: "Authoritative AKK turn id containing the approval prompt."
301
+ },
150
302
  conversation_id: {
151
303
  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."
304
+ deprecated: true,
305
+ 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
306
  },
154
307
  expected_approval_fingerprint: {
155
308
  type: "string",
@@ -205,20 +358,20 @@ function createPlugin(relayPath) {
205
358
  }
206
359
  api.registerCommand?.({
207
360
  name: "akk",
208
- description: "Send coding work through existing Codex or Claude Code tmux terminals, inspect managed turns, approve exact prompts, and cancel running work.",
361
+ description: "Send coding work through existing Codex or Claude Code tmux terminals, inspect managed turns, and explicitly start, clear, list, or resume native threads.",
209
362
  acceptsArgs: true,
210
363
  requireAuth: true,
211
364
  nativeProgressMessages: {
212
365
  default: "AKK is handling the request..."
213
366
  },
214
367
  agentPromptGuidance: [
215
- "Use /akk <task> when exactly one eligible idle coding-agent tmux pane should receive new work. Use /akk codex: <task>, /akk claude: <task>, or another selector returned by /akk list to target an existing pane. AKK never starts a coding agent."
368
+ "Use /akk <task> when exactly one eligible idle coding-agent tmux pane should receive new work. Use /akk codex: <task>, /akk claude: <task>, or another selector returned by /akk list to target an existing pane. Ordinary sends preserve native context. Use /akk threads, /akk new-thread or clear-thread, and /akk resume-thread only with an exact full terminal_id returned by /akk list; these switch native context without creating a Turn. AKK never starts a coding-agent process."
216
369
  ],
217
370
  handler: async (ctx) => handleAkkCommand(api, ctx)
218
371
  });
219
372
  registerCliTool(api, {
220
373
  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.",
374
+ 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; read-only thread listing targets the exact terminal, while new/resume mutations also require the current binding token and create no Turn; managed controls target the exact turn; a raw terminal row may prefill its own compatibility selector for status or recovery controls. Never construct identifiers or tokens. AKK revalidates every side effect and never starts a coding-agent process.",
222
375
  parameters: listParameters,
223
376
  buildArgs: (params) => {
224
377
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
@@ -239,10 +392,68 @@ function createPlugin(relayPath) {
239
392
  return args;
240
393
  }
241
394
  });
395
+ registerCliTool(api, {
396
+ name: "agent_knock_knock_list_resumable_threads",
397
+ description: "List verified native Codex or Claude Code threads for one exact terminal. Call this only from that terminal row's available list_resumable_threads action. The result includes the current native identity, a fresh expected_binding_token, and exact candidate UUIDs with opaque candidate_token fingerprints; resume only a row with resumable=true and preserve both tokens from that same result. This is read-only and creates no AKK Turn.",
398
+ parameters: listResumableThreadsParameters,
399
+ normalizeTurnIdentity: false,
400
+ buildArgs: (params) => {
401
+ const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
402
+ const args = [
403
+ "list-resumable-threads",
404
+ "--terminal",
405
+ requiredString(params.terminal_id, "terminal_id")
406
+ ];
407
+ pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
408
+ return args;
409
+ }
410
+ });
411
+ registerCliTool(api, {
412
+ name: "agent_knock_knock_new_thread",
413
+ description: "Start and verify a clean native coding-agent thread in the same exact tmux terminal. Call only from a current available new_thread action, using its exact terminal_id and expected_binding_token, or immediately after agent_knock_knock_list_resumable_threads using that result's token. Never send /clear as ordinary task text. This lifecycle transition creates a new AKK Session but no Turn; use ordinary send afterward.",
414
+ parameters: newThreadParameters,
415
+ normalizeTurnIdentity: false,
416
+ isErrorResult: (result) => !isAkkThreadTransitionSuccess(result),
417
+ buildArgs: (params) => {
418
+ const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
419
+ const args = [
420
+ "new-thread",
421
+ "--terminal",
422
+ requiredString(params.terminal_id, "terminal_id"),
423
+ "--expected-binding-token",
424
+ requiredString(params.expected_binding_token, "expected_binding_token")
425
+ ];
426
+ pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
427
+ return args;
428
+ }
429
+ });
430
+ registerCliTool(api, {
431
+ name: "agent_knock_knock_resume_thread",
432
+ description: "Resume one exact verified historical native thread in the same tmux terminal. First call agent_knock_knock_list_resumable_threads, then pass its exact terminal_id and expected_binding_token plus one complete native_thread_id whose row says resumable=true and that same row's candidate_token. Never guess, truncate, or reuse IDs or tokens, and never send a resume slash command as ordinary task text. This creates or reactivates an AKK Session but creates no Turn.",
433
+ parameters: resumeThreadParameters,
434
+ normalizeTurnIdentity: false,
435
+ isErrorResult: (result) => !isAkkThreadTransitionSuccess(result),
436
+ buildArgs: (params) => {
437
+ const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
438
+ const args = [
439
+ "resume-thread",
440
+ "--terminal",
441
+ requiredString(params.terminal_id, "terminal_id"),
442
+ "--native-thread",
443
+ requiredString(params.native_thread_id, "native_thread_id"),
444
+ "--expected-binding-token",
445
+ requiredString(params.expected_binding_token, "expected_binding_token"),
446
+ "--candidate-token",
447
+ requiredString(params.candidate_token, "candidate_token")
448
+ ];
449
+ pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
450
+ return args;
451
+ }
452
+ });
242
453
  api.registerTool((_toolContext) => ({
243
454
  label: "AKK Status",
244
455
  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.",
456
+ 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
457
  parameters: statusParameters,
247
458
  async execute(_toolCallId, params) {
248
459
  const result = runCli(api, buildStatusCliArgs(api, isRecord(params) ? params : {}));
@@ -252,20 +463,38 @@ function createPlugin(relayPath) {
252
463
  api.registerTool((toolContext) => ({
253
464
  label: "AKK Send",
254
465
  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.",
466
+ description: "Start a new turn in an existing AKK session without clearing the coding agent's native context. Target the exact session_id from list or a prior send; never pass a turn id or discovery selector as session_id. selector is compatibility-only for initial live-terminal discovery: use one explicitly named by the user or prefilled by list, and never infer one. 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
467
  parameters: sendParameters,
257
468
  async execute(_toolCallId, params) {
258
469
  const result = await runSendRequest(api, isRecord(params) ? params : {}, toolContext);
259
- return toolResult(result);
470
+ return toolResult(result, { submissionErrors: true });
260
471
  }
261
472
  }), { name: "agent_knock_knock_send", optional: true });
473
+ registerCliTool(api, {
474
+ name: "agent_knock_knock_respond",
475
+ 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.",
476
+ parameters: respondParameters,
477
+ buildArgs: (params) => {
478
+ const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
479
+ const args = [
480
+ "respond",
481
+ "--turn",
482
+ authoritativeManagedId(params.turn_id, "turn_id"),
483
+ "--message",
484
+ requiredString(params.request, "request")
485
+ ];
486
+ pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
487
+ return args;
488
+ }
489
+ });
262
490
  registerCliTool(api, {
263
491
  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.",
492
+ 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
493
  parameters: approveParameters,
266
494
  buildArgs: (params) => {
267
495
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
268
- const args = ["approve", "--conversation", requiredString(params.conversation_id, "conversation_id")];
496
+ const args = ["approve"];
497
+ pushTurnTarget(args, params);
269
498
  pushOptional(args, "--expected-approval-fingerprint", requiredString(params.expected_approval_fingerprint, "expected_approval_fingerprint"));
270
499
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
271
500
  return args;
@@ -273,11 +502,12 @@ function createPlugin(relayPath) {
273
502
  });
274
503
  registerCliTool(api, {
275
504
  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.",
505
+ 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
506
  parameters: renewParameters,
278
507
  buildArgs: (params) => {
279
508
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
280
- const args = ["renew", "--conversation", requiredString(params.conversation_id, "conversation_id")];
509
+ const args = ["renew"];
510
+ pushTurnTarget(args, params);
281
511
  pushOptional(args, "--minutes", numberString(params.minutes) ?? numberString(config.agentTimeoutMinutes));
282
512
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
283
513
  return args;
@@ -285,22 +515,24 @@ function createPlugin(relayPath) {
285
515
  });
286
516
  registerCliTool(api, {
287
517
  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.",
518
+ 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
519
  parameters: retryCallbackParameters,
290
520
  buildArgs: (params) => {
291
521
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
292
- const args = ["retry-callback", "--conversation", requiredString(params.conversation_id, "conversation_id")];
522
+ const args = ["retry-callback"];
523
+ pushTurnTarget(args, params);
293
524
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
294
525
  return args;
295
526
  }
296
527
  });
297
528
  registerCliTool(api, {
298
529
  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.",
530
+ 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
531
  parameters: cancelParameters,
301
532
  buildArgs: (params) => {
302
533
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
303
- const args = ["cancel", "--conversation", requiredString(params.conversation_id, "conversation_id")];
534
+ const args = ["cancel"];
535
+ pushTurnTarget(args, params);
304
536
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
305
537
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ?? numberString(config.idleTimeoutMinutes));
306
538
  return args;
@@ -308,13 +540,17 @@ function createPlugin(relayPath) {
308
540
  });
309
541
  registerCliTool(api, {
310
542
  name: "agent_knock_knock_close",
311
- description: "Close an AKK-managed turn record without terminating the shared tmux pane. For an orphaned terminal dispatch only, the user must explicitly request recovery and provide the exact expected_message_id reported by AKK list.",
543
+ description: "Close an AKK-managed turn record without terminating the shared tmux pane. For a list-prefilled raw-terminal recovery, the user must explicitly request it and provide the exact expected_message_id or expected_transition_id reported by that AKK list entry.",
312
544
  parameters: closeParameters,
545
+ isErrorResult: isBlockedTerminalDispatchResult,
313
546
  buildArgs: (params) => {
314
547
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
315
- const args = ["close", "--conversation", requiredString(params.conversation_id, "conversation_id")];
548
+ const args = ["close"];
549
+ assertExclusiveRecoveryFence(params);
550
+ pushTurnTarget(args, params);
316
551
  pushOptional(args, "--reason", stringValue(params.reason));
317
552
  pushOptional(args, "--expected-message-id", stringValue(params.expected_message_id));
553
+ pushOptional(args, "--expected-transition-id", stringValue(params.expected_transition_id));
318
554
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
319
555
  return args;
320
556
  }
@@ -345,6 +581,48 @@ async function handleAkkCommand(api, ctx) {
345
581
  };
346
582
  }
347
583
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
584
+ if (parsed.action === "list-resumable-threads" ||
585
+ parsed.action === "new-thread" ||
586
+ parsed.action === "resume-thread") {
587
+ const discoveryArgs = buildAkkCommandCliArgs({
588
+ action: "list-resumable-threads",
589
+ terminalId: parsed.terminalId
590
+ }, config);
591
+ if (!discoveryArgs) {
592
+ throw new Error("could not build native-thread discovery command");
593
+ }
594
+ const discovery = runCli(api, discoveryArgs);
595
+ if (parsed.action === "list-resumable-threads" ||
596
+ (parsed.action === "resume-thread" &&
597
+ !parsed.nativeThreadId)) {
598
+ return { text: formatAkkThreadsCommandResult(discovery) };
599
+ }
600
+ const expectedBindingToken = requiredString(discovery.expected_binding_token, "expected_binding_token from lifecycle discovery");
601
+ let candidateToken;
602
+ if (parsed.action === "resume-thread") {
603
+ const candidate = Array.isArray(discovery.threads)
604
+ ? discovery.threads.find((thread) => isRecord(thread) &&
605
+ stringValue(thread.native_thread_id) === parsed.nativeThreadId)
606
+ : undefined;
607
+ if (!isRecord(candidate) || candidate.resumable !== true) {
608
+ throw new Error(`native thread ${parsed.nativeThreadId} is not resumable in the current lifecycle snapshot`);
609
+ }
610
+ candidateToken = requiredString(candidate.candidate_token, "candidate_token from the selected resumable thread");
611
+ }
612
+ const mutationArgs = buildAkkCommandCliArgs(parsed, config, {
613
+ sessionKey: ctx.sessionKey,
614
+ expectedBindingToken,
615
+ candidateToken
616
+ });
617
+ if (!mutationArgs) {
618
+ throw new Error("could not build native-thread lifecycle command");
619
+ }
620
+ const transitionResult = runCli(api, mutationArgs);
621
+ return {
622
+ text: formatAkkThreadTransitionCommandResult(transitionResult),
623
+ isError: !isAkkThreadTransitionSuccess(transitionResult)
624
+ };
625
+ }
348
626
  const args = buildAkkCommandCliArgs(parsed, config, {
349
627
  sessionKey: ctx.sessionKey
350
628
  });
@@ -369,8 +647,10 @@ async function handleAkkCommand(api, ctx) {
369
647
  case "send":
370
648
  return {
371
649
  text: formatSendCommandResult(result),
372
- isError: ["uncertain", "aborted"].includes(String(result.submission_outcome ?? ""))
650
+ isError: ["uncertain", "aborted"].includes(String(result.submission_outcome ?? "")) || result.status === "delivered_unfenced"
373
651
  };
652
+ case "respond":
653
+ return formatAkkRespondCommandResult(result);
374
654
  case "approve":
375
655
  return { text: formatApproveCommandResult(result) };
376
656
  case "renew":
@@ -380,7 +660,10 @@ async function handleAkkCommand(api, ctx) {
380
660
  case "cancel":
381
661
  return { text: formatCancelCommandResult(result) };
382
662
  case "close":
383
- return { text: formatCloseCommandResult(result) };
663
+ return {
664
+ text: formatCloseCommandResult(result),
665
+ isError: isBlockedTerminalDispatchResult(result)
666
+ };
384
667
  }
385
668
  }
386
669
  catch (error) {
@@ -393,26 +676,35 @@ async function handleAkkCommand(api, ctx) {
393
676
  }
394
677
  function formatDelegateCommandResult(result) {
395
678
  const agent = executorDisplayName(result.agent);
679
+ const { sessionId, turnId } = publicTurnIdentity(result);
680
+ if (result.status === "submission_unfenced") {
681
+ return [
682
+ `AKK sent the terminal input to ${agent}, but could not bind later side effects to an exact native session.`,
683
+ `session: ${sessionId}`,
684
+ `turn: ${turnId}`,
685
+ "next: do not retry or continue automatically; inspect the named pane and close this Turn before sending more work."
686
+ ].join("\n");
687
+ }
396
688
  if (result.status === "submission_uncertain") {
397
689
  return [
398
690
  `AKK could not prove whether ${agent} received the terminal task.`,
399
- `conversation: ${result.conversation_id ?? "unknown"}`,
400
- `session: ${result.session ?? "unknown"}`,
691
+ `session: ${sessionId}`,
692
+ `turn: ${turnId}`,
401
693
  "next: do not retry automatically; inspect AKK status and the named tmux pane."
402
694
  ].join("\n");
403
695
  }
404
696
  if (result.status === "submission_aborted") {
405
697
  return [
406
698
  `AKK stopped before sending the terminal task to ${agent}.`,
407
- `conversation: ${result.conversation_id ?? "unknown"}`,
408
- `session: ${result.session ?? "unknown"}`,
699
+ `session: ${sessionId}`,
700
+ `turn: ${turnId}`,
409
701
  "next: no tmux input was sent, so this request may be retried."
410
702
  ].join("\n");
411
703
  }
412
704
  return [
413
705
  `AKK sent the task to ${agent} in the shared terminal.`,
414
- `conversation: ${result.conversation_id ?? "unknown"}`,
415
- `session: ${result.session ?? "unknown"}`,
706
+ `session: ${sessionId}`,
707
+ `turn: ${turnId}`,
416
708
  `status: ${result.conversation_status ?? result.status ?? "unknown"}`,
417
709
  "The result will return to this OpenClaw session through the callback."
418
710
  ].join("\n");
@@ -428,12 +720,13 @@ function executorDisplayName(kind) {
428
720
  function formatStatusCommandResult(result) {
429
721
  const summary = result.summary ?? result.conversation ?? result ?? {};
430
722
  const terminalStatus = isRecord(result.terminal_status) ? result.terminal_status : {};
431
- const terminalControl = isRecord(result.terminal_control) ? result.terminal_control : {};
723
+ const { sessionId, turnId } = publicTurnIdentity(result);
432
724
  const lines = [
433
- `AKK status: ${summary.conversation_id ?? result.conversation_id ?? "unknown"}`,
725
+ "AKK status:",
726
+ `session: ${sessionId}`,
727
+ `turn: ${turnId}`,
434
728
  `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"}`
729
+ `status: ${summary.status ?? terminalStatus.activity_state ?? "unknown"}`
437
730
  ];
438
731
  if (summary.request) {
439
732
  lines.push(`request: ${truncateText(summary.request, 180)}`);
@@ -477,16 +770,22 @@ function formatDoctorCommandResult(result) {
477
770
  ].join("\n");
478
771
  }
479
772
  function formatRenewCommandResult(result) {
773
+ const { sessionId, turnId } = publicTurnIdentity(result);
480
774
  return [
481
- `AKK monitoring renewed: ${result.conversation?.conversation_id ?? "unknown"}`,
775
+ "AKK monitoring renewed.",
776
+ `session: ${sessionId}`,
777
+ `turn: ${turnId}`,
482
778
  `inactivity timeout: ${result.agent_timeout_minutes ?? "unknown"} minutes`,
483
779
  `hard lifetime: ${result.agent_hard_timeout_minutes ?? "unknown"} minutes`,
484
780
  "No message or key was sent to the coding agent."
485
781
  ].join("\n");
486
782
  }
487
783
  function formatRetryCallbackCommandResult(result) {
784
+ const { sessionId, turnId } = publicTurnIdentity(result);
488
785
  return [
489
- `AKK callback delivered: ${result.conversation?.conversation_id ?? "unknown"}`,
786
+ "AKK callback delivered.",
787
+ `session: ${sessionId}`,
788
+ `turn: ${turnId}`,
490
789
  `status: ${result.conversation?.status ?? "unknown"}`,
491
790
  `attempts: ${result.conversation?.callback_delivery?.attempts ?? "unknown"}`
492
791
  ].join("\n");
@@ -494,12 +793,24 @@ function formatRetryCallbackCommandResult(result) {
494
793
  function formatSendCommandResult(result) {
495
794
  const conversation = result.conversation ?? {};
496
795
  const conversationId = conversation.conversation_id ?? result.conversation_id ?? "unknown";
796
+ const sessionId = conversation.session_id ?? result.session_id ?? conversationId;
797
+ const turnId = conversation.turn_id ?? result.turn_id ?? conversationId;
497
798
  const status = conversation.status ?? result.status ?? "unknown";
498
799
  const nextAction = isRecord(result.openclaw_next_action) ? result.openclaw_next_action : undefined;
800
+ if (result.status === "delivered_unfenced") {
801
+ return [
802
+ "AKK sent the terminal input but could not bind an exact native session.",
803
+ `session: ${sessionId}`,
804
+ `turn: ${turnId}`,
805
+ `status: ${status}`,
806
+ "next: do not retry or continue automatically; inspect the shared tmux pane and close this Turn before sending more work."
807
+ ].join("\n");
808
+ }
499
809
  if (result.submission_outcome === "uncertain") {
500
810
  return [
501
811
  "AKK terminal submission outcome is uncertain.",
502
- `conversation: ${conversationId}`,
812
+ `session: ${sessionId}`,
813
+ `turn: ${turnId}`,
503
814
  `status: ${result.status ?? status}`,
504
815
  "next: do not retry automatically; inspect AKK status and the named tmux pane."
505
816
  ].join("\n");
@@ -507,14 +818,16 @@ function formatSendCommandResult(result) {
507
818
  if (result.submission_outcome === "aborted") {
508
819
  return [
509
820
  "AKK terminal submission was aborted before tmux input.",
510
- `conversation: ${conversationId}`,
821
+ `session: ${sessionId}`,
822
+ `turn: ${turnId}`,
511
823
  `status: ${result.status ?? status}`,
512
824
  "next: this request was not sent and may be retried."
513
825
  ].join("\n");
514
826
  }
515
827
  const lines = [
516
- "AKK follow-up sent.",
517
- `conversation: ${conversationId}`,
828
+ "AKK turn sent.",
829
+ `session: ${sessionId}`,
830
+ `turn: ${turnId}`,
518
831
  `status: ${status}`
519
832
  ];
520
833
  if (result.source) {
@@ -529,54 +842,90 @@ function formatSendCommandResult(result) {
529
842
  }
530
843
  function formatCancelCommandResult(result) {
531
844
  const conversation = result.conversation ?? {};
532
- const terminalControl = isRecord(result.terminal_control) ? result.terminal_control : {};
845
+ const { sessionId, turnId } = publicTurnIdentity(result);
533
846
  return [
534
847
  "AKK cancel requested.",
535
- `conversation: ${conversation.conversation_id ?? result.conversation_id ?? "unknown"}`,
848
+ `session: ${sessionId}`,
849
+ `turn: ${turnId}`,
536
850
  `agent: ${result.executor?.kind ?? conversation.executor?.kind ?? "unknown"}`,
537
- `session: ${result.executor?.session ?? conversation.executor?.session ?? terminalControl.target ?? "unknown"}`,
538
851
  `status: ${conversation.status ?? (result.cancel_requested === true ? "cancel requested" : "not cancelled")}`
539
852
  ].join("\n");
540
853
  }
541
854
  function formatApproveCommandResult(result) {
542
855
  const conversation = result.conversation ?? {};
856
+ const { sessionId, turnId } = publicTurnIdentity(result);
543
857
  return [
544
858
  result.approved === true
545
859
  ? "AKK approved the current terminal request."
546
860
  : "AKK did not approve the terminal request.",
547
- `conversation: ${conversation.conversation_id ?? result.conversation_id ?? "unknown"}`,
861
+ `session: ${sessionId}`,
862
+ `turn: ${turnId}`,
548
863
  `status: ${conversation.status ?? "unknown"}`,
549
864
  ...(result.reason ? [`reason: ${result.reason}`] : [])
550
865
  ].join("\n");
551
866
  }
552
867
  function formatCloseCommandResult(result) {
553
- if (result.source === "terminal_control" &&
554
- result.terminal_dispatch_resolved === true) {
868
+ if (result.source === "terminal_control") {
555
869
  const terminalControl = isRecord(result.terminal_control)
556
870
  ? result.terminal_control
557
871
  : {};
872
+ if (result.terminal_dispatch_resolved !== true) {
873
+ return [
874
+ "AKK did not clear the unresolved terminal dispatch fence.",
875
+ `terminal: ${terminalControl.target ?? "unknown"}`,
876
+ ...(stringValue(result.transition_id)
877
+ ? [`transition: ${stringValue(result.transition_id)}`]
878
+ : []),
879
+ `reason: ${stringValue(result.reason) ?? "the recorded lifecycle outcome could not be verified"}`,
880
+ "This terminal remains blocked. Do not retry or continue automatically; inspect the pane and use the fresh recovery action from /akk list."
881
+ ].join("\n");
882
+ }
558
883
  return [
559
884
  "AKK cleared the unresolved terminal dispatch fence.",
560
885
  `terminal: ${terminalControl.target ?? "unknown"}`,
561
- `previous owner: ${result.owner_conversation_id ?? "unknown"}`,
886
+ ...(stringValue(result.transition_id)
887
+ ? [`transition: ${stringValue(result.transition_id)}`]
888
+ : [
889
+ `previous turn: ${result.owner_turn_id ?? result.owner_conversation_id ?? "unknown"}`
890
+ ]),
562
891
  "The coding agent and tmux pane remain open."
563
892
  ].join("\n");
564
893
  }
565
894
  const conversation = result.conversation ?? {};
895
+ const { sessionId, turnId } = publicTurnIdentity(result);
566
896
  return [
567
- "AKK session closed.",
568
- `conversation: ${conversation.conversation_id ?? "unknown"}`,
897
+ "AKK Turn record closed.",
898
+ `session: ${sessionId}`,
899
+ `turn: ${turnId}`,
569
900
  `status: ${conversation.status ?? "unknown"}`
570
901
  ].join("\n");
571
902
  }
903
+ function publicTurnIdentity(result) {
904
+ const conversation = isRecord(result.conversation) ? result.conversation : {};
905
+ const summary = isRecord(result.summary) ? result.summary : {};
906
+ const compatibilityId = stringValue(conversation.conversation_id) ??
907
+ stringValue(summary.conversation_id) ??
908
+ stringValue(result.conversation_id);
909
+ return {
910
+ sessionId: stringValue(conversation.session_id) ??
911
+ stringValue(summary.session_id) ??
912
+ stringValue(result.session_id) ??
913
+ compatibilityId ??
914
+ "unknown",
915
+ turnId: stringValue(conversation.turn_id) ??
916
+ stringValue(summary.turn_id) ??
917
+ stringValue(result.turn_id) ??
918
+ compatibilityId ??
919
+ "unknown"
920
+ };
921
+ }
572
922
  function buildStatusCliArgs(api, params) {
573
923
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
574
924
  const args = [
575
925
  "status",
576
- "--reconcile",
577
- "--conversation",
578
- requiredString(params.conversation_id, "conversation_id")
926
+ "--reconcile"
579
927
  ];
928
+ pushTurnTarget(args, params);
580
929
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
581
930
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ??
582
931
  numberString(api.pluginConfig?.idleTimeoutMinutes));
@@ -602,21 +951,37 @@ function terminalScreenExcerpt(result) {
602
951
  : `…${text.slice(-1599)}`;
603
952
  }
604
953
  async function runSendRequest(api, params, toolContext) {
605
- const selector = stringValue(params.selector);
606
- if (!selector) {
954
+ const requestedType = Object.hasOwn(params, "type")
955
+ ? stringValue(params.type)
956
+ : "task";
957
+ if (requestedType !== "task") {
958
+ throw new Error("ordinary send type must be task; use agent_knock_knock_respond for an in-flight response");
959
+ }
960
+ if (Object.hasOwn(params, "session_id") && Object.hasOwn(params, "selector")) {
961
+ throw new Error("ordinary send accepts only one of session_id or selector");
962
+ }
963
+ const sessionId = Object.hasOwn(params, "session_id")
964
+ ? authoritativeManagedId(params.session_id, "session_id")
965
+ : undefined;
966
+ const selector = Object.hasOwn(params, "selector")
967
+ ? requiredString(params.selector, "selector")
968
+ : undefined;
969
+ if (!sessionId && !selector) {
607
970
  return runDelegate(api, params, toolContext);
608
971
  }
609
972
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
610
973
  const openclawSession = stringValue(toolContext?.sessionKey) ??
611
974
  "agent:main:main";
612
975
  const args = [
613
- "send",
614
- "--conversation",
615
- selector,
616
- "--message",
617
- requiredString(params.request, "request"),
618
- "--background"
976
+ "send"
619
977
  ];
978
+ if (sessionId) {
979
+ args.push("--session", sessionId);
980
+ }
981
+ else {
982
+ args.push("--conversation", requiredString(selector, "selector"));
983
+ }
984
+ args.push("--message", requiredString(params.request, "request"), "--background");
620
985
  pushOptional(args, "--type", stringValue(params.type));
621
986
  pushOptional(args, "--store-dir", resolvePluginStoreDir(config));
622
987
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ??
@@ -631,17 +996,97 @@ async function runSendRequest(api, params, toolContext) {
631
996
  pushOptional(args, "--openclaw-bin", stringValue(config.openclawBin));
632
997
  return runCli(api, args);
633
998
  }
634
- function toolResult(result) {
999
+ function toolResult(result, { submissionErrors = false, normalizeTurnIdentity = true, forceError = false } = {}) {
1000
+ const normalized = normalizeTurnIdentity ? withTurnIdentity(result) : result;
1001
+ const submissionError = submissionErrors && isSubmissionError(normalized);
635
1002
  return {
636
1003
  content: [
637
1004
  {
638
1005
  type: "text",
639
- text: JSON.stringify(result, null, 2)
1006
+ text: JSON.stringify(normalized, null, 2)
640
1007
  }
641
1008
  ],
642
- details: result
1009
+ details: normalized,
1010
+ ...(submissionError || forceError ? { isError: true } : {})
643
1011
  };
644
1012
  }
1013
+ function isBlockedTerminalDispatchResult(result) {
1014
+ return Boolean(isRecord(result) &&
1015
+ result.source === "terminal_control" &&
1016
+ result.terminal_dispatch_resolved !== true);
1017
+ }
1018
+ function isSubmissionError(result) {
1019
+ if (!isRecord(result)) {
1020
+ return false;
1021
+ }
1022
+ return [
1023
+ "submission_unfenced",
1024
+ "submission_uncertain",
1025
+ "submission_aborted"
1026
+ ].includes(String(result.status ?? "")) ||
1027
+ ["uncertain", "aborted"].includes(String(result.submission_outcome ?? "")) ||
1028
+ result.status === "delivered_unfenced";
1029
+ }
1030
+ function withTurnIdentity(result) {
1031
+ if (!isRecord(result)) {
1032
+ return result;
1033
+ }
1034
+ const sources = [
1035
+ { label: "result", value: result },
1036
+ { label: "result.conversation", value: result.conversation },
1037
+ { label: "result.summary", value: result.summary },
1038
+ { label: "result.message", value: result.message }
1039
+ ];
1040
+ const compatibilityId = consistentResultIdentity("conversation_id", sources);
1041
+ const explicitSessionId = consistentResultIdentity("session_id", sources);
1042
+ const explicitTurnId = consistentResultIdentity("turn_id", sources);
1043
+ const hasModernIdentity = Boolean(explicitSessionId || explicitTurnId);
1044
+ if (hasModernIdentity && (!explicitSessionId || !explicitTurnId)) {
1045
+ throw new Error("agent-knock-knock CLI returned a partial session_id/turn_id identity");
1046
+ }
1047
+ if (hasModernIdentity && !compatibilityId) {
1048
+ throw new Error("agent-knock-knock CLI returned modern identity without conversation_id");
1049
+ }
1050
+ if (hasModernIdentity && compatibilityId !== explicitTurnId) {
1051
+ throw new Error("agent-knock-knock CLI returned conversation_id that differs from turn_id");
1052
+ }
1053
+ if (!hasModernIdentity && !compatibilityId) {
1054
+ return result;
1055
+ }
1056
+ if (!hasModernIdentity &&
1057
+ (compatibilityId?.startsWith("terminal:") ||
1058
+ stringValue(result.source) === "terminal" ||
1059
+ (isRecord(result.summary) &&
1060
+ stringValue(result.summary.source) === "terminal"))) {
1061
+ return result;
1062
+ }
1063
+ const sessionId = explicitSessionId ?? compatibilityId;
1064
+ const turnId = explicitTurnId ?? compatibilityId;
1065
+ return {
1066
+ ...result,
1067
+ session_id: sessionId,
1068
+ turn_id: turnId
1069
+ };
1070
+ }
1071
+ function consistentResultIdentity(field, sources) {
1072
+ const values = sources.flatMap(({ label, value }) => {
1073
+ if (!isRecord(value) || !Object.hasOwn(value, field)) {
1074
+ return [];
1075
+ }
1076
+ const identity = stringValue(value[field]);
1077
+ if (!identity) {
1078
+ throw new Error(`agent-knock-knock CLI returned invalid ${label}.${field}`);
1079
+ }
1080
+ return [{ label, identity }];
1081
+ });
1082
+ const expected = values[0];
1083
+ for (const candidate of values.slice(1)) {
1084
+ if (candidate.identity !== expected.identity) {
1085
+ throw new Error(`agent-knock-knock CLI returned conflicting ${field} between ${expected.label} and ${candidate.label}`);
1086
+ }
1087
+ }
1088
+ return expected?.identity;
1089
+ }
645
1090
  async function runDelegate(api, params, toolContext) {
646
1091
  const config = isRecord(api.pluginConfig) ? api.pluginConfig : {};
647
1092
  const request = requiredString(params.request, "request");
@@ -661,70 +1106,106 @@ async function runDelegate(api, params, toolContext) {
661
1106
  pushOptional(args, "--idle-timeout-minutes", numberString(params.idleTimeoutMinutes) ?? numberString(config.idleTimeoutMinutes));
662
1107
  pushOptional(args, "--agent-timeout-minutes", numberString(params.agentTimeoutMinutes) ?? numberString(config.agentTimeoutMinutes));
663
1108
  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;
1109
+ const parsed = withTurnIdentity(await runCliAsync(api, args));
1110
+ if (!isRecord(parsed)) {
1111
+ throw new Error("agent-knock-knock delegate returned a non-object result");
1112
+ }
1113
+ const conversationId = stringValue(parsed.conversation_id) ??
1114
+ (isRecord(parsed.conversation)
1115
+ ? stringValue(parsed.conversation.conversation_id)
1116
+ : undefined);
1117
+ const sessionId = stringValue(parsed.session_id);
1118
+ const turnId = stringValue(parsed.turn_id);
1119
+ if (!conversationId || !sessionId || !turnId) {
1120
+ throw new Error("agent-knock-knock delegate returned incomplete Turn identity");
1121
+ }
1122
+ const parsedConversation = isRecord(parsed.conversation)
1123
+ ? parsed.conversation
1124
+ : undefined;
1125
+ const parsedPaths = isRecord(parsed.paths) ? parsed.paths : undefined;
1126
+ const parsedTerminalControl = isRecord(parsed.terminal_control)
1127
+ ? parsed.terminal_control
1128
+ : undefined;
1129
+ const statePath = parsedConversation?.state_path ??
1130
+ parsedPaths?.statePath;
1131
+ const logPath = parsedConversation?.event_log_path ??
1132
+ parsedPaths?.logPath;
671
1133
  const submissionUncertain = parsed.submission_outcome === "uncertain";
672
1134
  const submissionAborted = parsed.submission_outcome === "aborted";
673
- const agent = stringValue(parsed.conversation?.executor?.kind) ??
1135
+ const submissionUnfenced = parsed.status === "delivered_unfenced";
1136
+ const agent = (isRecord(parsedConversation?.executor)
1137
+ ? stringValue(parsedConversation.executor.kind)
1138
+ : undefined) ??
674
1139
  stringValue(parsed.agent);
675
1140
  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",
1141
+ status: submissionUnfenced
1142
+ ? "submission_unfenced"
1143
+ : submissionUncertain
1144
+ ? "submission_uncertain"
1145
+ : submissionAborted
1146
+ ? "submission_aborted"
1147
+ : "async_pending",
1148
+ submission_status: submissionUnfenced
1149
+ ? "submitted_unfenced"
1150
+ : submissionUncertain
1151
+ ? "uncertain"
1152
+ : submissionAborted
1153
+ ? "aborted"
1154
+ : "accepted",
686
1155
  conversation_id: conversationId,
687
- conversation_status: parsed.conversation?.status,
1156
+ session_id: sessionId,
1157
+ turn_id: turnId,
1158
+ conversation_status: parsedConversation?.status,
688
1159
  state_path: statePath,
689
1160
  event_log_path: logPath,
690
1161
  agent,
691
- executor: parsed.conversation?.executor,
692
- session: parsed.conversation?.executor?.session ??
693
- parsed.terminal_control?.target,
1162
+ executor: parsedConversation?.executor,
1163
+ session: (isRecord(parsedConversation?.executor)
1164
+ ? parsedConversation.executor.session
1165
+ : undefined) ??
1166
+ parsedTerminalControl?.target,
694
1167
  openclaw_session: openclawSession,
695
1168
  launched: parsed.launched === true,
696
1169
  replayed: parsed.replayed === true,
697
1170
  background: parsed.background === true,
698
- pid: parsed.pid ?? parsed.terminal_control?.panePid ?? null,
1171
+ pid: parsed.pid ?? parsedTerminalControl?.panePid ?? null,
699
1172
  callback_method: CALLBACK_METHOD,
700
- ...(submissionUncertain
1173
+ ...(submissionUnfenced
701
1174
  ? {
702
- submission_outcome: "uncertain",
1175
+ submission_outcome: "submitted",
703
1176
  do_not_retry: true,
704
1177
  reason: parsed.reason,
705
1178
  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."
1179
+ 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
1180
  }
708
- : submissionAborted
1181
+ : submissionUncertain
709
1182
  ? {
710
- submission_outcome: "aborted",
711
- safe_to_retry: true,
1183
+ submission_outcome: "uncertain",
1184
+ do_not_retry: true,
712
1185
  reason: parsed.reason,
713
1186
  openclaw_next_action: parsed.openclaw_next_action,
714
- note: "AKK stopped before sending tmux input. The request was not submitted and may be retried."
1187
+ note: "AKK could not prove whether tmux accepted Enter. Do not retry automatically; inspect the exact AKK Turn record and shared terminal."
715
1188
  }
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
- })
1189
+ : submissionAborted
1190
+ ? {
1191
+ submission_outcome: "aborted",
1192
+ safe_to_retry: true,
1193
+ reason: parsed.reason,
1194
+ openclaw_next_action: parsed.openclaw_next_action,
1195
+ note: "AKK stopped before sending tmux input. The request was not submitted and may be retried."
1196
+ }
1197
+ : {
1198
+ openclaw_next_action: {
1199
+ action: "yield",
1200
+ reason: "The coding agent is working in the shared tmux terminal. End this OpenClaw turn now and wait for an Agent Knock Knock callback.",
1201
+ do_not: "Do not poll terminal internals while waiting. Further communication must use Agent Knock Knock tools so the same shared terminal remains authoritative.",
1202
+ expected_callback: "The callback will be injected and scheduled into this OpenClaw session by the agent-knock-knock.callback Gateway method."
1203
+ },
1204
+ note: "The task was sent to the shared tmux terminal. OpenClaw should yield now and wait for the scheduled callback turn."
1205
+ })
725
1206
  };
726
1207
  }
727
- function registerCliTool(api, { name, description, parameters, buildArgs }) {
1208
+ function registerCliTool(api, { name, description, parameters, buildArgs, normalizeTurnIdentity = true, isErrorResult = (_result) => false }) {
728
1209
  api.registerTool((toolContext) => ({
729
1210
  label: toolLabel(name),
730
1211
  name,
@@ -732,15 +1213,11 @@ function registerCliTool(api, { name, description, parameters, buildArgs }) {
732
1213
  parameters,
733
1214
  async execute(_toolCallId, params) {
734
1215
  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
- };
1216
+ return toolResult(result, {
1217
+ submissionErrors: name === "agent_knock_knock_respond",
1218
+ normalizeTurnIdentity,
1219
+ forceError: typeof isErrorResult === "function" && isErrorResult(result) === true
1220
+ });
744
1221
  }
745
1222
  }), { name, optional: true });
746
1223
  }
@@ -846,6 +1323,139 @@ function runCliAsync(api, cliArgs, { cwd = process.cwd(), allowNonzeroJson = fal
846
1323
  function relayPathForApi(api) {
847
1324
  return relayPathByApi.get(api) ?? defaultBinPath;
848
1325
  }
1326
+ function callbackIdentity({ params, conversation, message, messageMetadata }) {
1327
+ const messageHasSessionId = isRecord(message) &&
1328
+ Object.hasOwn(message, "session_id");
1329
+ const messageHasTurnId = isRecord(message) &&
1330
+ Object.hasOwn(message, "turn_id");
1331
+ if (messageHasSessionId !== messageHasTurnId) {
1332
+ throw new Error("modern callback messages require both session_id and turn_id");
1333
+ }
1334
+ const identityMode = messageHasSessionId ? "modern" : "legacy";
1335
+ const sources = [
1336
+ { label: "message", value: message },
1337
+ { label: "message.metadata", value: messageMetadata },
1338
+ { label: "conversation", value: conversation },
1339
+ { label: "params", value: params }
1340
+ ];
1341
+ const explicitConversationId = consistentCallbackIdentity("conversation_id", sources);
1342
+ const explicitTurnId = consistentCallbackIdentity("turn_id", sources);
1343
+ const explicitSessionId = consistentCallbackIdentity("session_id", sources);
1344
+ const hasModernIdentity = Boolean(explicitSessionId || explicitTurnId);
1345
+ if (hasModernIdentity && (!explicitSessionId || !explicitTurnId)) {
1346
+ throw new Error("modern callbacks require both session_id and turn_id");
1347
+ }
1348
+ if (hasModernIdentity && !explicitConversationId) {
1349
+ throw new Error("modern callbacks require conversation_id as the Turn Store alias");
1350
+ }
1351
+ if (hasModernIdentity && explicitConversationId !== explicitTurnId) {
1352
+ throw new Error("callback conversation_id must equal turn_id for modern callback identities");
1353
+ }
1354
+ if (!explicitConversationId) {
1355
+ throw new Error("callback identity requires session_id and turn_id, or a legacy conversation_id");
1356
+ }
1357
+ const turnId = explicitTurnId ?? explicitConversationId;
1358
+ const conversationId = explicitConversationId;
1359
+ const sessionId = explicitSessionId ?? explicitConversationId;
1360
+ return {
1361
+ conversationId,
1362
+ sessionId,
1363
+ turnId,
1364
+ identityMode
1365
+ };
1366
+ }
1367
+ function consistentCallbackIdentity(field, sources) {
1368
+ const values = sources.flatMap(({ label, value }) => {
1369
+ if (!isRecord(value) || !Object.hasOwn(value, field)) {
1370
+ return [];
1371
+ }
1372
+ const identity = stringValue(value[field]);
1373
+ if (!identity) {
1374
+ throw new Error(`callback ${label}.${field} must be a non-empty string`);
1375
+ }
1376
+ return [{ label, identity }];
1377
+ });
1378
+ const expected = values[0];
1379
+ for (const candidate of values.slice(1)) {
1380
+ if (candidate.identity !== expected.identity) {
1381
+ throw new Error(`callback ${field} mismatch between ${expected.label} and ${candidate.label}`);
1382
+ }
1383
+ }
1384
+ return expected?.identity;
1385
+ }
1386
+ function callbackSessionKey({ params, conversation, message, messageMetadata }) {
1387
+ const sessionKey = callbackStringField("params.sessionKey", params, "sessionKey");
1388
+ if (!sessionKey) {
1389
+ throw new Error("callback params.sessionKey is required");
1390
+ }
1391
+ const gatewaySession = consistentCallbackTarget("Gateway session", [
1392
+ {
1393
+ label: "conversation.gateway_session",
1394
+ owner: conversation,
1395
+ field: "gateway_session"
1396
+ },
1397
+ {
1398
+ label: "message.gateway_session",
1399
+ owner: message,
1400
+ field: "gateway_session"
1401
+ },
1402
+ {
1403
+ label: "message.metadata.gateway_session",
1404
+ owner: messageMetadata,
1405
+ field: "gateway_session"
1406
+ }
1407
+ ]);
1408
+ const openclawSession = consistentCallbackTarget("OpenClaw session", [
1409
+ {
1410
+ label: "params.openclaw_session",
1411
+ owner: params,
1412
+ field: "openclaw_session"
1413
+ },
1414
+ {
1415
+ label: "conversation.openclaw_session",
1416
+ owner: conversation,
1417
+ field: "openclaw_session"
1418
+ },
1419
+ {
1420
+ label: "message.openclaw_session",
1421
+ owner: message,
1422
+ field: "openclaw_session"
1423
+ },
1424
+ {
1425
+ label: "message.metadata.openclaw_session",
1426
+ owner: messageMetadata,
1427
+ field: "openclaw_session"
1428
+ }
1429
+ ]);
1430
+ const expectedGatewayTarget = gatewaySession ?? openclawSession;
1431
+ if (expectedGatewayTarget && expectedGatewayTarget !== sessionKey) {
1432
+ throw new Error("callback Gateway session mismatch with params.sessionKey");
1433
+ }
1434
+ return sessionKey;
1435
+ }
1436
+ function consistentCallbackTarget(label, sources) {
1437
+ const values = sources.flatMap((source) => {
1438
+ const value = callbackStringField(source.label, source.owner, source.field);
1439
+ return value ? [{ label: source.label, value }] : [];
1440
+ });
1441
+ const expected = values[0];
1442
+ for (const candidate of values.slice(1)) {
1443
+ if (candidate.value !== expected.value) {
1444
+ throw new Error(`callback ${label} mismatch between ${expected.label} and ${candidate.label}`);
1445
+ }
1446
+ }
1447
+ return expected?.value;
1448
+ }
1449
+ function callbackStringField(label, owner, field) {
1450
+ if (!isRecord(owner) || !Object.hasOwn(owner, field)) {
1451
+ return undefined;
1452
+ }
1453
+ const value = stringValue(owner[field]);
1454
+ if (!value) {
1455
+ throw new Error(`callback ${label} must be a non-empty string`);
1456
+ }
1457
+ return value;
1458
+ }
849
1459
  async function handleCallback(api, params) {
850
1460
  if (!isRecord(params)) {
851
1461
  throw new Error("callback params must be an object");
@@ -853,17 +1463,20 @@ async function handleCallback(api, params) {
853
1463
  const message = isRecord(params.message) ? params.message : undefined;
854
1464
  const conversation = isRecord(params.conversation) ? params.conversation : undefined;
855
1465
  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
1466
  if (!message) {
863
1467
  throw new Error("callback params.message is required");
864
1468
  }
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()}`;
1469
+ const sessionKey = callbackSessionKey({
1470
+ params,
1471
+ conversation,
1472
+ message,
1473
+ messageMetadata
1474
+ });
1475
+ const { conversationId, sessionId, turnId, identityMode } = callbackIdentity({ params, conversation, message, messageMetadata });
1476
+ const messageId = stringValue(message.id);
1477
+ if (!messageId) {
1478
+ throw new Error("callback message.id is required");
1479
+ }
867
1480
  const autoApproval = tryAutoApproveCallback({
868
1481
  api,
869
1482
  message,
@@ -878,6 +1491,8 @@ async function handleCallback(api, params) {
878
1491
  delivery_mode: "none",
879
1492
  session_key: sessionKey,
880
1493
  conversation_id: conversationId,
1494
+ session_id: sessionId,
1495
+ turn_id: turnId,
881
1496
  message_id: messageId,
882
1497
  message_type: stringValue(message.type) ?? "unknown",
883
1498
  auto_approved: autoApproval.approved === true,
@@ -885,16 +1500,26 @@ async function handleCallback(api, params) {
885
1500
  approval: autoApproval
886
1501
  };
887
1502
  }
888
- const formatted = formatCallbackInjection({ conversation, message, statePath: stringValue(params.statePath) });
1503
+ const formatted = formatCallbackInjection({
1504
+ message,
1505
+ sessionId,
1506
+ turnId,
1507
+ statePath: stringValue(params.statePath)
1508
+ });
1509
+ const dedupeIdentity = identityMode === "legacy"
1510
+ ? conversationId
1511
+ : `${sessionId}:${turnId}`;
889
1512
  const injection = await api.session.workflow.enqueueNextTurnInjection({
890
1513
  sessionKey,
891
1514
  text: formatted,
892
- idempotencyKey: `agent-knock-knock:${conversationId ?? "unknown"}:${messageId}`,
1515
+ idempotencyKey: `agent-knock-knock:${dedupeIdentity}:${messageId}`,
893
1516
  placement: "append_context",
894
1517
  ttlMs: 24 * 60 * 60 * 1000,
895
1518
  metadata: {
896
1519
  kind: "agent-knock-knock-callback",
897
1520
  conversation_id: conversationId,
1521
+ session_id: sessionId,
1522
+ turn_id: turnId,
898
1523
  message_id: messageId,
899
1524
  message_type: stringValue(message.type) ?? "unknown",
900
1525
  state_path: stringValue(params.statePath),
@@ -904,6 +1529,9 @@ async function handleCallback(api, params) {
904
1529
  const delivery = buildCallbackDeliveryPlan({
905
1530
  sessionKey,
906
1531
  conversationId,
1532
+ sessionId,
1533
+ turnId,
1534
+ identityMode,
907
1535
  messageId,
908
1536
  message,
909
1537
  formatted
@@ -918,6 +1546,8 @@ async function handleCallback(api, params) {
918
1546
  injection_id: injection?.id,
919
1547
  session_key: injection?.sessionKey ?? sessionKey,
920
1548
  conversation_id: conversationId,
1549
+ session_id: sessionId,
1550
+ turn_id: turnId,
921
1551
  message_id: messageId,
922
1552
  message_type: stringValue(message.type) ?? "unknown"
923
1553
  };
@@ -937,7 +1567,7 @@ function tryAutoApproveCallback({ api, message, conversationId, statePath }) {
937
1567
  }
938
1568
  return result;
939
1569
  }
940
- function buildCallbackDeliveryPlan({ sessionKey, conversationId, messageId, message, formatted }) {
1570
+ function buildCallbackDeliveryPlan({ sessionKey, conversationId, sessionId, turnId, identityMode, messageId, message, formatted }) {
941
1571
  const type = stringValue(message.type) ?? "unknown";
942
1572
  const shouldWake = message.requires_response === true ||
943
1573
  type === "question" ||
@@ -950,6 +1580,9 @@ function buildCallbackDeliveryPlan({ sessionKey, conversationId, messageId, mess
950
1580
  mode: "none"
951
1581
  };
952
1582
  }
1583
+ const dedupeIdentity = identityMode === "legacy"
1584
+ ? conversationId
1585
+ : `${sessionId}:${turnId}`;
953
1586
  return {
954
1587
  required: true,
955
1588
  mode: "chat.send",
@@ -958,27 +1591,31 @@ function buildCallbackDeliveryPlan({ sessionKey, conversationId, messageId, mess
958
1591
  message: [
959
1592
  "Continue this OpenClaw product-manager conversation from the Agent Knock Knock callback below.",
960
1593
  "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.",
1594
+ "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
1595
  "Do not poll files, processes, sessions, stdout, or stderr. Use only the structured callback payload below.",
963
1596
  "",
964
1597
  formatted
965
1598
  ].join("\n"),
966
- idempotencyKey: `agent-knock-knock-callback:${conversationId ?? "unknown"}:${messageId ?? "unknown"}`,
1599
+ idempotencyKey: `agent-knock-knock-callback:${dedupeIdentity}:${messageId}`,
967
1600
  deliver: true
968
1601
  }
969
1602
  };
970
1603
  }
971
- function formatCallbackInjection({ conversation, message, statePath }) {
972
- const conversationId = stringValue(message.conversation_id) ?? stringValue(conversation?.conversation_id) ?? "unknown";
1604
+ function formatCallbackInjection({ message, sessionId, turnId, statePath }) {
973
1605
  const type = stringValue(message.type) ?? "unknown";
974
1606
  const body = stringValue(message.body) ?? JSON.stringify(message.body ?? "");
975
1607
  const requiresResponse = message.requires_response === true ? "yes" : "no";
976
1608
  const round = typeof message.round === "number" ? String(message.round) : "unknown";
977
1609
  const stateLine = statePath ? `State: ${statePath}\n` : "";
978
- const shortcuts = type === "done" ? formatDoneShortcuts(conversationId) : "";
1610
+ const shortcuts = type === "done"
1611
+ ? formatDoneShortcuts(sessionId, turnId)
1612
+ : message.requires_response === true || type === "question" || type === "blocked"
1613
+ ? formatRespondShortcut(turnId)
1614
+ : "";
979
1615
  return [
980
1616
  "[Agent Knock Knock callback]",
981
- `Conversation: ${conversationId}`,
1617
+ `Session: ${sessionId}`,
1618
+ `Turn: ${turnId}`,
982
1619
  `Message type: ${type}`,
983
1620
  `Requires OpenClaw response: ${requiresResponse}`,
984
1621
  `Round: ${round}`,
@@ -988,17 +1625,50 @@ function formatCallbackInjection({ conversation, message, statePath }) {
988
1625
  shortcuts
989
1626
  ].filter((line) => line !== "").join("\n");
990
1627
  }
991
- function formatDoneShortcuts(conversationId) {
1628
+ function formatDoneShortcuts(sessionId, turnId) {
992
1629
  return [
993
1630
  "",
994
1631
  "[AKK convenience commands]",
995
1632
  "When summarizing this result to the user, include these short next-step commands:",
996
1633
  "- `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.`,
1634
+ `- Use \`agent_knock_knock_send\` with \`session_id: ${JSON.stringify(sessionId)}\` to start a later turn in the same coding-agent context.`,
1635
+ `- Use \`agent_knock_knock_status\` with \`turn_id: ${JSON.stringify(turnId)}\` to inspect this exact turn.`,
999
1636
  "- AKK never starts or closes the coding agent or tmux pane."
1000
1637
  ].join("\n");
1001
1638
  }
1639
+ function formatRespondShortcut(turnId) {
1640
+ return [
1641
+ "",
1642
+ "[AKK response command]",
1643
+ `- Use \`agent_knock_knock_respond\` with \`turn_id: ${JSON.stringify(turnId)}\` and your decision in \`request\`. Do not use ordinary send for this response.`
1644
+ ].join("\n");
1645
+ }
1646
+ function pushTurnTarget(args, params) {
1647
+ if (Object.hasOwn(params, "turn_id") && Object.hasOwn(params, "conversation_id")) {
1648
+ throw new Error("turn-target tools accept only one of turn_id or conversation_id");
1649
+ }
1650
+ const turnId = stringValue(params.turn_id);
1651
+ if (turnId) {
1652
+ args.push("--turn", authoritativeManagedId(turnId, "turn_id"));
1653
+ return;
1654
+ }
1655
+ args.push("--conversation", requiredString(params.conversation_id, "turn_id"));
1656
+ }
1657
+ function authoritativeManagedId(value, name) {
1658
+ const id = requiredString(value, name).trim();
1659
+ if (/^(?:only|latest|codex|claude|(?:codex|claude):latest)$/iu.test(id) ||
1660
+ /^@[0-9a-f]+$/iu.test(id) ||
1661
+ /^terminal:/iu.test(id)) {
1662
+ throw new Error(`${name} must be an authoritative managed id, not a discovery selector or terminal id`);
1663
+ }
1664
+ return id;
1665
+ }
1666
+ function assertExclusiveRecoveryFence(params) {
1667
+ if (stringValue(params.expected_message_id) &&
1668
+ stringValue(params.expected_transition_id)) {
1669
+ throw new Error("close accepts only one of expected_message_id or expected_transition_id");
1670
+ }
1671
+ }
1002
1672
  function pushOptional(args, flag, value) {
1003
1673
  if (value !== undefined && value !== "") {
1004
1674
  args.push(flag, value);