@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.
Files changed (41) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +21 -15
  3. package/dist/src/cli-core.d.ts +48 -0
  4. package/dist/src/cli-core.js +20089 -0
  5. package/dist/src/cli-core.js.map +1 -0
  6. package/dist/src/cli.js +12 -19719
  7. package/dist/src/cli.js.map +1 -1
  8. package/dist/src/doctor-capabilities.d.ts +10 -2
  9. package/dist/src/doctor-capabilities.js +48 -7
  10. package/dist/src/doctor-capabilities.js.map +1 -1
  11. package/dist/src/herdr-terminal-control-provider.d.ts +115 -0
  12. package/dist/src/herdr-terminal-control-provider.js +961 -0
  13. package/dist/src/herdr-terminal-control-provider.js.map +1 -0
  14. package/dist/src/managed-session.d.ts +15 -0
  15. package/dist/src/managed-session.js +142 -15
  16. package/dist/src/managed-session.js.map +1 -1
  17. package/dist/src/native-thread-resume-snapshot.d.ts +13 -3
  18. package/dist/src/native-thread-resume-snapshot.js +105 -16
  19. package/dist/src/native-thread-resume-snapshot.js.map +1 -1
  20. package/dist/src/openclaw-plugin-helpers.js +4 -4
  21. package/dist/src/openclaw-plugin-helpers.js.map +1 -1
  22. package/dist/src/openclaw-plugin.js +28 -26
  23. package/dist/src/openclaw-plugin.js.map +1 -1
  24. package/dist/src/session-store.js +5 -0
  25. package/dist/src/session-store.js.map +1 -1
  26. package/dist/src/terminal-agent-adapter.d.ts +5 -16
  27. package/dist/src/terminal-agent-adapter.js +10 -6
  28. package/dist/src/terminal-agent-adapter.js.map +1 -1
  29. package/dist/src/terminal-agent-bridge.d.ts +11 -1
  30. package/dist/src/terminal-agent-bridge.js +199 -86
  31. package/dist/src/terminal-agent-bridge.js.map +1 -1
  32. package/dist/src/terminal-control-provider.d.ts +60 -21
  33. package/dist/src/terminal-control-provider.js +452 -30
  34. package/dist/src/terminal-control-provider.js.map +1 -1
  35. package/dist/src/terminal-control-ref.d.ts +141 -0
  36. package/dist/src/terminal-control-ref.js +557 -0
  37. package/dist/src/terminal-control-ref.js.map +1 -0
  38. package/docs/quickstart-herdr.md +37 -0
  39. package/openclaw.plugin.json +2 -2
  40. package/package.json +4 -2
  41. 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 the
57
- // same pane. The pane PID is system-unique, so keep only the first route.
58
- const key = `${pane.target}\t${pane.panePid}`;
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 capture(target, options = {}) {
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(options.socketPath, [
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(target, keys, options = {}) {
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(options.socketPath, ["send-keys", "-t", target, ...keys]));
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(target, text, options = {}) {
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(options.socketPath, [
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(options.socketPath, [
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(options.socketPath, [
365
+ this.runCommand(command, tmuxArgs(socketPath, [
148
366
  "delete-buffer",
149
367
  "-b",
150
368
  bufferName
151
369
  ]));
152
- if (pasteBuffer.status === null &&
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(options.socketPath, ["send-keys", "-t", target, "-l", text]));
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 capture(target) {
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(target, text) {
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(target, keys) {
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 >= 6
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
- : parseWhitespaceTmuxPaneLine(line) ?? parseUnderscoreTmuxPaneLine(line);
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 panes = await provider.listPanes();
263
- if (panes.length === 0) {
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 pane = panes.find((candidate) => terminalPaneContainsProcess(process, candidate, processTree));
270
- if (!pane) {
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, pane);
566
+ matches.set(process.pid, matchingTerminals[0]);
274
567
  }
275
568
  return processes.map((process) => {
276
- const pane = matches.get(process.pid);
277
- if (!pane) {
569
+ const terminal = matches.get(process.pid);
570
+ if (!terminal) {
278
571
  return process;
279
572
  }
280
573
  return {
281
574
  ...process,
282
- terminalControl: terminalRefFromPane(pane, options.capabilities)
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",