termdock 1.4.191 → 1.4.192
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.
|
@@ -4,11 +4,14 @@ export class CollaborationDeliveryWorker {
|
|
|
4
4
|
states = new Map();
|
|
5
5
|
failures = new Map();
|
|
6
6
|
submitted = new Set();
|
|
7
|
-
/** Sessions whose
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
|
|
7
|
+
/** Sessions whose latest delivery was confirmed rendered in this process,
|
|
8
|
+
* mapped to the timestamp until which confirmations are skipped. A recent
|
|
9
|
+
* confirmed delivery means the agent is actively consuming input, so
|
|
10
|
+
* follow-up messages settle immediately; once the cooldown lapses the
|
|
11
|
+
* agent's state is unknown again (it may be mid-turn, or finishing one)
|
|
12
|
+
* and the gate re-engages. Settled-but-unconfirmed deliveries never grant
|
|
13
|
+
* cooldown — that would re-open the turn-handover hole the gate closes. */
|
|
14
|
+
confirmedUntil = new Map();
|
|
12
15
|
timer = null;
|
|
13
16
|
ticking = null;
|
|
14
17
|
constructor(options) {
|
|
@@ -117,6 +120,28 @@ export class CollaborationDeliveryWorker {
|
|
|
117
120
|
}
|
|
118
121
|
const message = pending[0];
|
|
119
122
|
const attempts = store.diagnostic(message.id)?.attempt_count ?? 0;
|
|
123
|
+
// Confirm gate: the route becoming ready only proves the agent process is
|
|
124
|
+
// up, not that its TUI is consuming — a write landing while the agent
|
|
125
|
+
// finishes another turn can sit unsubmitted in its input box. When the
|
|
126
|
+
// route can read terminal history, hold deliveries outside the session's
|
|
127
|
+
// confirm cooldown until our message appears in the history (the agent
|
|
128
|
+
// rendered it). Unconfirmed writes are re-attempted up to the configured
|
|
129
|
+
// bound; at-least-once transport is preserved throughout.
|
|
130
|
+
const confirmMs = this.options.firstDeliveryConfirmMs ?? 1_500;
|
|
131
|
+
const gateActive = Boolean(route.confirm) && confirmMs > 0
|
|
132
|
+
&& Date.now() >= (this.confirmedUntil.get(id) ?? 0)
|
|
133
|
+
&& attempts + 1 < (this.options.maxUnconfirmedWrites ?? 3);
|
|
134
|
+
// Differential baseline for stuck-paste recovery: captured before the
|
|
135
|
+
// write so a later screen diff can tell our paste apart from stale ones.
|
|
136
|
+
let baseline = '';
|
|
137
|
+
if (gateActive && route.recoverStuck && !this.submitted.has(message.id) && route.capture) {
|
|
138
|
+
try {
|
|
139
|
+
baseline = (await route.capture()) ?? '';
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
baseline = '';
|
|
143
|
+
}
|
|
144
|
+
}
|
|
120
145
|
if (!this.submitted.has(message.id)) {
|
|
121
146
|
store.recordTransport(message.id, {
|
|
122
147
|
relay_online: null, peer_reachable: true,
|
|
@@ -143,18 +168,8 @@ export class CollaborationDeliveryWorker {
|
|
|
143
168
|
catch { /* best-effort */ }
|
|
144
169
|
}
|
|
145
170
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
// boot window can be wiped by the startup clear while the message is
|
|
149
|
-
// already marked delivered. When the route can read terminal history,
|
|
150
|
-
// hold the first delivery until our message actually appears there (the
|
|
151
|
-
// agent rendered it), dismissing any approval dialog blocking the agent
|
|
152
|
-
// on the way. Unconfirmed writes are re-attempted up to the configured
|
|
153
|
-
// bound; at-least-once transport is preserved throughout.
|
|
154
|
-
const confirmMs = this.options.firstDeliveryConfirmMs ?? 1_500;
|
|
155
|
-
if (route.confirm && confirmMs > 0 && !this.confirmedSessions.has(id)
|
|
156
|
-
&& attempts + 1 < (this.options.maxUnconfirmedWrites ?? 3)) {
|
|
157
|
-
if (!(await this.confirmConsumed(pending, route, confirmMs))) {
|
|
171
|
+
if (gateActive) {
|
|
172
|
+
if (!(await this.confirmConsumed(pending, route, confirmMs, baseline))) {
|
|
158
173
|
this.submitted.delete(message.id);
|
|
159
174
|
store.recordTransport(message.id, {
|
|
160
175
|
relay_online: null, peer_reachable: true,
|
|
@@ -164,17 +179,24 @@ export class CollaborationDeliveryWorker {
|
|
|
164
179
|
this.failures.delete(id);
|
|
165
180
|
return;
|
|
166
181
|
}
|
|
182
|
+
// Confirmed rendered: refresh the exemption window so follow-ups in the
|
|
183
|
+
// agent's active turn settle immediately.
|
|
184
|
+
this.confirmedUntil.set(id, Date.now() + (this.options.confirmCooldownMs ?? 30_000));
|
|
167
185
|
}
|
|
168
186
|
// If persistence fails after writing, the in-process guard avoids a
|
|
169
187
|
// second write on retry. Across a crash, transport is at-least-once.
|
|
170
188
|
this.complete(id, message);
|
|
171
189
|
}
|
|
172
190
|
/** Wait out the confirm window, then look for the written messages in the
|
|
173
|
-
* recipient's terminal history.
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
|
|
191
|
+
* recipient's terminal history. A message found there was rendered by the
|
|
192
|
+
* agent and counts as consumed. While the agent is blocked on an approval
|
|
193
|
+
* dialog (its Bash call needs permission) dismiss it once per cycle, and
|
|
194
|
+
* on the first cycle submit a paste that arrived during turn handover and
|
|
195
|
+
* is still sitting unsubmitted (differential baseline evidence only, at
|
|
196
|
+
* most once per delivery). Each recovery sends Enter on evidence, never
|
|
197
|
+
* blind. */
|
|
198
|
+
async confirmConsumed(messages, route, delayMs, baseline) {
|
|
199
|
+
let recovered = false;
|
|
178
200
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
179
201
|
await new Promise((resolve) => setTimeout(resolve, attempt === 0 ? delayMs : Math.min(1_200, delayMs)));
|
|
180
202
|
let content = null;
|
|
@@ -186,6 +208,12 @@ export class CollaborationDeliveryWorker {
|
|
|
186
208
|
}
|
|
187
209
|
if (content && messages.some((message) => content.includes(message.id)))
|
|
188
210
|
return true;
|
|
211
|
+
if (!recovered && route.recoverStuck && baseline) {
|
|
212
|
+
try {
|
|
213
|
+
recovered = await route.recoverStuck(baseline);
|
|
214
|
+
}
|
|
215
|
+
catch { /* a failed submit must not settle delivery */ }
|
|
216
|
+
}
|
|
189
217
|
if (content && route.approve) {
|
|
190
218
|
try {
|
|
191
219
|
await route.approve();
|
|
@@ -197,7 +225,6 @@ export class CollaborationDeliveryWorker {
|
|
|
197
225
|
}
|
|
198
226
|
complete(id, message) {
|
|
199
227
|
const { store } = this.options;
|
|
200
|
-
this.confirmedSessions.add(id);
|
|
201
228
|
store.markDelivered([message.id]);
|
|
202
229
|
this.submitted.delete(message.id);
|
|
203
230
|
store.recordTransport(message.id, {
|
|
@@ -18,9 +18,26 @@ const APPROVAL_DIALOG_PATTERNS = [
|
|
|
18
18
|
/\b1\.\s*是\b/,
|
|
19
19
|
/允许.*执行|确认.*执行/i,
|
|
20
20
|
];
|
|
21
|
+
/** Agent TUIs collapse pasted input in the input box to a marker line like
|
|
22
|
+
* "[Pasted text #3 +42 lines]". While the paste sits unsubmitted the marker
|
|
23
|
+
* stays on screen; a submitted paste clears the box and only the transcript
|
|
24
|
+
* holds the content. Session-unique #N lets us diff screen captures. */
|
|
25
|
+
const PASTE_MARKER_PATTERN = /\[Pasted text #(\d+)/g;
|
|
21
26
|
export function detectApprovalDialog(content) {
|
|
22
27
|
return APPROVAL_DIALOG_PATTERNS.some((pattern) => pattern.test(content));
|
|
23
28
|
}
|
|
29
|
+
/** Paste marker sequence numbers visible in a screen capture. */
|
|
30
|
+
export function extractPasteMarkerNumbers(content) {
|
|
31
|
+
return new Set([...content.matchAll(PASTE_MARKER_PATTERN)].map((match) => Number(match[1])));
|
|
32
|
+
}
|
|
33
|
+
/** True when `after` shows a paste marker that was not in `before`. Diffing
|
|
34
|
+
* prevents a recovery Enter from submitting a stale paste that predates this
|
|
35
|
+
* delivery (or someone else's draft): we only ever submit what we just put
|
|
36
|
+
* there, at most once per delivery. */
|
|
37
|
+
export function hasNewPasteMarker(after, before) {
|
|
38
|
+
const beforeNumbers = extractPasteMarkerNumbers(before);
|
|
39
|
+
return [...extractPasteMarkerNumbers(after)].some((number) => !beforeNumbers.has(number));
|
|
40
|
+
}
|
|
24
41
|
async function assertSamePane(run, pane) {
|
|
25
42
|
const identity = (await run(['display-message', '-p', '-t', pane.paneId,
|
|
26
43
|
'#{pid}:#{session_id}:#{pane_id}:#{pane_pid}'])).trim();
|
|
@@ -56,6 +73,19 @@ export async function approveCollaborationDialog(run, pane) {
|
|
|
56
73
|
await sendTmuxPaneKey(run, pane, 'enter');
|
|
57
74
|
return true;
|
|
58
75
|
}
|
|
76
|
+
/** Submit a paste that arrived while the agent was finishing its previous
|
|
77
|
+
* turn and is still sitting unsubmitted in the input box. Only acts on
|
|
78
|
+
* differential evidence — a paste marker that appeared after the write —
|
|
79
|
+
* never on pre-existing stale markers, and returns whether Enter was sent.
|
|
80
|
+
* Committing the whole input buffer is safe: each delivery is written once,
|
|
81
|
+
* so each marker's content enters the transcript exactly once. */
|
|
82
|
+
export async function recoverStuckPaste(run, pane, baseline) {
|
|
83
|
+
const viewport = await captureTmuxPaneText(run, pane);
|
|
84
|
+
if (!hasNewPasteMarker(viewport, baseline))
|
|
85
|
+
return false;
|
|
86
|
+
await sendTmuxPaneKey(run, pane, 'enter');
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
59
89
|
/** History-inclusive pane text (scrollback + viewport) used to confirm a
|
|
60
90
|
* delivery was actually rendered by the agent, not wiped by a boot clear. */
|
|
61
91
|
export async function captureTmuxPaneHistory(run, pane, lines = 800) {
|
|
@@ -45,7 +45,7 @@ import { SessionSearchStore } from '../agent/sessionSearchStore.js';
|
|
|
45
45
|
import { resolveCollaborationBackend, resolveCollaborationSessionId } from '../agent/sessionBindingRecovery.js';
|
|
46
46
|
import { CollaborationRoutingStore, selectCollaborationPane } from '../agent/collaborationRouting.js';
|
|
47
47
|
import { CollaborationDeliveryWorker } from '../agent/collaborationDeliveryWorker.js';
|
|
48
|
-
import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
|
|
48
|
+
import { approveCollaborationDialog, captureTmuxPaneHistory, captureTmuxPaneText, recoverStuckPaste, sendTmuxPaneKey, writeCollaborationTmuxPane } from '../agent/collaborationTmuxDelivery.js';
|
|
49
49
|
import { listAllHookAgents, refreshStaleHooksAtLaunch, installHooksForSlug, uninstallHooksForSlug, } from '../agent/installers.js';
|
|
50
50
|
import { loadPlugins, savePlugin, removePlugin, readPluginIcon, validateManifest, } from '../agent/plugins.js';
|
|
51
51
|
import { TmuxLifecycleCoordinator } from '../utils/tmuxLifecycle.js';
|
|
@@ -1604,7 +1604,8 @@ async function resolveCollaborationRoute(frontendSessionId) {
|
|
|
1604
1604
|
// First-delivery confirm: history proves the agent rendered our
|
|
1605
1605
|
// message (boot sequences clear only the screen, never the history a
|
|
1606
1606
|
// live TUI writes into). Terminal-state only — no agent hooks.
|
|
1607
|
-
}, confirm: async () => captureTmuxPaneHistory(runTmux, pinned), approve: async () => approveCollaborationDialog(runTmux, pinned)
|
|
1607
|
+
}, confirm: async () => captureTmuxPaneHistory(runTmux, pinned), approve: async () => approveCollaborationDialog(runTmux, pinned),
|
|
1608
|
+
recoverStuck: async (baseline) => recoverStuckPaste(runTmux, pinned, baseline) };
|
|
1608
1609
|
}
|
|
1609
1610
|
if (!backend || !binding.backendSessionId)
|
|
1610
1611
|
return { state: 'offline', reason: 'SHELL_BACKEND_NOT_RUNNING' };
|
package/package.json
CHANGED
package/runtime-manifest.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"packageName": "termdock",
|
|
4
|
-
"version": "1.4.
|
|
4
|
+
"version": "1.4.192",
|
|
5
5
|
"runtimeProtocolVersion": 1,
|
|
6
6
|
"minimumDesktopVersion": "1.4.46",
|
|
7
7
|
"nodeMajor": 22,
|
|
8
8
|
"dependencyHash": "sha256-K4KyYJuVoQDpZsBM8ZX1Rw3MddOxAzVdp/cPm0iGZz0=",
|
|
9
|
-
"serverBundleHash": "sha256-
|
|
9
|
+
"serverBundleHash": "sha256-jIRqCSStnF2/E81Q9GtAEeXAsshmnR4UUDtExjgLex0=",
|
|
10
10
|
"clientBundleHash": "sha256-wpGsT/oJup/G4MyJSKqpHBJlzc2Ha3wFIWmtp4vc/Wc=",
|
|
11
11
|
"entrypoint": "dist/server/cli.js",
|
|
12
12
|
"clientEntrypoint": "dist/client/index.html"
|