@scotthuang/agent-knock-knock 0.11.6 → 0.11.7

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.
@@ -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
@@ -124,34 +125,57 @@ export class TerminalAgentBridge {
124
125
  throw new Error(`process ${process.pid} is not terminal-controlled`);
125
126
  }
126
127
  this.registry.require(process.agent);
128
+ const terminal = this.terminalProvider.endpoint(process.terminalControl);
127
129
  return formatTerminalConversationId({
128
130
  agent: process.agent,
129
- target: process.terminalControl.target,
131
+ target: terminal.route.label,
130
132
  pid: process.pid
131
133
  });
132
134
  }
135
+ /**
136
+ * Resolve a persisted control reference by stable endpoint identity. This is
137
+ * the managed-Session path: its route-shaped v2 terminal id is only a
138
+ * compatibility alias and may be stale after an explicit provider refresh.
139
+ */
140
+ async resolveStoredTerminal(agent, pid, terminalControl, runtime = { pid }) {
141
+ const adapter = this.registry.require(agent);
142
+ const freshControl = await this.verifyTerminalIdentity(agent, terminalControl, { ...runtime, pid });
143
+ return {
144
+ conversationId: this.terminalConversationId({
145
+ agent,
146
+ pid,
147
+ terminalControl: freshControl
148
+ }),
149
+ agent,
150
+ pid,
151
+ legacy: false,
152
+ adapter,
153
+ terminalControl: freshControl
154
+ };
155
+ }
133
156
  async resolveConversationId(conversationId) {
134
157
  const parsed = parseTerminalConversationId(conversationId);
135
158
  if (!parsed) {
136
159
  return undefined;
137
160
  }
138
161
  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);
162
+ const terminals = await this.terminalProvider.listTerminals();
163
+ const candidates = terminals.filter((candidate) => candidate.identity.providerKind === parsed.kind &&
164
+ candidate.route.label === parsed.target);
141
165
  const verified = this.verifyIdentity
