@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
|
@@ -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
|
|
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:
|
|
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
|
|
140
|
-
const candidates =
|
|
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 (
|
|
143
|
-
const terminalControl =
|
|
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 {
|
|
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((
|
|
153
|
-
|
|
154
|
-
terminalControl:
|
|
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
|
-
|
|
207
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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.
|
|
242
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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 (
|
|
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 =
|
|
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 =
|
|
552
|
+
const elapsed = this.nowMs() - startedAt;
|
|
498
553
|
const remaining = settleTimeoutMs - elapsed;
|
|
499
554
|
if (remaining <= 0) {
|
|
500
555
|
break;
|
|
501
556
|
}
|
|
502
|
-
await
|
|
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 =
|
|
595
|
+
const startedAt = this.nowMs();
|
|
541
596
|
let stableDigest;
|
|
542
597
|
let stableSince;
|
|
543
598
|
let stableCaptures = 0;
|
|
544
|
-
while (
|
|
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 =
|
|
619
|
+
stableSince = this.nowMs();
|
|
565
620
|
stableCaptures = 1;
|
|
566
621
|
}
|
|
567
622
|
if (stableCaptures >= CODEX_MULTILINE_STABLE_CAPTURES &&
|
|
568
623
|
stableSince !== undefined &&
|
|
569
|
-
|
|
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 :
|
|
598
|
-
await
|
|
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
|
|
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
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
1048
|
-
|
|
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
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
|
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
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
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,
|