@scotthuang/agent-knock-knock 0.11.6 → 0.12.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 +30 -0
- package/README.md +21 -15
- package/dist/src/cli-core.d.ts +48 -0
- package/dist/src/cli-core.js +20089 -0
- package/dist/src/cli-core.js.map +1 -0
- package/dist/src/cli.js +12 -19719
- package/dist/src/cli.js.map +1 -1
- package/dist/src/doctor-capabilities.d.ts +10 -2
- package/dist/src/doctor-capabilities.js +48 -7
- package/dist/src/doctor-capabilities.js.map +1 -1
- package/dist/src/herdr-terminal-control-provider.d.ts +115 -0
- package/dist/src/herdr-terminal-control-provider.js +961 -0
- package/dist/src/herdr-terminal-control-provider.js.map +1 -0
- package/dist/src/managed-session.d.ts +15 -0
- package/dist/src/managed-session.js +142 -15
- package/dist/src/managed-session.js.map +1 -1
- package/dist/src/native-thread-resume-snapshot.d.ts +13 -3
- package/dist/src/native-thread-resume-snapshot.js +105 -16
- package/dist/src/native-thread-resume-snapshot.js.map +1 -1
- package/dist/src/openclaw-plugin-helpers.js +4 -4
- package/dist/src/openclaw-plugin-helpers.js.map +1 -1
- package/dist/src/openclaw-plugin.js +28 -26
- package/dist/src/openclaw-plugin.js.map +1 -1
- package/dist/src/session-store.js +5 -0
- package/dist/src/session-store.js.map +1 -1
- package/dist/src/terminal-agent-adapter.d.ts +5 -16
- package/dist/src/terminal-agent-adapter.js +10 -6
- package/dist/src/terminal-agent-adapter.js.map +1 -1
- package/dist/src/terminal-agent-bridge.d.ts +11 -1
- package/dist/src/terminal-agent-bridge.js +199 -86
- package/dist/src/terminal-agent-bridge.js.map +1 -1
- package/dist/src/terminal-control-provider.d.ts +60 -21
- package/dist/src/terminal-control-provider.js +452 -30
- package/dist/src/terminal-control-provider.js.map +1 -1
- package/dist/src/terminal-control-ref.d.ts +141 -0
- package/dist/src/terminal-control-ref.js +557 -0
- package/dist/src/terminal-control-ref.js.map +1 -0
- package/docs/quickstart-herdr.md +37 -0
- package/openclaw.plugin.json +2 -2
- package/package.json +4 -2
- package/templates/openclaw-skills/agent-knock-knock/SKILL.md +17 -17
|
@@ -2,6 +2,163 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { spawnSync } from "node:child_process";
|
|
3
3
|
import fs from "node:fs";
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { createTerminalEndpointRef, sameTerminalEndpointIdentity, terminalControlWithCapabilities, terminalEndpointFromControlRef, tmuxTerminalRouteKey } from "./terminal-control-ref.js";
|
|
6
|
+
export class TerminalControlProviderRegistry {
|
|
7
|
+
providers = new Map();
|
|
8
|
+
facade;
|
|
9
|
+
constructor(providers = []) {
|
|
10
|
+
for (const provider of providers) {
|
|
11
|
+
this.register(provider);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
register(provider) {
|
|
15
|
+
if (!provider.kind.trim()) {
|
|
16
|
+
throw new Error("terminal control provider kind is required");
|
|
17
|
+
}
|
|
18
|
+
if (this.providers.has(provider.kind)) {
|
|
19
|
+
throw new Error(`terminal control provider is already registered for ${provider.kind}`);
|
|
20
|
+
}
|
|
21
|
+
this.providers.set(provider.kind, provider);
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
get(kind) {
|
|
25
|
+
return this.providers.get(kind);
|
|
26
|
+
}
|
|
27
|
+
require(kind) {
|
|
28
|
+
const provider = this.get(kind);
|
|
29
|
+
if (!provider) {
|
|
30
|
+
throw new Error(`terminal control provider is not registered for ${kind || "<empty>"}`);
|
|
31
|
+
}
|
|
32
|
+
return provider;
|
|
33
|
+
}
|
|
34
|
+
list() {
|
|
35
|
+
return [...this.providers.values()];
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Exposes all registered providers through the same provider boundary used
|
|
39
|
+
* by the terminal bridge. Discovery is aggregated, while every operation on
|
|
40
|
+
* an existing endpoint is dispatched only to that endpoint's provider.
|
|
41
|
+
*
|
|
42
|
+
* The facade advertises only capabilities shared by every registered
|
|
43
|
+
* provider. This keeps generic preflight checks conservative; the selected
|
|
44
|
+
* provider still performs its own endpoint-specific checks when dispatched.
|
|
45
|
+
*/
|
|
46
|
+
asProvider() {
|
|
47
|
+
this.facade ??= new RegistryTerminalControlProvider(() => this.list());
|
|
48
|
+
return this.facade;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class RegistryTerminalControlProvider {
|
|
52
|
+
registeredProviders;
|
|
53
|
+
kind = "registry";
|
|
54
|
+
discoveryErrors = new Map();
|
|
55
|
+
constructor(registeredProviders) {
|
|
56
|
+
this.registeredProviders = registeredProviders;
|
|
57
|
+
}
|
|
58
|
+
get supportedCapabilities() {
|
|
59
|
+
return intersectRegisteredCapabilities(this.registeredProviders(), (provider) => provider.supportedCapabilities);
|
|
60
|
+
}
|
|
61
|
+
get providerCapabilities() {
|
|
62
|
+
return intersectRegisteredCapabilities(this.registeredProviders(), (provider) => provider.providerCapabilities);
|
|
63
|
+
}
|
|
64
|
+
async diagnostics() {
|
|
65
|
+
const providers = this.registeredProviders();
|
|
66
|
+
const diagnostics = await Promise.all(providers.map(async (provider) => {
|
|
67
|
+
try {
|
|
68
|
+
return [provider.kind, await provider.diagnostics()];
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
return [provider.kind, {
|
|
72
|
+
provider: provider.kind,
|
|
73
|
+
status: "error",
|
|
74
|
+
error: terminalProviderErrorMessage(error)
|
|
75
|
+
}];
|
|
76
|
+
}
|
|
77
|
+
}));
|
|
78
|
+
return {
|
|
79
|
+
provider: this.kind,
|
|
80
|
+
providerKinds: providers.map((provider) => provider.kind),
|
|
81
|
+
providers: Object.fromEntries(diagnostics),
|
|
82
|
+
discoveryErrors: Object.fromEntries(this.discoveryErrors)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async listTerminals() {
|
|
86
|
+
const batches = await Promise.all(this.registeredProviders().map(async (provider) => {
|
|
87
|
+
try {
|
|
88
|
+
const terminals = await provider.listTerminals();
|
|
89
|
+
for (const terminal of terminals) {
|
|
90
|
+
assertEndpointKind(terminal, provider.kind);
|
|
91
|
+
}
|
|
92
|
+
this.discoveryErrors.delete(provider.kind);
|
|
93
|
+
return terminals;
|
|
94
|
+
}
|
|
95
|
+
catch (error) {
|
|
96
|
+
// A provider that cannot prove its own endpoints must contribute no
|
|
97
|
+
// candidates, but it must not make a different healthy transport
|
|
98
|
+
// disappear. Keep the last failure visible through diagnostics.
|
|
99
|
+
this.discoveryErrors.set(provider.kind, terminalProviderErrorMessage(error));
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
}));
|
|
103
|
+
return batches.flat();
|
|
104
|
+
}
|
|
105
|
+
endpoint(terminalControl) {
|
|
106
|
+
const provider = this.requireProvider(terminalControl.kind);
|
|
107
|
+
const terminal = provider.endpoint(terminalControl);
|
|
108
|
+
assertEndpointKind(terminal, provider.kind);
|
|
109
|
+
return terminal;
|
|
110
|
+
}
|
|
111
|
+
toControlRef(terminal, capabilities) {
|
|
112
|
+
const provider = this.providerForEndpoint(terminal);
|
|
113
|
+
const control = provider.toControlRef(terminal, capabilities);
|
|
114
|
+
if (control.kind !== provider.kind) {
|
|
115
|
+
throw new Error(`terminal control provider ${provider.kind} returned control for ` +
|
|
116
|
+
`${control.kind}`);
|
|
117
|
+
}
|
|
118
|
+
return control;
|
|
119
|
+
}
|
|
120
|
+
async resolve(terminal) {
|
|
121
|
+
const provider = this.providerForEndpoint(terminal);
|
|
122
|
+
const resolved = await provider.resolve(terminal);
|
|
123
|
+
assertEndpointKind(resolved, provider.kind);
|
|
124
|
+
return resolved;
|
|
125
|
+
}
|
|
126
|
+
containsProcess(terminal, process, processes) {
|
|
127
|
+
return this.providerForEndpoint(terminal).containsProcess(terminal, process, processes);
|
|
128
|
+
}
|
|
129
|
+
capture(terminal, options = {}) {
|
|
130
|
+
return this.providerForEndpoint(terminal).capture(terminal, options);
|
|
131
|
+
}
|
|
132
|
+
sendText(terminal, text) {
|
|
133
|
+
return this.providerForEndpoint(terminal).sendText(terminal, text);
|
|
134
|
+
}
|
|
135
|
+
sendKeys(terminal, keys) {
|
|
136
|
+
return this.providerForEndpoint(terminal).sendKeys(terminal, keys);
|
|
137
|
+
}
|
|
138
|
+
providerForEndpoint(terminal) {
|
|
139
|
+
return this.requireProvider(terminal.identity.providerKind);
|
|
140
|
+
}
|
|
141
|
+
requireProvider(kind) {
|
|
142
|
+
const provider = this.registeredProviders().find((candidate) => candidate.kind === kind);
|
|
143
|
+
if (!provider) {
|
|
144
|
+
throw new Error(`terminal control provider is not registered for ${kind || "<empty>"}`);
|
|
145
|
+
}
|
|
146
|
+
return provider;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function terminalProviderErrorMessage(error) {
|
|
150
|
+
return error instanceof Error ? error.message : String(error);
|
|
151
|
+
}
|
|
152
|
+
function intersectRegisteredCapabilities(providers, capabilities) {
|
|
153
|
+
const [first, ...rest] = providers;
|
|
154
|
+
if (!first) {
|
|
155
|
+
return [];
|
|
156
|
+
}
|
|
157
|
+
return [...new Set(capabilities(first))].filter((capability) => rest.every((provider) => capabilities(provider).includes(capability)));
|
|
158
|
+
}
|
|
159
|
+
export function createTerminalControlProviderRegistry(providers = []) {
|
|
160
|
+
return new TerminalControlProviderRegistry(providers);
|
|
161
|
+
}
|
|
5
162
|
/**
|
|
6
163
|
* A provider-level failure proving that no terminal input operation succeeded.
|
|
7
164
|
* Unknown/time-out outcomes must use an ordinary Error instead.
|
|
@@ -14,6 +171,23 @@ export class TerminalControlInputNotSentError extends Error {
|
|
|
14
171
|
}
|
|
15
172
|
}
|
|
16
173
|
export class TmuxTerminalControlProvider {
|
|
174
|
+
kind = "tmux";
|
|
175
|
+
supportedCapabilities = [
|
|
176
|
+
"screen_status",
|
|
177
|
+
"send_keys",
|
|
178
|
+
"terminal_approval",
|
|
179
|
+
"screen_completion",
|
|
180
|
+
"durable_completion",
|
|
181
|
+
"terminal_cancel"
|
|
182
|
+
];
|
|
183
|
+
providerCapabilities = [
|
|
184
|
+
"screen_capture",
|
|
185
|
+
"ansi_capture",
|
|
186
|
+
"text_delivery",
|
|
187
|
+
"key_delivery",
|
|
188
|
+
"process_inspection",
|
|
189
|
+
"stable_resource_resolution"
|
|
190
|
+
];
|
|
17
191
|
runCommand;
|
|
18
192
|
socketPaths;
|
|
19
193
|
commands;
|
|
@@ -25,6 +199,35 @@ export class TmuxTerminalControlProvider {
|
|
|
25
199
|
async listPanes() {
|
|
26
200
|
return (await this.diagnose()).panes;
|
|
27
201
|
}
|
|
202
|
+
async listTerminals() {
|
|
203
|
+
return (await this.listPanes()).map((pane) => tmuxEndpointFromPane(pane, this.supportedCapabilities));
|
|
204
|
+
}
|
|
205
|
+
endpoint(terminalControl) {
|
|
206
|
+
if (terminalControl.kind !== this.kind) {
|
|
207
|
+
throw new Error(`terminal control provider ${this.kind} cannot resolve ${terminalControl.kind}`);
|
|
208
|
+
}
|
|
209
|
+
return terminalEndpointFromControlRef(terminalControl);
|
|
210
|
+
}
|
|
211
|
+
toControlRef(terminal, capabilities = terminal.capabilities) {
|
|
212
|
+
const control = tmuxControlFromEndpoint(terminal);
|
|
213
|
+
return terminalControlWithCapabilities(control, intersectCapabilities(capabilities, this.supportedCapabilities));
|
|
214
|
+
}
|
|
215
|
+
async resolve(terminal) {
|
|
216
|
+
assertProviderCapability(this.kind, this.providerCapabilities, "stable_resource_resolution");
|
|
217
|
+
assertEndpointKind(terminal, this.kind);
|
|
218
|
+
const matches = (await this.listTerminals()).filter((candidate) => sameTerminalEndpointIdentity(candidate, terminal) ||
|
|
219
|
+
legacyTmuxEndpointMatches(candidate, terminal));
|
|
220
|
+
if (matches.length !== 1) {
|
|
221
|
+
throw new Error(matches.length === 0
|
|
222
|
+
? `terminal resource ${terminal.route.label} is no longer available`
|
|
223
|
+
: `terminal resource ${terminal.route.label} is ambiguous`);
|
|
224
|
+
}
|
|
225
|
+
return endpointWithCapabilities(matches[0], intersectCapabilities(terminal.capabilities, this.supportedCapabilities));
|
|
226
|
+
}
|
|
227
|
+
containsProcess(terminal, process, processes) {
|
|
228
|
+
assertProviderCapability(this.kind, this.providerCapabilities, "process_inspection");
|
|
229
|
+
return terminalPaneContainsProcess(process, tmuxPaneFromEndpoint(terminal), processes);
|
|
230
|
+
}
|
|
28
231
|
async diagnose() {
|
|
29
232
|
const panes = [];
|
|
30
233
|
const diagnosticAttempts = [];
|
|
@@ -36,7 +239,7 @@ export class TmuxTerminalControlProvider {
|
|
|
36
239
|
"list-panes",
|
|
37
240
|
"-a",
|
|
38
241
|
"-F",
|
|
39
|
-
"#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}"
|
|
242
|
+
"#{session_name}\t#{window_index}\t#{pane_index}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{socket_path}\t#{pane_id}"
|
|
40
243
|
]));
|
|
41
244
|
const parsedPanes = result.status === 0 ? parseTmuxListPanes(result.stdout, socketPath) : [];
|
|
42
245
|
diagnosticAttempts.push({
|
|
@@ -53,9 +256,12 @@ export class TmuxTerminalControlProvider {
|
|
|
53
256
|
continue;
|
|
54
257
|
}
|
|
55
258
|
for (const pane of parsedPanes) {
|
|
56
|
-
// A default-server query and its discovered socket path can describe
|
|
57
|
-
// same pane.
|
|
58
|
-
|
|
259
|
+
// A default-server query and its discovered socket path can describe
|
|
260
|
+
// the same pane. Prefer tmux's server-scoped stable pane identity; the
|
|
261
|
+
// complete legacy route+PID tuple is the safe fallback for old tmux.
|
|
262
|
+
const key = pane.serverSocketPath && pane.paneId
|
|
263
|
+
? `${pane.serverSocketPath}\t${pane.paneId}`
|
|
264
|
+
: `${pane.target}\t${pane.panePid}`;
|
|
59
265
|
if (seenTargets.has(key)) {
|
|
60
266
|
continue;
|
|
61
267
|
}
|
|
@@ -72,11 +278,17 @@ export class TmuxTerminalControlProvider {
|
|
|
72
278
|
panes
|
|
73
279
|
};
|
|
74
280
|
}
|
|
75
|
-
async
|
|
281
|
+
async diagnostics() {
|
|
282
|
+
return this.diagnose();
|
|
283
|
+
}
|
|
284
|
+
async capture(terminal, options = {}) {
|
|
285
|
+
assertTerminalCapability(terminal, "screen_status", this.supportedCapabilities);
|
|
286
|
+
assertProviderCapability(this.kind, this.providerCapabilities, options.preserveEscapes ? "ansi_capture" : "screen_capture");
|
|
287
|
+
const { target, socketPath } = tmuxIoRoute(terminal);
|
|
76
288
|
const scrollbackLines = Math.max(0, Math.floor(options.scrollbackLines ?? 200));
|
|
77
289
|
let lastResult;
|
|
78
290
|
for (const command of this.commands) {
|
|
79
|
-
const result = this.runCommand(command, tmuxArgs(
|
|
291
|
+
const result = this.runCommand(command, tmuxArgs(socketPath, [
|
|
80
292
|
"capture-pane",
|
|
81
293
|
...(options.preserveEscapes ? ["-e"] : []),
|
|
82
294
|
"-t",
|
|
@@ -92,10 +304,13 @@ export class TmuxTerminalControlProvider {
|
|
|
92
304
|
}
|
|
93
305
|
throw new Error(lastResult?.stderr || lastResult?.error?.message || `tmux capture-pane failed for ${target}`);
|
|
94
306
|
}
|
|
95
|
-
async sendKeys(
|
|
307
|
+
async sendKeys(terminal, keys) {
|
|
308
|
+
assertTerminalCapability(terminal, "send_keys", this.supportedCapabilities);
|
|
309
|
+
assertInputProviderCapability(this.kind, this.providerCapabilities, "key_delivery");
|
|
310
|
+
const { target, socketPath } = tmuxIoRoute(terminal);
|
|
96
311
|
let lastResult;
|
|
97
312
|
for (const command of this.commands) {
|
|
98
|
-
const result = this.runCommand(command, tmuxArgs(
|
|
313
|
+
const result = this.runCommand(command, tmuxArgs(socketPath, ["send-keys", "-t", target, ...keys]));
|
|
99
314
|
if (result.status === 0) {
|
|
100
315
|
return;
|
|
101
316
|
}
|
|
@@ -116,12 +331,15 @@ export class TmuxTerminalControlProvider {
|
|
|
116
331
|
throw new TerminalControlInputNotSentError(lastResult?.stderr || lastResult?.error?.message ||
|
|
117
332
|
`tmux send-keys failed for ${target}`);
|
|
118
333
|
}
|
|
119
|
-
async sendText(
|
|
334
|
+
async sendText(terminal, text) {
|
|
335
|
+
assertTerminalCapability(terminal, "send_keys", this.supportedCapabilities);
|
|
336
|
+
assertInputProviderCapability(this.kind, this.providerCapabilities, "text_delivery");
|
|
337
|
+
const { target, socketPath } = tmuxIoRoute(terminal);
|
|
120
338
|
let lastResult;
|
|
121
339
|
for (const command of this.commands) {
|
|
122
340
|
if (/[\r\n]/u.test(text)) {
|
|
123
341
|
const bufferName = `akk-${process.pid}-${randomUUID()}`;
|
|
124
|
-
const setBuffer = this.runCommand(command, tmuxArgs(
|
|
342
|
+
const setBuffer = this.runCommand(command, tmuxArgs(socketPath, [
|
|
125
343
|
"set-buffer",
|
|
126
344
|
"-b",
|
|
127
345
|
bufferName,
|
|
@@ -132,7 +350,7 @@ export class TmuxTerminalControlProvider {
|
|
|
132
350
|
lastResult = setBuffer;
|
|
133
351
|
continue;
|
|
134
352
|
}
|
|
135
|
-
const pasteBuffer = this.runCommand(command, tmuxArgs(
|
|
353
|
+
const pasteBuffer = this.runCommand(command, tmuxArgs(socketPath, [
|
|
136
354
|
"paste-buffer",
|
|
137
355
|
"-p",
|
|
138
356
|
"-d",
|
|
@@ -144,12 +362,12 @@ export class TmuxTerminalControlProvider {
|
|
|
144
362
|
if (pasteBuffer.status === 0) {
|
|
145
363
|
return;
|
|
146
364
|
}
|
|
147
|
-
this.runCommand(command, tmuxArgs(
|
|
365
|
+
this.runCommand(command, tmuxArgs(socketPath, [
|
|
148
366
|
"delete-buffer",
|
|
149
367
|
"-b",
|
|
150
368
|
bufferName
|
|
151
369
|
]));
|
|
152
|
-
if (pasteBuffer.status
|
|
370
|
+
if (pasteBuffer.status !== null ||
|
|
153
371
|
!commandDefinitelyDidNotStart(pasteBuffer.error)) {
|
|
154
372
|
throw new Error(pasteBuffer.stderr || pasteBuffer.error?.message ||
|
|
155
373
|
`tmux terminal paste outcome is uncertain for ${target}`);
|
|
@@ -157,7 +375,7 @@ export class TmuxTerminalControlProvider {
|
|
|
157
375
|
lastResult = pasteBuffer;
|
|
158
376
|
continue;
|
|
159
377
|
}
|
|
160
|
-
const result = this.runCommand(command, tmuxArgs(
|
|
378
|
+
const result = this.runCommand(command, tmuxArgs(socketPath, ["send-keys", "-t", target, "-l", text]));
|
|
161
379
|
if (result.status === 0) {
|
|
162
380
|
return;
|
|
163
381
|
}
|
|
@@ -180,6 +398,23 @@ function commandDefinitelyDidNotStart(error) {
|
|
|
180
398
|
return code === "ENOENT" || code === "EACCES" || code === "ENOTDIR";
|
|
181
399
|
}
|
|
182
400
|
export class StaticTerminalControlProvider {
|
|
401
|
+
kind = "tmux";
|
|
402
|
+
supportedCapabilities = [
|
|
403
|
+
"screen_status",
|
|
404
|
+
"send_keys",
|
|
405
|
+
"terminal_approval",
|
|
406
|
+
"screen_completion",
|
|
407
|
+
"durable_completion",
|
|
408
|
+
"terminal_cancel"
|
|
409
|
+
];
|
|
410
|
+
providerCapabilities = [
|
|
411
|
+
"screen_capture",
|
|
412
|
+
"ansi_capture",
|
|
413
|
+
"text_delivery",
|
|
414
|
+
"key_delivery",
|
|
415
|
+
"process_inspection",
|
|
416
|
+
"stable_resource_resolution"
|
|
417
|
+
];
|
|
183
418
|
panes;
|
|
184
419
|
screens;
|
|
185
420
|
sentKeys = [];
|
|
@@ -190,13 +425,56 @@ export class StaticTerminalControlProvider {
|
|
|
190
425
|
async listPanes() {
|
|
191
426
|
return this.panes;
|
|
192
427
|
}
|
|
193
|
-
async
|
|
428
|
+
async diagnostics() {
|
|
429
|
+
return {
|
|
430
|
+
provider: "static",
|
|
431
|
+
paneCount: (await this.listTerminals()).length
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
async listTerminals() {
|
|
435
|
+
return this.panes.map((pane) => tmuxEndpointFromPane(pane, this.supportedCapabilities));
|
|
436
|
+
}
|
|
437
|
+
endpoint(terminalControl) {
|
|
438
|
+
if (terminalControl.kind !== this.kind) {
|
|
439
|
+
throw new Error(`terminal control provider ${this.kind} cannot resolve ${terminalControl.kind}`);
|
|
440
|
+
}
|
|
441
|
+
return terminalEndpointFromControlRef(terminalControl);
|
|
442
|
+
}
|
|
443
|
+
toControlRef(terminal, capabilities = terminal.capabilities) {
|
|
444
|
+
return terminalControlWithCapabilities(tmuxControlFromEndpoint(terminal), intersectCapabilities(capabilities, this.supportedCapabilities));
|
|
445
|
+
}
|
|
446
|
+
async resolve(terminal) {
|
|
447
|
+
assertProviderCapability(this.kind, this.providerCapabilities, "stable_resource_resolution");
|
|
448
|
+
assertEndpointKind(terminal, this.kind);
|
|
449
|
+
const matches = (await this.listTerminals()).filter((candidate) => sameTerminalEndpointIdentity(candidate, terminal) ||
|
|
450
|
+
legacyTmuxEndpointMatches(candidate, terminal));
|
|
451
|
+
if (matches.length !== 1) {
|
|
452
|
+
throw new Error(matches.length === 0
|
|
453
|
+
? `terminal resource ${terminal.route.label} is no longer available`
|
|
454
|
+
: `terminal resource ${terminal.route.label} is ambiguous`);
|
|
455
|
+
}
|
|
456
|
+
return endpointWithCapabilities(matches[0], intersectCapabilities(terminal.capabilities, this.supportedCapabilities));
|
|
457
|
+
}
|
|
458
|
+
containsProcess(terminal, process, processes) {
|
|
459
|
+
assertProviderCapability(this.kind, this.providerCapabilities, "process_inspection");
|
|
460
|
+
return terminalPaneContainsProcess(process, tmuxPaneFromEndpoint(terminal), processes);
|
|
461
|
+
}
|
|
462
|
+
async capture(terminal) {
|
|
463
|
+
assertTerminalCapability(terminal, "screen_status", this.supportedCapabilities);
|
|
464
|
+
assertProviderCapability(this.kind, this.providerCapabilities, "screen_capture");
|
|
465
|
+
const { target } = tmuxControlFromEndpoint(terminal);
|
|
194
466
|
return this.screens.get(target) ?? "";
|
|
195
467
|
}
|
|
196
|
-
async sendText(
|
|
468
|
+
async sendText(terminal, text) {
|
|
469
|
+
assertTerminalCapability(terminal, "send_keys", this.supportedCapabilities);
|
|
470
|
+
assertInputProviderCapability(this.kind, this.providerCapabilities, "text_delivery");
|
|
471
|
+
const { target } = tmuxControlFromEndpoint(terminal);
|
|
197
472
|
this.sentKeys.push({ target, keys: ["-l", text] });
|
|
198
473
|
}
|
|
199
|
-
async sendKeys(
|
|
474
|
+
async sendKeys(terminal, keys) {
|
|
475
|
+
assertTerminalCapability(terminal, "send_keys", this.supportedCapabilities);
|
|
476
|
+
assertInputProviderCapability(this.kind, this.providerCapabilities, "key_delivery");
|
|
477
|
+
const { target } = tmuxControlFromEndpoint(terminal);
|
|
200
478
|
this.sentKeys.push({ target, keys: [...keys] });
|
|
201
479
|
}
|
|
202
480
|
}
|
|
@@ -221,20 +499,31 @@ export function parseTmuxListPanes(output, socketPath) {
|
|
|
221
499
|
}
|
|
222
500
|
function parseTmuxPaneLine(line) {
|
|
223
501
|
const tabFields = line.split("\t");
|
|
224
|
-
const fields = tabFields.length >=
|
|
502
|
+
const fields = tabFields.length >= 8
|
|
225
503
|
? [
|
|
226
504
|
tabFields[0],
|
|
227
505
|
tabFields[1],
|
|
228
506
|
tabFields[2],
|
|
229
507
|
tabFields[3],
|
|
230
508
|
tabFields[4],
|
|
231
|
-
tabFields.slice(5).join("\t")
|
|
509
|
+
tabFields.slice(5, -2).join("\t"),
|
|
510
|
+
tabFields.at(-2) ?? "",
|
|
511
|
+
tabFields.at(-1) ?? ""
|
|
232
512
|
]
|
|
233
|
-
:
|
|
513
|
+
: tabFields.length >= 6
|
|
514
|
+
? [
|
|
515
|
+
tabFields[0],
|
|
516
|
+
tabFields[1],
|
|
517
|
+
tabFields[2],
|
|
518
|
+
tabFields[3],
|
|
519
|
+
tabFields[4],
|
|
520
|
+
tabFields.slice(5).join("\t")
|
|
521
|
+
]
|
|
522
|
+
: parseWhitespaceTmuxPaneLine(line) ?? parseUnderscoreTmuxPaneLine(line);
|
|
234
523
|
if (!fields) {
|
|
235
524
|
return undefined;
|
|
236
525
|
}
|
|
237
|
-
const [session, windowIndex, paneIndex, panePid, currentCommand, currentPath] = fields;
|
|
526
|
+
const [session, windowIndex, paneIndex, panePid, currentCommand, currentPath, serverSocketPath, paneId] = fields;
|
|
238
527
|
const window = Number(windowIndex);
|
|
239
528
|
const pane = Number(paneIndex);
|
|
240
529
|
const parsedPanePid = Number(panePid);
|
|
@@ -247,7 +536,9 @@ function parseTmuxPaneLine(line) {
|
|
|
247
536
|
pane,
|
|
248
537
|
panePid: parsedPanePid,
|
|
249
538
|
currentCommand: currentCommand || undefined,
|
|
250
|
-
currentPath: currentPath || undefined
|
|
539
|
+
currentPath: currentPath || undefined,
|
|
540
|
+
serverSocketPath: serverSocketPath || undefined,
|
|
541
|
+
paneId: paneId || undefined
|
|
251
542
|
};
|
|
252
543
|
}
|
|
253
544
|
function parseWhitespaceTmuxPaneLine(line) {
|
|
@@ -259,30 +550,161 @@ function parseUnderscoreTmuxPaneLine(line) {
|
|
|
259
550
|
return match ? match.slice(1) : undefined;
|
|
260
551
|
}
|
|
261
552
|
export async function enrichActiveProcessesWithTerminalControl(processes, provider, options = {}) {
|
|
262
|
-
const
|
|
263
|
-
if (
|
|
553
|
+
const terminals = await provider.listTerminals();
|
|
554
|
+
if (terminals.length === 0) {
|
|
264
555
|
return processes;
|
|
265
556
|
}
|
|
266
557
|
const matches = new Map();
|
|
267
558
|
const processTree = options.processTree ?? processes;
|
|
268
559
|
for (const process of processes) {
|
|
269
|
-
const
|
|
270
|
-
|
|
560
|
+
const matchingTerminals = terminals.filter((candidate) => provider.containsProcess(candidate, process, processTree));
|
|
561
|
+
// Never guess when nested/multiple terminal providers both contain the
|
|
562
|
+
// same process. A wrong match would grant control over an unrelated route.
|
|
563
|
+
if (matchingTerminals.length !== 1) {
|
|
271
564
|
continue;
|
|
272
565
|
}
|
|
273
|
-
matches.set(process.pid,
|
|
566
|
+
matches.set(process.pid, matchingTerminals[0]);
|
|
274
567
|
}
|
|
275
568
|
return processes.map((process) => {
|
|
276
|
-
const
|
|
277
|
-
if (!
|
|
569
|
+
const terminal = matches.get(process.pid);
|
|
570
|
+
if (!terminal) {
|
|
278
571
|
return process;
|
|
279
572
|
}
|
|
280
573
|
return {
|
|
281
574
|
...process,
|
|
282
|
-
terminalControl:
|
|
575
|
+
terminalControl: provider.toControlRef(terminal, options.capabilities ?? ["screen_status", "send_keys"])
|
|
283
576
|
};
|
|
284
577
|
});
|
|
285
578
|
}
|
|
579
|
+
function assertEndpointKind(terminal, kind) {
|
|
580
|
+
if (terminal.identity.providerKind !== kind) {
|
|
581
|
+
throw new Error(`terminal control provider ${kind} cannot resolve ` +
|
|
582
|
+
`${terminal.identity.providerKind}`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function assertTerminalCapability(terminal, capability, supportedCapabilities) {
|
|
586
|
+
if (!supportedCapabilities.includes(capability) ||
|
|
587
|
+
!terminal.capabilities.includes(capability)) {
|
|
588
|
+
throw new Error(`terminal control capability ${capability} is not available for ` +
|
|
589
|
+
`${terminal.identity.providerKind}:${terminal.route.label}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
function assertProviderCapability(kind, supported, capability) {
|
|
593
|
+
if (!supported.includes(capability)) {
|
|
594
|
+
throw new Error(`terminal control provider ${kind} does not support ${capability}`);
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
function assertInputProviderCapability(kind, supported, capability) {
|
|
598
|
+
if (!supported.includes(capability)) {
|
|
599
|
+
throw new TerminalControlInputNotSentError(`terminal control provider ${kind} does not support ${capability}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
function intersectCapabilities(requested, supported) {
|
|
603
|
+
const supportedSet = new Set(supported);
|
|
604
|
+
return [...new Set(requested)].filter((capability) => supportedSet.has(capability));
|
|
605
|
+
}
|
|
606
|
+
function endpointWithCapabilities(terminal, capabilities) {
|
|
607
|
+
const providerRef = terminalControlWithCapabilities(tmuxControlFromEndpoint(terminal), capabilities);
|
|
608
|
+
return createTerminalEndpointRef({
|
|
609
|
+
identity: terminal.identity,
|
|
610
|
+
route: terminal.route,
|
|
611
|
+
processAnchorPid: terminal.processAnchorPid,
|
|
612
|
+
capabilities,
|
|
613
|
+
providerRef
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
function tmuxEndpointFromPane(pane, capabilities) {
|
|
617
|
+
const providerRef = terminalRefFromPane(pane, capabilities);
|
|
618
|
+
const legacy = terminalEndpointFromControlRef(providerRef);
|
|
619
|
+
const endpointKey = pane.serverSocketPath
|
|
620
|
+
? `socket:${pane.serverSocketPath}`
|
|
621
|
+
: legacy.identity.endpointKey;
|
|
622
|
+
const resourceKey = pane.paneId
|
|
623
|
+
? `pane-id:${pane.paneId}`
|
|
624
|
+
: legacy.identity.resourceKey;
|
|
625
|
+
return createTerminalEndpointRef({
|
|
626
|
+
identity: {
|
|
627
|
+
providerKind: "tmux",
|
|
628
|
+
endpointKey,
|
|
629
|
+
resourceKey
|
|
630
|
+
},
|
|
631
|
+
route: {
|
|
632
|
+
routeKey: tmuxTerminalRouteKey(endpointKey, pane.target, pane.socketPath),
|
|
633
|
+
label: pane.target,
|
|
634
|
+
currentCommand: pane.currentCommand,
|
|
635
|
+
currentPath: pane.currentPath
|
|
636
|
+
},
|
|
637
|
+
processAnchorPid: pane.panePid,
|
|
638
|
+
capabilities,
|
|
639
|
+
providerRef
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
function legacyTmuxEndpointMatches(candidate, requested) {
|
|
643
|
+
if (!requested.identity.resourceKey.startsWith("legacy:")) {
|
|
644
|
+
return false;
|
|
645
|
+
}
|
|
646
|
+
const candidateControl = tmuxControlFromEndpoint(candidate);
|
|
647
|
+
const requestedControl = tmuxControlFromEndpoint(requested);
|
|
648
|
+
if (candidateControl.target !== requestedControl.target ||
|
|
649
|
+
candidateControl.panePid !== requestedControl.panePid) {
|
|
650
|
+
return false;
|
|
651
|
+
}
|
|
652
|
+
if (!requestedControl.socketPath) {
|
|
653
|
+
return true;
|
|
654
|
+
}
|
|
655
|
+
return candidateControl.socketPath === requestedControl.socketPath ||
|
|
656
|
+
candidate.identity.endpointKey === `socket:${requestedControl.socketPath}`;
|
|
657
|
+
}
|
|
658
|
+
function tmuxControlFromEndpoint(terminal) {
|
|
659
|
+
assertEndpointKind(terminal, "tmux");
|
|
660
|
+
const control = terminal.providerRef;
|
|
661
|
+
if (!isTmuxTerminalControlRef(control)) {
|
|
662
|
+
throw new Error("tmux terminal endpoint has an invalid provider reference");
|
|
663
|
+
}
|
|
664
|
+
return control;
|
|
665
|
+
}
|
|
666
|
+
function tmuxIoRoute(terminal) {
|
|
667
|
+
const control = tmuxControlFromEndpoint(terminal);
|
|
668
|
+
const stablePaneId = terminal.identity.resourceKey.startsWith("pane-id:")
|
|
669
|
+
? terminal.identity.resourceKey.slice("pane-id:".length)
|
|
670
|
+
: undefined;
|
|
671
|
+
const stableSocketPath = terminal.identity.endpointKey.startsWith("socket:")
|
|
672
|
+
? terminal.identity.endpointKey.slice("socket:".length)
|
|
673
|
+
: undefined;
|
|
674
|
+
return {
|
|
675
|
+
// tmux's `%pane_id` remains valid across session/window/pane renames and
|
|
676
|
+
// cannot be confused with a newly reused human selector.
|
|
677
|
+
target: stablePaneId || control.target,
|
|
678
|
+
socketPath: stableSocketPath || control.socketPath
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
function isTmuxTerminalControlRef(value) {
|
|
682
|
+
if (typeof value !== "object" || value === null) {
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
const control = value;
|
|
686
|
+
return control.kind === "tmux" &&
|
|
687
|
+
typeof control.target === "string" &&
|
|
688
|
+
typeof control.session === "string" &&
|
|
689
|
+
Number.isInteger(control.window) &&
|
|
690
|
+
Number.isInteger(control.pane) &&
|
|
691
|
+
Number.isInteger(control.panePid) &&
|
|
692
|
+
Array.isArray(control.capabilities);
|
|
693
|
+
}
|
|
694
|
+
function tmuxPaneFromEndpoint(terminal) {
|
|
695
|
+
const control = tmuxControlFromEndpoint(terminal);
|
|
696
|
+
return {
|
|
697
|
+
kind: "tmux",
|
|
698
|
+
target: control.target,
|
|
699
|
+
socketPath: control.socketPath,
|
|
700
|
+
session: control.session,
|
|
701
|
+
window: control.window,
|
|
702
|
+
pane: control.pane,
|
|
703
|
+
panePid: control.panePid,
|
|
704
|
+
currentCommand: control.currentCommand,
|
|
705
|
+
currentPath: control.currentPath
|
|
706
|
+
};
|
|
707
|
+
}
|
|
286
708
|
export function terminalRefFromPane(pane, capabilities = ["screen_status", "send_keys"]) {
|
|
287
709
|
return {
|
|
288
710
|
kind: "tmux",
|