@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
@@ -1,6 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { formatTerminalConversationId, parseTerminalConversationId, terminalControlCapabilitiesForAdapter } from "./terminal-agent-adapter.js";
3
- import { enrichActiveProcessesWithTerminalControl, TerminalControlInputNotSentError, terminalRefFromPane } from "./terminal-control-provider.js";
3
+ import { enrichActiveProcessesWithTerminalControl, TerminalControlInputNotSentError } from "./terminal-control-provider.js";
4
+ import { hasCanonicalTerminalEndpoint, sameTerminalControlIncarnation, terminalEndpointFromControlRef, terminalEndpointIdentityKey } from "./terminal-control-ref.js";
4
5
  // Verified Codex profiles through 0.147.0 keep Enter in paste/newline mode for
5
6
  // 120ms after burst input. Cross that boundary rather than landing on it, and
6
7
  // also require observable composer stability instead of treating this delay
@@ -81,10 +82,14 @@ export class TerminalAgentBridge {
81
82
  registry;
82
83
  terminalProvider;
83
84
  verifyIdentity;
85
+ nowMs;
86
+ sleep;
84
87
  constructor(options) {
85
88
  this.registry = options.registry;
86
89
  this.terminalProvider = options.terminalProvider;
87
90
  this.verifyIdentity = options.verifyIdentity;
91
+ this.nowMs = options.nowMs ?? (() => performance.now());
92
+ this.sleep = options.sleep ?? terminalSettleDelay;
88
93
  }
89
94
  adapterFor(agent) {
90
95
  return this.registry.require(agent);
@@ -124,34 +129,58 @@ export class TerminalAgentBridge {
124
129
  throw new Error(`process ${process.pid} is not terminal-controlled`);
125
130
  }
126
131
  this.registry.require(process.agent);
132
+ const terminal = this.terminalProvider.endpoint(process.terminalControl);
127
133
  return formatTerminalConversationId({
128
134
  agent: process.agent,
129
- target: process.terminalControl.target,
130
- pid: process.pid
135
+ target: terminal.route.label,
136
+ pid: process.pid,
137
+ kind: terminal.identity.providerKind
131
138
  });
132
139
  }
140
+ /**
141
+ * Resolve a persisted control reference by stable endpoint identity. This is
142
+ * the managed-Session path: its route-shaped v2 terminal id is only a
143
+ * compatibility alias and may be stale after an explicit provider refresh.
144
+ */
145
+ async resolveStoredTerminal(agent, pid, terminalControl, runtime = { pid }) {
146
+ const adapter = this.registry.require(agent);
147
+ const freshControl = await this.verifyTerminalIdentity(agent, terminalControl, { ...runtime, pid });
148
+ return {
149
+ conversationId: this.terminalConversationId({
150
+ agent,
151
+ pid,
152
+ terminalControl: freshControl
153
+ }),
154
+ agent,
155
+ pid,
156
+ legacy: false,
157
+ adapter,
158
+ terminalControl: freshControl
159
+ };
160
+ }
133
161
  async resolveConversationId(conversationId) {
134
162
  const parsed = parseTerminalConversationId(conversationId);
135
163
  if (!parsed) {
136
164
  return undefined;
137
165
  }
138
166
  const adapter = this.registry.require(parsed.agent);
139
- const panes = await this.terminalProvider.listPanes();
140
- const candidates = panes.filter((candidate) => candidate.kind === parsed.kind && candidate.target === parsed.target);
167
+ const terminals = await this.terminalProvider.listTerminals();
168
+ const candidates = terminals.filter((candidate) => candidate.identity.providerKind === parsed.kind &&
169
+ candidate.route.label === parsed.target);
141
170
  const verified = this.verifyIdentity
142
- ? (await Promise.all(candidates.map(async (pane) => {
143
- const terminalControl = terminalRefFromPane(pane, terminalControlCapabilitiesForAdapter(adapter));
171
+ ? (await Promise.all(candidates.map(async (terminal) => {
172
+ const terminalControl = this.terminalProvider.toControlRef(terminal, terminalControlCapabilitiesForAdapter(adapter));
144
173
  try {
145
174
  const verifiedTerminalControl = await this.verifyTerminalIdentity(adapter.agent, terminalControl, { pid: parsed.pid });
146
- return { pane, terminalControl: verifiedTerminalControl };
175
+ return { terminal, terminalControl: verifiedTerminalControl };
147
176
  }
148
177
  catch {
149
178
  return undefined;
150
179
  }
151
180
  }))).filter((candidate) => candidate !== undefined)
152
- : candidates.slice(0, 1).map((pane) => ({
153
- pane,
154
- terminalControl: terminalRefFromPane(pane, terminalControlCapabilitiesForAdapter(adapter))
181
+ : candidates.slice(0, 1).map((terminal) => ({
182
+ terminal,
183
+ terminalControl: this.terminalProvider.toControlRef(terminal, terminalControlCapabilitiesForAdapter(adapter))
155
184
  }));
156
185
  if (verified.length === 0) {
157
186
  throw new Error(`terminal-controlled session ${parsed.conversationId} is no longer available`);
@@ -203,14 +232,34 @@ export class TerminalAgentBridge {
203
232
  }
204
233
  async send(agent, terminalControl, text, options = {}) {
205
234
  const adapter = this.registry.require(agent);
206
- if (!terminalControl.capabilities.includes("send_keys")) {
207
- throw new TerminalInputNotStartedError(`${adapter.displayName} terminal input is not supported`);
235
+ const multiline = /[\r\n]/u.test(text.trimEnd());
236
+ try {
237
+ assertTerminalMutationCapabilities({
238
+ provider: this.terminalProvider,
239
+ terminal: this.terminalProvider.endpoint(terminalControl),
240
+ semantic: [
241
+ "send_keys",
242
+ ...(adapter.agent === "codex" && multiline
243
+ ? ["screen_status"]
244
+ : [])
245
+ ],
246
+ transport: [
247
+ "stable_resource_resolution",
248
+ "text_delivery",
249
+ "key_delivery",
250
+ ...(adapter.agent === "codex" && multiline
251
+ ? ["screen_capture"]
252
+ : [])
253
+ ]
254
+ });
255
+ }
256
+ catch (error) {
257
+ throw new TerminalInputNotStartedError(error instanceof Error ? error.message : String(error), { cause: error });
208
258
  }
209
259
  const normalized = text.trimEnd();
210
260
  if (!normalized) {
211
261
  throw new TerminalInputNotStartedError("terminal message is empty");
212
262
  }
213
- const multiline = /[\r\n]/u.test(normalized);
214
263
  let verifiedForText;
215
264
  try {
216
265
  verifiedForText = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
@@ -219,9 +268,10 @@ export class TerminalAgentBridge {
219
268
  throw new TerminalInputNotStartedError(error instanceof Error ? error.message : String(error), { cause: error });
220
269
  }
221
270
  try {
222
- await this.terminalProvider.sendText(verifiedForText.target, normalized, {
223
- socketPath: verifiedForText.socketPath
224
- });
271
+ if (!verifiedForText.capabilities.includes("send_keys")) {
272
+ throw new TerminalControlInputNotSentError(`${adapter.displayName} terminal input capability changed before injection`);
273
+ }
274
+ await this.terminalProvider.sendText(this.terminalProvider.endpoint(verifiedForText), normalized);
225
275
  }
226
276
  catch (error) {
227
277
  if (error instanceof TerminalControlInputNotSentError) {
@@ -238,8 +288,9 @@ export class TerminalAgentBridge {
238
288
  multiline
239
289
  });
240
290
  // Text and Enter are separate tmux operations. Revalidate between them and
241
- // never submit after identity or composer drift. The legacy single-line
242
- // path retains its best-effort pre-submit cleanup behavior.
291
+ // never submit after identity or composer drift. A failed revalidation
292
+ // leaves the injected draft untouched because cleanup is also terminal
293
+ // input and cannot safely use a stale route.
243
294
  let verifiedForEnter;
244
295
  if (adapter.agent === "codex" && multiline) {
245
296
  verifiedForEnter = await this.settleCodexMultilineComposer(adapter, terminalControl, normalized, options.runtime);
@@ -249,20 +300,10 @@ export class TerminalAgentBridge {
249
300
  verifiedForEnter = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
250
301
  }
251
302
  catch (error) {
252
- try {
253
- await this.terminalProvider.sendKeys(verifiedForText.target, ["C-u"], {
254
- socketPath: verifiedForText.socketPath
255
- });
256
- }
257
- catch {
258
- // Best effort only: preserving the identity failure is more important than cleanup.
259
- }
260
303
  throw error;
261
304
  }
262
305
  }
263
- await this.terminalProvider.sendKeys(verifiedForEnter.target, ["C-m"], {
264
- socketPath: verifiedForEnter.socketPath
265
- });
306
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verifiedForEnter), ["C-m"]);
266
307
  await options.onTransportStage?.({
267
308
  stage: "enter_dispatched",
268
309
  agent: adapter.agent,
@@ -287,6 +328,17 @@ export class TerminalAgentBridge {
287
328
  const adapter = this.registry.require(agent);
288
329
  try {
289
330
  assertClosedStatusInspectionPlan(adapter, terminalControl, plan);
331
+ assertTerminalMutationCapabilities({
332
+ provider: this.terminalProvider,
333
+ terminal: this.terminalProvider.endpoint(terminalControl),
334
+ semantic: ["send_keys", "screen_status"],
335
+ transport: [
336
+ "stable_resource_resolution",
337
+ "screen_capture",
338
+ "text_delivery",
339
+ "key_delivery"
340
+ ]
341
+ });
290
342
  }
291
343
  catch (error) {
292
344
  throw nativeInspectionSubmissionError("not_started", error);
@@ -299,7 +351,10 @@ export class TerminalAgentBridge {
299
351
  throw nativeInspectionSubmissionError("not_started", error);
300
352
  }
301
353
  try {
302
- await this.terminalProvider.sendText(verifiedForText.target, plan.command, { socketPath: verifiedForText.socketPath });
354
+ if (!verifiedForText.capabilities.includes("send_keys")) {
355
+ throw new TerminalControlInputNotSentError(`${adapter.displayName} native inspection input capability changed`);
356
+ }
357
+ await this.terminalProvider.sendText(this.terminalProvider.endpoint(verifiedForText), plan.command);
303
358
  }
304
359
  catch (error) {
305
360
  if (error instanceof TerminalControlInputNotSentError) {
@@ -327,7 +382,7 @@ export class TerminalAgentBridge {
327
382
  try {
328
383
  // Exactly one Enter attempt. Any error is submission-uncertain and must
329
384
  // never trigger a fallback executable or a blind second Enter.
330
- await this.terminalProvider.sendKeys(settled.terminalControl.target, ["C-m"], { socketPath: settled.terminalControl.socketPath });
385
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(settled.terminalControl), ["C-m"]);
331
386
  }
332
387
  catch (error) {
333
388
  throw nativeInspectionSubmissionError("enter_uncertain", error);
@@ -430,7 +485,7 @@ export class TerminalAgentBridge {
430
485
  });
431
486
  verified = await observeExactPanel(verified);
432
487
  try {
433
- await this.terminalProvider.sendKeys(verified.target, plan.expectedResult.dismissal.keys, { socketPath: verified.socketPath });
488
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verified), plan.expectedResult.dismissal.keys);
434
489
  }
435
490
  catch (error) {
436
491
  throw new NativeInspectionDismissalError("native status modal dismissal outcome is uncertain; do not retry automatically", { cause: error });
@@ -451,20 +506,20 @@ export class TerminalAgentBridge {
451
506
  }
452
507
  }
453
508
  async settleNativeInspectionComposer(adapter, terminalControl, plan, runtime) {
454
- const startedAt = performance.now();
509
+ const startedAt = this.nowMs();
455
510
  const settleTimeoutMs = plan.composer.maximumSettleMs;
456
511
  let stableDigest;
457
512
  let stableKind;
458
513
  let stableSince;
459
514
  let stableCaptures = 0;
460
- while (performance.now() - startedAt <= settleTimeoutMs) {
515
+ while (this.nowMs() - startedAt <= settleTimeoutMs) {
461
516
  const captured = await this.captureInspection(adapter, terminalControl, {
462
517
  runtime,
463
518
  scrollbackLines: CODEX_MULTILINE_SETTLE_SCROLLBACK_LINES
464
519
  });
465
520
  assertNativeInspectionComposerSafe(captured.inspection);
466
521
  const materialized = exactNativeInspectionComposerCapture(adapter.agent, captured.screen, plan);
467
- const now = performance.now();
522
+ const now = this.nowMs();
468
523
  if (materialized) {
469
524
  if (materialized.digest === stableDigest &&
470
525
  materialized.kind === stableKind) {
@@ -494,12 +549,12 @@ export class TerminalAgentBridge {
494
549
  stableSince = undefined;
495
550
  stableCaptures = 0;
496
551
  }
497
- const elapsed = performance.now() - startedAt;
552
+ const elapsed = this.nowMs() - startedAt;
498
553
  const remaining = settleTimeoutMs - elapsed;
499
554
  if (remaining <= 0) {
500
555
  break;
501
556
  }
502
- await terminalSettleDelay(Math.min(CODEX_MULTILINE_SETTLE_POLL_MS, remaining));
557
+ await this.sleep(Math.min(CODEX_MULTILINE_SETTLE_POLL_MS, remaining));
503
558
  }
504
559
  throw new Error(`${adapter.displayName} /status composer did not become exact, idle, and stable before the bounded submit deadline`);
505
560
  }
@@ -537,11 +592,11 @@ export class TerminalAgentBridge {
537
592
  };
538
593
  }
539
594
  async settleCodexMultilineComposer(adapter, terminalControl, expectedText, runtime) {
540
- const startedAt = performance.now();
595
+ const startedAt = this.nowMs();
541
596
  let stableDigest;
542
597
  let stableSince;
543
598
  let stableCaptures = 0;
544
- while (performance.now() - startedAt <= CODEX_MULTILINE_SETTLE_TIMEOUT_MS) {
599
+ while (this.nowMs() - startedAt <= CODEX_MULTILINE_SETTLE_TIMEOUT_MS) {
545
600
  const captured = await this.captureInspection(adapter, terminalControl, {
546
601
  runtime,
547
602
  scrollbackLines: CODEX_MULTILINE_SETTLE_SCROLLBACK_LINES
@@ -561,12 +616,12 @@ export class TerminalAgentBridge {
561
616
  }
562
617
  else {
563
618
  stableDigest = composer.digest;
564
- stableSince = performance.now();
619
+ stableSince = this.nowMs();
565
620
  stableCaptures = 1;
566
621
  }
567
622
  if (stableCaptures >= CODEX_MULTILINE_STABLE_CAPTURES &&
568
623
  stableSince !== undefined &&
569
- performance.now() - stableSince >= CODEX_PASTE_ENTER_SETTLE_MS) {
624
+ this.nowMs() - stableSince >= CODEX_PASTE_ENTER_SETTLE_MS) {
570
625
  const finalCapture = await this.captureInspection(adapter, captured.terminalControl, {
571
626
  runtime,
572
627
  scrollbackLines: CODEX_MULTILINE_SETTLE_SCROLLBACK_LINES
@@ -594,8 +649,8 @@ export class TerminalAgentBridge {
594
649
  stableCaptures = 0;
595
650
  }
596
651
  const remainingSuppressionMs = CODEX_PASTE_ENTER_SETTLE_MS -
597
- (stableSince === undefined ? 0 : performance.now() - stableSince);
598
- await terminalSettleDelay(Math.max(1, Math.min(CODEX_MULTILINE_SETTLE_POLL_MS, remainingSuppressionMs > 0
652
+ (stableSince === undefined ? 0 : this.nowMs() - stableSince);
653
+ await this.sleep(Math.max(1, Math.min(CODEX_MULTILINE_SETTLE_POLL_MS, remainingSuppressionMs > 0
599
654
  ? remainingSuppressionMs
600
655
  : CODEX_MULTILINE_SETTLE_POLL_MS)));
601
656
  }
@@ -628,7 +683,7 @@ export class TerminalAgentBridge {
628
683
  if (!first.draft) {
629
684
  return false;
630
685
  }
631
- await terminalSettleDelay(CODEX_MULTILINE_SETTLE_POLL_MS);
686
+ await this.sleep(CODEX_MULTILINE_SETTLE_POLL_MS);
632
687
  const second = await captureExactDraft(first.captured.terminalControl);
633
688
  if (!sameTerminalControlIdentity(first.captured.terminalControl, second.captured.terminalControl)) {
634
689
  throw new Error("terminal control identity changed while proving the exact draft remained");
@@ -642,13 +697,14 @@ export class TerminalAgentBridge {
642
697
  */
643
698
  async clearInputLine(agent, terminalControl, options = {}) {
644
699
  const adapter = this.registry.require(agent);
645
- if (!terminalControl.capabilities.includes("send_keys")) {
646
- throw new Error(`${adapter.displayName} terminal input is not supported`);
647
- }
648
- const verified = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
649
- await this.terminalProvider.sendKeys(verified.target, ["C-u"], {
650
- socketPath: verified.socketPath
700
+ assertTerminalMutationCapabilities({
701
+ provider: this.terminalProvider,
702
+ terminal: this.terminalProvider.endpoint(terminalControl),
703
+ semantic: ["send_keys"],
704
+ transport: ["stable_resource_resolution", "key_delivery"]
651
705
  });
706
+ const verified = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
707
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verified), ["C-u"]);
652
708
  }
653
709
  async cancel(agent, terminalControl, options = {}) {
654
710
  const adapter = this.registry.require(agent);
@@ -673,10 +729,14 @@ export class TerminalAgentBridge {
673
729
  reason: `${adapter.displayName} terminal cancellation is not supported`
674
730
  };
675
731
  }
676
- const verifiedForCancel = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
677
- await this.terminalProvider.sendKeys(verifiedForCancel.target, adapter.cancelKeys, {
678
- socketPath: verifiedForCancel.socketPath
732
+ assertTerminalMutationCapabilities({
733
+ provider: this.terminalProvider,
734
+ terminal: this.terminalProvider.endpoint(terminalControl),
735
+ semantic: ["terminal_cancel", "send_keys"],
736
+ transport: ["stable_resource_resolution", "key_delivery"]
679
737
  });
738
+ const verifiedForCancel = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
739
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verifiedForCancel), adapter.cancelKeys);
680
740
  return {
681
741
  cancelRequested: true,
682
742
  key: adapter.cancelKeys.length === 1 ? adapter.cancelKeys[0] : undefined,
@@ -693,6 +753,16 @@ export class TerminalAgentBridge {
693
753
  reason: `${adapter.displayName} terminal approval is not supported`
694
754
  };
695
755
  }
756
+ assertTerminalMutationCapabilities({
757
+ provider: this.terminalProvider,
758
+ terminal: this.terminalProvider.endpoint(terminalControl),
759
+ semantic: ["screen_status", "terminal_approval", "send_keys"],
760
+ transport: [
761
+ "stable_resource_resolution",
762
+ "screen_capture",
763
+ "key_delivery"
764
+ ]
765
+ });
696
766
  const captured = await this.captureInspection(adapter, terminalControl, options);
697
767
  const { inspection } = captured;
698
768
  const activeTerminalControl = captured.terminalControl;
@@ -735,10 +805,24 @@ export class TerminalAgentBridge {
735
805
  screenExcerpt: inspection.screenExcerpt
736
806
  };
737
807
  }
738
- const fingerprint = terminalApprovalFingerprint(adapter.agent, activeTerminalControl, inspection, {
808
+ const canonicalFingerprint = terminalApprovalFingerprint(adapter.agent, activeTerminalControl, inspection, {
739
809
  screen: captured.screen,
740
810
  runtime: options.runtime
741
811
  });
812
+ const legacyFingerprint = hasCanonicalTerminalEndpoint(terminalControl)
813
+ ? undefined
814
+ : terminalApprovalFingerprint(adapter.agent, terminalControl, inspection, {
815
+ screen: captured.screen,
816
+ runtime: options.runtime
817
+ });
818
+ const useLegacyFingerprint = Boolean(options.expectedFingerprint &&
819
+ options.expectedFingerprint === legacyFingerprint);
820
+ const fingerprintControl = useLegacyFingerprint
821
+ ? terminalControl
822
+ : activeTerminalControl;
823
+ const fingerprint = useLegacyFingerprint
824
+ ? legacyFingerprint
825
+ : canonicalFingerprint;
742
826
  if (adapter.agent === "claude" &&
743
827
  decisionMode === "keys" &&
744
828
  !options.expectedFingerprint) {
@@ -825,7 +909,7 @@ export class TerminalAgentBridge {
825
909
  };
826
910
  }
827
911
  const recapturedDecisionMode = recapturedInspection.approval.action.mode ?? "keys";
828
- const recapturedFingerprint = terminalApprovalFingerprint(adapter.agent, recaptured.terminalControl, recapturedInspection, {
912
+ const recapturedFingerprint = terminalApprovalFingerprint(adapter.agent, fingerprintControl, recapturedInspection, {
829
913
  screen: recaptured.screen,
830
914
  runtime: options.runtime
831
915
  });
@@ -908,7 +992,7 @@ export class TerminalAgentBridge {
908
992
  };
909
993
  }
910
994
  const afterReservationMode = afterReservationInspection.approval.action.mode ?? "keys";
911
- const afterReservationFingerprint = terminalApprovalFingerprint(adapter.agent, afterReservation.terminalControl, afterReservationInspection, {
995
+ const afterReservationFingerprint = terminalApprovalFingerprint(adapter.agent, fingerprintControl, afterReservationInspection, {
912
996
  screen: afterReservation.screen,
913
997
  runtime: options.runtime
914
998
  });
@@ -950,7 +1034,7 @@ export class TerminalAgentBridge {
950
1034
  if (!sameTerminalControlIdentity(dispatchTerminalControl, verifiedImmediatelyBeforeSend)) {
951
1035
  throw new Error("terminal control identity changed after the final approval capture");
952
1036
  }
953
- await this.terminalProvider.sendKeys(verifiedImmediatelyBeforeSend.target, dispatchApproval.action.keys, { socketPath: verifiedImmediatelyBeforeSend.socketPath });
1037
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verifiedImmediatelyBeforeSend), dispatchApproval.action.keys);
954
1038
  return {
955
1039
  approved: true,
956
1040
  blocked: false,
@@ -1023,10 +1107,7 @@ export class TerminalAgentBridge {
1023
1107
  }
1024
1108
  async captureInspection(adapter, terminalControl, options = {}) {
1025
1109
  const verifiedTerminalControl = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
1026
- const screen = await this.terminalProvider.capture(verifiedTerminalControl.target, {
1027
- scrollbackLines: options.scrollbackLines ?? 120,
1028
- socketPath: verifiedTerminalControl.socketPath
1029
- });
1110
+ const screen = await this.terminalProvider.capture(this.terminalProvider.endpoint(verifiedTerminalControl), { scrollbackLines: options.scrollbackLines ?? 120 });
1030
1111
  return {
1031
1112
  terminalControl: verifiedTerminalControl,
1032
1113
  screen,
@@ -1041,20 +1122,45 @@ export class TerminalAgentBridge {
1041
1122
  };
1042
1123
  }
1043
1124
  async verifyTerminalIdentity(agent, terminalControl, runtime) {
1044
- if (!this.verifyIdentity) {
1045
- return terminalControl;
1125
+ let verifiedControl = terminalControl;
1126
+ if (this.verifyIdentity) {
1127
+ if (!Number.isInteger(runtime?.pid) || Number(runtime?.pid) <= 0) {
1128
+ throw new Error(`refusing terminal access for ${agent}:${terminalControl.target} without an exact agent pid; reattach this legacy tmux session before controlling it`);
1129
+ }
1130
+ const result = await this.verifyIdentity({
1131
+ agent,
1132
+ pid: Number(runtime?.pid),
1133
+ terminalControl,
1134
+ runtime
1135
+ });
1136
+ verifiedControl = result?.terminalControl ?? terminalControl;
1137
+ if (!sameTerminalControlIncarnation(terminalControl, verifiedControl)) {
1138
+ throw new Error("terminal control identity changed during identity verification");
1139
+ }
1046
1140
  }
1047
- if (!Number.isInteger(runtime?.pid) || Number(runtime?.pid) <= 0) {
1048
- throw new Error(`refusing terminal access for ${agent}:${terminalControl.target} without an exact agent pid; reattach this legacy tmux session before controlling it`);
1141
+ const verifiedEndpoint = this.terminalProvider.endpoint(verifiedControl);
1142
+ const resolvedEndpoint = await this.terminalProvider.resolve(verifiedEndpoint);
1143
+ if (hasCanonicalTerminalEndpoint(verifiedControl) &&
1144
+ !sameTerminalControlIncarnation(verifiedEndpoint, resolvedEndpoint)) {
1145
+ throw new Error("terminal stable resource or process anchor changed during fresh resolution");
1049
1146
  }
1050
- const result = await this.verifyIdentity({
1051
- agent,
1052
- pid: Number(runtime?.pid),
1053
- terminalControl,
1054
- runtime
1055
- });
1056
- return result?.terminalControl ?? terminalControl;
1147
+ return this.terminalProvider.toControlRef(resolvedEndpoint, verifiedControl.capabilities);
1148
+ }
1149
+ }
1150
+ function assertTerminalMutationCapabilities({ provider, terminal, semantic, transport }) {
1151
+ const missingSemantic = semantic.filter((capability) => !provider.supportedCapabilities.includes(capability) ||
1152
+ !terminal.capabilities.includes(capability));
1153
+ const missingTransport = transport.filter((capability) => !provider.providerCapabilities.includes(capability));
1154
+ if (missingSemantic.length === 0 && missingTransport.length === 0) {
1155
+ return;
1057
1156
  }
1157
+ const missing = [
1158
+ ...missingSemantic.map((value) => `terminal:${value}`),
1159
+ ...missingTransport.map((value) => `provider:${value}`)
1160
+ ];
1161
+ throw new TerminalControlInputNotSentError(`terminal action capability preflight failed for ` +
1162
+ `${terminal.identity.providerKind}:${terminal.route.label}: ` +
1163
+ missing.join(", "));
1058
1164
  }
1059
1165
  function nativeInspectionSubmissionError(stage, error) {
1060
1166
  if (error instanceof NativeInspectionSubmissionError) {
@@ -1433,13 +1539,7 @@ async function terminalSettleDelay(milliseconds) {
1433
1539
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
1434
1540
  }
1435
1541
  function sameTerminalControlIdentity(left, right) {
1436
- return left.kind === right.kind &&
1437
- left.target === right.target &&
1438
- left.socketPath === right.socketPath &&
1439
- left.session === right.session &&
1440
- left.window === right.window &&
1441
- left.pane === right.pane &&
1442
- left.panePid === right.panePid;
1542
+ return sameTerminalControlIncarnation(left, right);
1443
1543
  }
1444
1544
  export function terminalApprovalFingerprint(agent, terminalControl, inspection, options = {}) {
1445
1545
  if (!inspection.approval.approvable) {
@@ -1449,18 +1549,31 @@ export function terminalApprovalFingerprint(agent, terminalControl, inspection,
1449
1549
  const rawScreenDigest = decisionMode === "keys" && options.screen !== undefined
1450
1550
  ? createHash("sha256").update(options.screen).digest("hex")
1451
1551
  : undefined;
1452
- return createHash("sha256")
1453
- .update(JSON.stringify({
1454
- agent,
1455
- provider: "tmux",
1456
- terminal: {
1552
+ const terminal = terminalEndpointFromControlRef(terminalControl);
1553
+ // A pending v0.11.x approval may outlive an upgrade. Preserve its exact
1554
+ // fingerprint until the persisted control record has canonical endpoint
1555
+ // evidence; new records bind the fingerprint to stable endpoint identity.
1556
+ const terminalFingerprint = hasCanonicalTerminalEndpoint(terminalControl)
1557
+ ? {
1558
+ identity: terminalEndpointIdentityKey(terminal),
1559
+ process_anchor_pid: terminal.processAnchorPid
1560
+ }
1561
+ : terminalControl.kind === "tmux" ? {
1457
1562
  target: terminalControl.target,
1458
1563
  socket_path: terminalControl.socketPath,
1459
1564
  session: terminalControl.session,
1460
1565
  window: terminalControl.window,
1461
1566
  pane: terminalControl.pane,
1462
1567
  pane_pid: terminalControl.panePid
1463
- },
1568
+ } : undefined;
1569
+ if (!terminalFingerprint) {
1570
+ return undefined;
1571
+ }
1572
+ return createHash("sha256")
1573
+ .update(JSON.stringify({
1574
+ agent,
1575
+ provider: terminal.identity.providerKind,
1576
+ terminal: terminalFingerprint,
1464
1577
  runtime: {
1465
1578
  pid: options.runtime?.pid,
1466
1579
  session_id: options.runtime?.sessionId,