142
- ? (await Promise.all(candidates.map(async (pane) => {
143
- const terminalControl = terminalRefFromPane(pane, terminalControlCapabilitiesForAdapter(adapter));
166
+ ? (await Promise.all(candidates.map(async (terminal) => {
167
+ const terminalControl = this.terminalProvider.toControlRef(terminal, terminalControlCapabilitiesForAdapter(adapter));
144
168
  try {
145
169
  const verifiedTerminalControl = await this.verifyTerminalIdentity(adapter.agent, terminalControl, { pid: parsed.pid });
146
- return { pane, terminalControl: verifiedTerminalControl };
170
+ return { terminal, terminalControl: verifiedTerminalControl };
147
171
  }
148
172
  catch {
149
173
  return undefined;
150
174
  }
151
175
  }))).filter((candidate) => candidate !== undefined)
152
- : candidates.slice(0, 1).map((pane) => ({
153
- pane,
154
- terminalControl: terminalRefFromPane(pane, terminalControlCapabilitiesForAdapter(adapter))
176
+ : candidates.slice(0, 1).map((terminal) => ({
177
+ terminal,
178
+ terminalControl: this.terminalProvider.toControlRef(terminal, terminalControlCapabilitiesForAdapter(adapter))
155
179
  }));
156
180
  if (verified.length === 0) {
157
181
  throw new Error(`terminal-controlled session ${parsed.conversationId} is no longer available`);
@@ -203,14 +227,34 @@ export class TerminalAgentBridge {
203
227
  }
204
228
  async send(agent, terminalControl, text, options = {}) {
205
229
  const adapter = this.registry.require(agent);
206
- if (!terminalControl.capabilities.includes("send_keys")) {
207
- throw new TerminalInputNotStartedError(`${adapter.displayName} terminal input is not supported`);
230
+ const multiline = /[\r\n]/u.test(text.trimEnd());
231
+ try {
232
+ assertTerminalMutationCapabilities({
233
+ provider: this.terminalProvider,
234
+ terminal: this.terminalProvider.endpoint(terminalControl),
235
+ semantic: [
236
+ "send_keys",
237
+ ...(adapter.agent === "codex" && multiline
238
+ ? ["screen_status"]
239
+ : [])
240
+ ],
241
+ transport: [
242
+ "stable_resource_resolution",
243
+ "text_delivery",
244
+ "key_delivery",
245
+ ...(adapter.agent === "codex" && multiline
246
+ ? ["screen_capture"]
247
+ : [])
248
+ ]
249
+ });
250
+ }
251
+ catch (error) {
252
+ throw new TerminalInputNotStartedError(error instanceof Error ? error.message : String(error), { cause: error });
208
253
  }
209
254
  const normalized = text.trimEnd();
210
255
  if (!normalized) {
211
256
  throw new TerminalInputNotStartedError("terminal message is empty");
212
257
  }
213
- const multiline = /[\r\n]/u.test(normalized);
214
258
  let verifiedForText;
215
259
  try {
216
260
  verifiedForText = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
@@ -219,9 +263,10 @@ export class TerminalAgentBridge {
219
263
  throw new TerminalInputNotStartedError(error instanceof Error ? error.message : String(error), { cause: error });
220
264
  }
221
265
  try {
222
- await this.terminalProvider.sendText(verifiedForText.target, normalized, {
223
- socketPath: verifiedForText.socketPath
224
- });
266
+ if (!verifiedForText.capabilities.includes("send_keys")) {
267
+ throw new TerminalControlInputNotSentError(`${adapter.displayName} terminal input capability changed before injection`);
268
+ }
269
+ await this.terminalProvider.sendText(this.terminalProvider.endpoint(verifiedForText), normalized);
225
270
  }
226
271
  catch (error) {
227
272
  if (error instanceof TerminalControlInputNotSentError) {
@@ -238,8 +283,9 @@ export class TerminalAgentBridge {
238
283
  multiline
239
284
  });
240
285
  // 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.
286
+ // never submit after identity or composer drift. A failed revalidation
287
+ // leaves the injected draft untouched because cleanup is also terminal
288
+ // input and cannot safely use a stale route.
243
289
  let verifiedForEnter;
244
290
  if (adapter.agent === "codex" && multiline) {
245
291
  verifiedForEnter = await this.settleCodexMultilineComposer(adapter, terminalControl, normalized, options.runtime);
@@ -249,20 +295,10 @@ export class TerminalAgentBridge {
249
295
  verifiedForEnter = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
250
296
  }
251
297
  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
298
  throw error;
261
299
  }
262
300
  }
263
- await this.terminalProvider.sendKeys(verifiedForEnter.target, ["C-m"], {
264
- socketPath: verifiedForEnter.socketPath
265
- });
301
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verifiedForEnter), ["C-m"]);
266
302
  await options.onTransportStage?.({
267
303
  stage: "enter_dispatched",
268
304
  agent: adapter.agent,
@@ -287,6 +323,17 @@ export class TerminalAgentBridge {
287
323
  const adapter = this.registry.require(agent);
288
324
  try {
289
325
  assertClosedStatusInspectionPlan(adapter, terminalControl, plan);
326
+ assertTerminalMutationCapabilities({
327
+ provider: this.terminalProvider,
328
+ terminal: this.terminalProvider.endpoint(terminalControl),
329
+ semantic: ["send_keys", "screen_status"],
330
+ transport: [
331
+ "stable_resource_resolution",
332
+ "screen_capture",
333
+ "text_delivery",
334
+ "key_delivery"
335
+ ]
336
+ });
290
337
  }
291
338
  catch (error) {
292
339
  throw nativeInspectionSubmissionError("not_started", error);
@@ -299,7 +346,10 @@ export class TerminalAgentBridge {
299
346
  throw nativeInspectionSubmissionError("not_started", error);
300
347
  }
301
348
  try {
302
- await this.terminalProvider.sendText(verifiedForText.target, plan.command, { socketPath: verifiedForText.socketPath });
349
+ if (!verifiedForText.capabilities.includes("send_keys")) {
350
+ throw new TerminalControlInputNotSentError(`${adapter.displayName} native inspection input capability changed`);
351
+ }
352
+ await this.terminalProvider.sendText(this.terminalProvider.endpoint(verifiedForText), plan.command);
303
353
  }
304
354
  catch (error) {
305
355
  if (error instanceof TerminalControlInputNotSentError) {
@@ -327,7 +377,7 @@ export class TerminalAgentBridge {
327
377
  try {
328
378
  // Exactly one Enter attempt. Any error is submission-uncertain and must
329
379
  // never trigger a fallback executable or a blind second Enter.
330
- await this.terminalProvider.sendKeys(settled.terminalControl.target, ["C-m"], { socketPath: settled.terminalControl.socketPath });
380
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(settled.terminalControl), ["C-m"]);
331
381
  }
332
382
  catch (error) {
333
383
  throw nativeInspectionSubmissionError("enter_uncertain", error);
@@ -430,7 +480,7 @@ export class TerminalAgentBridge {
430
480
  });
431
481
  verified = await observeExactPanel(verified);
432
482
  try {
433
- await this.terminalProvider.sendKeys(verified.target, plan.expectedResult.dismissal.keys, { socketPath: verified.socketPath });
483
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verified), plan.expectedResult.dismissal.keys);
434
484
  }
435
485
  catch (error) {
436
486
  throw new NativeInspectionDismissalError("native status modal dismissal outcome is uncertain; do not retry automatically", { cause: error });
@@ -642,13 +692,14 @@ export class TerminalAgentBridge {
642
692
  */
643
693
  async clearInputLine(agent, terminalControl, options = {}) {
644
694
  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
695
+ assertTerminalMutationCapabilities({
696
+ provider: this.terminalProvider,
697
+ terminal: this.terminalProvider.endpoint(terminalControl),
698
+ semantic: ["send_keys"],
699
+ transport: ["stable_resource_resolution", "key_delivery"]
651
700
  });
701
+ const verified = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
702
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verified), ["C-u"]);
652
703
  }
653
704
  async cancel(agent, terminalControl, options = {}) {
654
705
  const adapter = this.registry.require(agent);
@@ -673,10 +724,14 @@ export class TerminalAgentBridge {
673
724
  reason: `${adapter.displayName} terminal cancellation is not supported`
674
725
  };
675
726
  }
676
- const verifiedForCancel = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
677
- await this.terminalProvider.sendKeys(verifiedForCancel.target, adapter.cancelKeys, {
678
- socketPath: verifiedForCancel.socketPath
727
+ assertTerminalMutationCapabilities({
728
+ provider: this.terminalProvider,
729
+ terminal: this.terminalProvider.endpoint(terminalControl),
730
+ semantic: ["terminal_cancel", "send_keys"],
731
+ transport: ["stable_resource_resolution", "key_delivery"]
679
732
  });
733
+ const verifiedForCancel = await this.verifyTerminalIdentity(adapter.agent, terminalControl, options.runtime);
734
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verifiedForCancel), adapter.cancelKeys);
680
735
  return {
681
736
  cancelRequested: true,
682
737
  key: adapter.cancelKeys.length === 1 ? adapter.cancelKeys[0] : undefined,
@@ -693,6 +748,16 @@ export class TerminalAgentBridge {
693
748
  reason: `${adapter.displayName} terminal approval is not supported`
694
749
  };
695
750
  }
751
+ assertTerminalMutationCapabilities({
752
+ provider: this.terminalProvider,
753
+ terminal: this.terminalProvider.endpoint(terminalControl),
754
+ semantic: ["screen_status", "terminal_approval", "send_keys"],
755
+ transport: [
756
+ "stable_resource_resolution",
757
+ "screen_capture",
758
+ "key_delivery"
759
+ ]
760
+ });
696
761
  const captured = await this.captureInspection(adapter, terminalControl, options);
697
762
  const { inspection } = captured;
698
763
  const activeTerminalControl = captured.terminalControl;
@@ -735,10 +800,24 @@ export class TerminalAgentBridge {
735
800
  screenExcerpt: inspection.screenExcerpt
736
801
  };
737
802
  }
738
- const fingerprint = terminalApprovalFingerprint(adapter.agent, activeTerminalControl, inspection, {
803
+ const canonicalFingerprint = terminalApprovalFingerprint(adapter.agent, activeTerminalControl, inspection, {
739
804
  screen: captured.screen,
740
805
  runtime: options.runtime
741
806
  });
807
+ const legacyFingerprint = hasCanonicalTerminalEndpoint(terminalControl)
808
+ ? undefined
809
+ : terminalApprovalFingerprint(adapter.agent, terminalControl, inspection, {
810
+ screen: captured.screen,
811
+ runtime: options.runtime
812
+ });
813
+ const useLegacyFingerprint = Boolean(options.expectedFingerprint &&
814
+ options.expectedFingerprint === legacyFingerprint);
815
+ const fingerprintControl = useLegacyFingerprint
816
+ ? terminalControl
817
+ : activeTerminalControl;
818
+ const fingerprint = useLegacyFingerprint
819
+ ? legacyFingerprint
820
+ : canonicalFingerprint;
742
821
  if (adapter.agent === "claude" &&
743
822
  decisionMode === "keys" &&
744
823
  !options.expectedFingerprint) {
@@ -825,7 +904,7 @@ export class TerminalAgentBridge {
825
904
  };
826
905
  }
827
906
  const recapturedDecisionMode = recapturedInspection.approval.action.mode ?? "keys";
828
- const recapturedFingerprint = terminalApprovalFingerprint(adapter.agent, recaptured.terminalControl, recapturedInspection, {
907
+ const recapturedFingerprint = terminalApprovalFingerprint(adapter.agent, fingerprintControl, recapturedInspection, {
829
908
  screen: recaptured.screen,
830
909
  runtime: options.runtime
831
910
  });
@@ -908,7 +987,7 @@ export class TerminalAgentBridge {
908
987
  };
909
988
  }
910
989
  const afterReservationMode = afterReservationInspection.approval.action.mode ?? "keys";
911
- const afterReservationFingerprint = terminalApprovalFingerprint(adapter.agent, afterReservation.terminalControl, afterReservationInspection, {
990
+ const afterReservationFingerprint = terminalApprovalFingerprint(adapter.agent, fingerprintControl, afterReservationInspection, {
912
991
  screen: afterReservation.screen,
913
992
  runtime: options.runtime
914
993
  });
@@ -950,7 +1029,7 @@ export class TerminalAgentBridge {
950
1029
  if (!sameTerminalControlIdentity(dispatchTerminalControl, verifiedImmediatelyBeforeSend)) {
951
1030
  throw new Error("terminal control identity changed after the final approval capture");
952
1031
  }
953
- await this.terminalProvider.sendKeys(verifiedImmediatelyBeforeSend.target, dispatchApproval.action.keys, { socketPath: verifiedImmediatelyBeforeSend.socketPath });
1032
+ await this.terminalProvider.sendKeys(this.terminalProvider.endpoint(verifiedImmediatelyBeforeSend), dispatchApproval.action.keys);
954
1033
  return {
955
1034
  approved: true,
956
1035
  blocked: false,
@@ -1023,10 +1102,7 @@ export class TerminalAgentBridge {
1023
1102
  }
1024
1103
  async captureInspection(adapter, terminalControl, options = {}) {
1025
1104
  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
- });
1105
+ const screen = await this.terminalProvider.capture(this.terminalProvider.endpoint(verifiedTerminalControl), { scrollbackLines: options.scrollbackLines ?? 120 });
1030
1106
  return {
1031
1107
  terminalControl: verifiedTerminalControl,
1032
1108
  screen,
@@ -1041,20 +1117,45 @@ export class TerminalAgentBridge {
1041
1117
  };
1042
1118
  }
1043
1119
  async verifyTerminalIdentity(agent, terminalControl, runtime) {
1044
- if (!this.verifyIdentity) {
1045
- return terminalControl;
1120
+ let verifiedControl = terminalControl;
1121
+ if (this.verifyIdentity) {
1122
+ if (!Number.isInteger(runtime?.pid) || Number(runtime?.pid) <= 0) {
1123
+ throw new Error(`refusing terminal access for ${agent}:${terminalControl.target} without an exact agent pid; reattach this legacy tmux session before controlling it`);
1124
+ }
1125
+ const result = await this.verifyIdentity({
1126
+ agent,
1127
+ pid: Number(runtime?.pid),
1128
+ terminalControl,
1129
+ runtime
1130
+ });
1131
+ verifiedControl = result?.terminalControl ?? terminalControl;
1132
+ if (!sameTerminalControlIncarnation(terminalControl, verifiedControl)) {
1133
+ throw new Error("terminal control identity changed during identity verification");
1134
+ }
1046
1135
  }
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`);
1136
+ const verifiedEndpoint = this.terminalProvider.endpoint(verifiedControl);
1137
+ const resolvedEndpoint = await this.terminalProvider.resolve(verifiedEndpoint);
1138
+ if (hasCanonicalTerminalEndpoint(verifiedControl) &&
1139
+ !sameTerminalControlIncarnation(verifiedEndpoint, resolvedEndpoint)) {
1140
+ throw new Error("terminal stable resource or process anchor changed during fresh resolution");
1049
1141
  }
1050
- const result = await this.verifyIdentity({
1051
- agent,
1052
- pid: Number(runtime?.pid),
1053
- terminalControl,
1054
- runtime
1055
- });
1056
- return result?.terminalControl ?? terminalControl;
1142
+ return this.terminalProvider.toControlRef(resolvedEndpoint, verifiedControl.capabilities);
1143
+ }
1144
+ }
1145
+ function assertTerminalMutationCapabilities({ provider, terminal, semantic, transport }) {
1146
+ const missingSemantic = semantic.filter((capability) => !provider.supportedCapabilities.includes(capability) ||
1147
+ !terminal.capabilities.includes(capability));
1148
+ const missingTransport = transport.filter((capability) => !provider.providerCapabilities.includes(capability));
1149
+ if (missingSemantic.length === 0 && missingTransport.length === 0) {
1150
+ return;
1057
1151
  }
1152
+ const missing = [
1153
+ ...missingSemantic.map((value) => `terminal:${value}`),
1154
+ ...missingTransport.map((value) => `provider:${value}`)
1155
+ ];
1156
+ throw new TerminalControlInputNotSentError(`terminal action capability preflight failed for ` +
1157
+ `${terminal.identity.providerKind}:${terminal.route.label}: ` +
1158
+ missing.join(", "));
1058
1159
  }
1059
1160
  function nativeInspectionSubmissionError(stage, error) {
1060
1161
  if (error instanceof NativeInspectionSubmissionError) {
@@ -1433,13 +1534,7 @@ async function terminalSettleDelay(milliseconds) {
1433
1534
  await new Promise((resolve) => setTimeout(resolve, milliseconds));
1434
1535
  }
1435
1536
  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;
1537
+ return sameTerminalControlIncarnation(left, right);
1443
1538
  }
1444
1539
  export function terminalApprovalFingerprint(agent, terminalControl, inspection, options = {}) {
1445
1540
  if (!inspection.approval.approvable) {
@@ -1449,18 +1544,28 @@ export function terminalApprovalFingerprint(agent, terminalControl, inspection,
1449
1544
  const rawScreenDigest = decisionMode === "keys" && options.screen !== undefined
1450
1545
  ? createHash("sha256").update(options.screen).digest("hex")
1451
1546
  : undefined;
1452
- return createHash("sha256")
1453
- .update(JSON.stringify({
1454
- agent,
1455
- provider: "tmux",
1456
- terminal: {
1547
+ const terminal = terminalEndpointFromControlRef(terminalControl);
1548
+ // A pending v0.11.x approval may outlive an upgrade. Preserve its exact
1549
+ // fingerprint until the persisted control record has canonical endpoint
1550
+ // evidence; new records bind the fingerprint to stable endpoint identity.
1551
+ const terminalFingerprint = hasCanonicalTerminalEndpoint(terminalControl)
1552
+ ? {
1553
+ identity: terminalEndpointIdentityKey(terminal),
1554
+ process_anchor_pid: terminal.processAnchorPid
1555
+ }
1556
+ : {
1457
1557
  target: terminalControl.target,
1458
1558
  socket_path: terminalControl.socketPath,
1459
1559
  session: terminalControl.session,
1460
1560
  window: terminalControl.window,
1461
1561
  pane: terminalControl.pane,
1462
1562
  pane_pid: terminalControl.panePid
1463
- },
1563
+ };
1564
+ return createHash("sha256")
1565
+ .update(JSON.stringify({
1566
+ agent,
1567
+ provider: terminal.identity.providerKind,
1568
+ terminal: terminalFingerprint,
1464
1569
  runtime: {
1465
1570
  pid: options.runtime?.pid,
1466
1571
  session_id: options.runtime?.sessionId,