@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.
- package/CHANGELOG.md +42 -0
- package/README.md +45 -17
- package/dist/src/agent-session-provider.d.ts +13 -0
- package/dist/src/claude-local-transcript-provider.d.ts +29 -1
- package/dist/src/claude-local-transcript-provider.js +313 -0
- package/dist/src/claude-local-transcript-provider.js.map +1 -1
- package/dist/src/claude-terminal-agent-adapter.d.ts +6 -1
- package/dist/src/claude-terminal-agent-adapter.js +178 -0
- package/dist/src/claude-terminal-agent-adapter.js.map +1 -1
- package/dist/src/cli.js +5671 -317
- package/dist/src/cli.js.map +1 -1
- package/dist/src/codex-local-session-provider.d.ts +3 -1
- package/dist/src/codex-local-session-provider.js +17 -0
- package/dist/src/codex-local-session-provider.js.map +1 -1
- package/dist/src/codex-store-adapter.d.ts +26 -1
- package/dist/src/codex-store-adapter.js +575 -2
- package/dist/src/codex-store-adapter.js.map +1 -1
- package/dist/src/codex-terminal-agent-adapter.d.ts +6 -1
- package/dist/src/codex-terminal-agent-adapter.js +192 -0
- package/dist/src/codex-terminal-agent-adapter.js.map +1 -1
- package/dist/src/managed-session.d.ts +163 -0
- package/dist/src/managed-session.js +873 -0
- package/dist/src/managed-session.js.map +1 -0
- package/dist/src/native-thread-lifecycle-policy.d.ts +57 -0
- package/dist/src/native-thread-lifecycle-policy.js +116 -0
- package/dist/src/native-thread-lifecycle-policy.js.map +1 -0
- package/dist/src/openclaw-plugin-helpers.d.ts +45 -12
- package/dist/src/openclaw-plugin-helpers.js +386 -47
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +814 -144
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/protocol.d.ts +28 -1
- package/dist/src/protocol.js +93 -4
- package/dist/src/protocol.js.map +1 -1
- package/dist/src/session-store.d.ts +57 -0
- package/dist/src/session-store.js +529 -0
- package/dist/src/session-store.js.map +1 -0
- package/dist/src/store.d.ts +5 -3
- package/dist/src/store.js +422 -17
- package/dist/src/store.js.map +1 -1
- package/dist/src/terminal-agent-adapter.d.ts +175 -0
- package/dist/src/terminal-agent-adapter.js +12 -0
- package/dist/src/terminal-agent-adapter.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +9 -0
- package/dist/src/terminal-agent-bridge.js +17 -1
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-control-provider.d.ts +2 -0
- package/dist/src/terminal-control-provider.js +1 -0
- package/dist/src/terminal-control-provider.js.map +1 -1
- package/docs/quickstart-tmux.md +23 -2
- package/openclaw.plugin.json +19 -3
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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: ["
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: ["
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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: "
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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
|
|
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"
|
|
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 {
|
|
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
|
-
`
|
|
400
|
-
`
|
|
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
|
-
`
|
|
408
|
-
`
|
|
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
|
-
`
|
|
415
|
-
`
|
|
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
|
|
723
|
+
const { sessionId, turnId } = publicTurnIdentity(result);
|
|
432
724
|
const lines = [
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
`
|
|
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
|
|
517
|
-
`
|
|
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
|
|
845
|
+
const { sessionId, turnId } = publicTurnIdentity(result);
|
|
533
846
|
return [
|
|
534
847
|
"AKK cancel requested.",
|
|
535
|
-
`
|
|
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
|
-
`
|
|
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
|
-
|
|
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
|
|
568
|
-
`
|
|
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
|
|
606
|
-
|
|
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(
|
|
1006
|
+
text: JSON.stringify(normalized, null, 2)
|
|
640
1007
|
}
|
|
641
1008
|
],
|
|
642
|
-
details:
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
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
|
|
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:
|
|
677
|
-
? "
|
|
678
|
-
:
|
|
679
|
-
? "
|
|
680
|
-
:
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
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
|
-
|
|
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:
|
|
692
|
-
session:
|
|
693
|
-
|
|
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 ??
|
|
1171
|
+
pid: parsed.pid ?? parsedTerminalControl?.panePid ?? null,
|
|
699
1172
|
callback_method: CALLBACK_METHOD,
|
|
700
|
-
...(
|
|
1173
|
+
...(submissionUnfenced
|
|
701
1174
|
? {
|
|
702
|
-
submission_outcome: "
|
|
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
|
|
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
|
-
:
|
|
1181
|
+
: submissionUncertain
|
|
709
1182
|
? {
|
|
710
|
-
submission_outcome: "
|
|
711
|
-
|
|
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
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
866
|
-
|
|
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({
|
|
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:${
|
|
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
|
|
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:${
|
|
1599
|
+
idempotencyKey: `agent-knock-knock-callback:${dedupeIdentity}:${messageId}`,
|
|
967
1600
|
deliver: true
|
|
968
1601
|
}
|
|
969
1602
|
};
|
|
970
1603
|
}
|
|
971
|
-
function formatCallbackInjection({
|
|
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"
|
|
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
|
-
`
|
|
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(
|
|
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
|
-
|
|
998
|
-
`- \`
|
|
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);
|