pi-pier 0.3.1 → 0.4.2
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/README.md +7 -5
- package/package.json +3 -3
- package/schemas/efficiency-config.schema.json +6 -0
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/bootstrap.ts +2 -1
- package/src/compact-coordinator.ts +207 -35
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +40 -15
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +17 -6
- package/src/efficiency-store.ts +43 -6
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +141 -36
- package/src/history-store.ts +2 -0
- package/src/index-gates.ts +6 -12
- package/src/index-master.ts +12 -16
- package/src/index-pipe.ts +6 -0
- package/src/index-runtime.ts +14 -0
- package/src/index-worker.ts +4 -10
- package/src/index.ts +310 -76
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +71 -36
- package/src/observation-core.ts +13 -1
- package/src/panel-open.ts +47 -0
- package/src/pier-options.ts +1 -2
- package/src/pipe-channel.ts +9 -0
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +119 -68
- package/src/plugins/terminal.ts +221 -57
- package/src/plugins/todo.ts +63 -63
- package/src/reducer-core.ts +3 -5
- package/src/reducer-invoker.ts +27 -13
- package/src/renderers.ts +25 -2
- package/src/role-manifest.ts +2 -3
- package/src/roles/worker-default.json +2 -2
- package/src/settle-wake-core.ts +17 -3
- package/src/subagent-core.ts +209 -3
- package/src/subagent-poller.ts +69 -19
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +55 -7
- package/src/terminal-core.ts +178 -50
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +54 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +7 -1
- package/src/worker-marker.ts +76 -0
- package/src/focus-poller.ts +0 -223
package/src/plugins/terminal.ts
CHANGED
|
@@ -4,9 +4,12 @@
|
|
|
4
4
|
* pi surface, the herdr client/environment and the state slot used by index GC; terminal-core holds the
|
|
5
5
|
* pure, independently testable logic.
|
|
6
6
|
*/
|
|
7
|
+
import { open } from 'node:fs/promises';
|
|
8
|
+
import { homedir } from 'node:os';
|
|
9
|
+
import { resolve as resolvePath } from 'node:path';
|
|
7
10
|
import { Context } from '@deepseek-ai/cordis';
|
|
8
11
|
import { Type } from 'typebox';
|
|
9
|
-
import
|
|
12
|
+
import { cordisGet } from '../cordis-services.ts';
|
|
10
13
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
11
14
|
import { herdrUnavailableHint } from '../herdr-client.ts';
|
|
12
15
|
import { toolError } from '../tool-error.ts'
|
|
@@ -18,14 +21,20 @@ import {
|
|
|
18
21
|
activeTerminalPaneIds,
|
|
19
22
|
classifyReadiness,
|
|
20
23
|
closeTerminal,
|
|
24
|
+
compilePattern,
|
|
21
25
|
computeIncrement,
|
|
22
26
|
classifyWaitMatch,
|
|
27
|
+
detectContinuationPrompt,
|
|
23
28
|
detectFullscreenTUI,
|
|
29
|
+
echoSafeWaitSuggestion,
|
|
30
|
+
excerptAroundMatch,
|
|
24
31
|
foldTerminalsRegistry,
|
|
25
32
|
makeTerminalsRegistry,
|
|
26
|
-
|
|
33
|
+
planTerminalReminder,
|
|
27
34
|
planShellInit,
|
|
28
35
|
promptStrategyFor,
|
|
36
|
+
regexLiteralPrefix,
|
|
37
|
+
redirectedSentinel,
|
|
29
38
|
registerTerminal,
|
|
30
39
|
sentinelEchoHazard,
|
|
31
40
|
stripAnsi,
|
|
@@ -45,22 +54,46 @@ type ToolParams = Record<string, unknown> | undefined;
|
|
|
45
54
|
/** Terminal tool actions; the tool schema union and the handler map are both keyed by this list. */
|
|
46
55
|
const TERMINAL_ACTIONS = ['open', 'send', 'wait', 'read', 'signal', 'close', 'list'] as const;
|
|
47
56
|
type TerminalAction = (typeof TERMINAL_ACTIONS)[number];
|
|
48
|
-
type ActionHandler = (params: ToolParams, ctx: unknown) => Promise<unknown>;
|
|
57
|
+
type ActionHandler = (params: ToolParams, ctx: unknown, signal?: AbortSignal) => Promise<unknown>;
|
|
58
|
+
|
|
59
|
+
/** File waits poll the log in-process; one tick per second keeps a 10-minute wait to ~600 reads. */
|
|
60
|
+
const FILE_WAIT_POLL_MS = 1000;
|
|
61
|
+
/** Only the log's tail is matched: a sentinel is the newest line, and a multi-GB log must not be read whole. */
|
|
62
|
+
const FILE_WAIT_TAIL_BYTES = 1024 * 1024;
|
|
63
|
+
|
|
64
|
+
/** The last `maxBytes` of a file as UTF-8, or null when it does not exist (yet). */
|
|
65
|
+
async function readFileTail(path: string, maxBytes: number): Promise<string | null> {
|
|
66
|
+
let handle;
|
|
67
|
+
try {
|
|
68
|
+
handle = await open(path, 'r');
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
try {
|
|
73
|
+
const { size } = await handle.stat();
|
|
74
|
+
const length = Math.min(size, maxBytes);
|
|
75
|
+
const buffer = Buffer.alloc(length);
|
|
76
|
+
await handle.read(buffer, 0, length, size - length);
|
|
77
|
+
return buffer.toString('utf8');
|
|
78
|
+
} finally {
|
|
79
|
+
await handle.close();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
49
82
|
|
|
50
83
|
export interface TerminalStateSlot {
|
|
51
84
|
/** Lets index GC preserve panes that host active terminals. */
|
|
52
85
|
activePaneIds: () => Set<string>;
|
|
53
86
|
}
|
|
54
87
|
|
|
55
|
-
interface TerminalDeps {
|
|
88
|
+
export interface TerminalDeps {
|
|
56
89
|
client: HerdrClientLike;
|
|
57
90
|
env: { paneId: string; tabId: string } | null;
|
|
58
91
|
state: TerminalStateSlot;
|
|
59
92
|
}
|
|
60
93
|
|
|
61
94
|
export default function terminalPlugin(ctx: Context): void {
|
|
62
|
-
const surface = ctx
|
|
63
|
-
const { client, env, state } = ctx
|
|
95
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
96
|
+
const { client, env, state } = cordisGet(ctx, 'pi-herdr.terminal-deps');
|
|
64
97
|
const pi = surface.raw as {
|
|
65
98
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
66
99
|
};
|
|
@@ -129,17 +162,20 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
129
162
|
persistTerminals();
|
|
130
163
|
});
|
|
131
164
|
|
|
132
|
-
// Turn-end nudge: remind the model to close terminals
|
|
133
|
-
// shape as the todo stop reminder: grace window, cancel on agent_start,
|
|
165
|
+
// Turn-end nudge: remind the model to close terminals it just used, then once more after the idle
|
|
166
|
+
// threshold. Same delivery shape as the todo stop reminder: grace window, cancel on agent_start,
|
|
167
|
+
// capped per terminal.
|
|
134
168
|
const piSend = surface.raw as {
|
|
135
169
|
sendMessage?: (
|
|
136
170
|
message: { customType: string; content: string; display?: boolean },
|
|
137
171
|
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
138
172
|
) => void;
|
|
139
173
|
};
|
|
140
|
-
|
|
174
|
+
/** Start of the current agent run; terminals touched since then count as "used this turn". */
|
|
175
|
+
let turnStartedAt: number | null = null;
|
|
141
176
|
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
142
177
|
scoped.on('agent_start', () => {
|
|
178
|
+
turnStartedAt = Date.now();
|
|
143
179
|
if (idleReminderTimer !== null) {
|
|
144
180
|
clearTimeout(idleReminderTimer);
|
|
145
181
|
idleReminderTimer = null;
|
|
@@ -150,24 +186,39 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
150
186
|
clearTimeout(idleReminderTimer);
|
|
151
187
|
idleReminderTimer = null;
|
|
152
188
|
}
|
|
153
|
-
const
|
|
154
|
-
|
|
189
|
+
const open = await Promise.all(
|
|
190
|
+
terminals
|
|
191
|
+
.filter((t) => t.status === 'open')
|
|
192
|
+
.map(async (t) => {
|
|
193
|
+
// A pane running a foreground job (dev server, REPL, load test) is in use even when the
|
|
194
|
+
// last tool interaction is old: nudge only after it is back at a shell prompt.
|
|
195
|
+
const info = await client.processInfo(t.paneId).catch(() => null);
|
|
196
|
+
const busy = info?.shellPid != null
|
|
197
|
+
&& info.foreground.some((p) => p.pid != null && p.pid !== info.shellPid);
|
|
198
|
+
return { ...t, busy };
|
|
199
|
+
}),
|
|
200
|
+
);
|
|
201
|
+
const plan = planTerminalReminder({
|
|
202
|
+
open,
|
|
155
203
|
now: Date.now(),
|
|
156
|
-
|
|
204
|
+
turnStartedAt,
|
|
157
205
|
});
|
|
206
|
+
turnStartedAt = null;
|
|
158
207
|
if (!plan.due || plan.content == null) return;
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
|
|
162
|
-
const ids = new Set(plan.ids);
|
|
163
|
-
terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
|
|
164
|
-
persistTerminals();
|
|
165
|
-
idleReminders += 1;
|
|
208
|
+
// Deliver the notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the
|
|
209
|
+
// agent. The per-terminal count is bumped only when the timer fires: agent_start cancels the
|
|
210
|
+
// grace timer, and a cancelled reminder must not consume one of the two allowed nudges.
|
|
166
211
|
const content = plan.content;
|
|
167
212
|
idleReminderTimer = setTimeout(() => {
|
|
168
213
|
idleReminderTimer = null;
|
|
169
214
|
const send = piSend.sendMessage;
|
|
170
215
|
if (typeof send !== 'function') return;
|
|
216
|
+
const now = Date.now();
|
|
217
|
+
const ids = new Set(plan.ids);
|
|
218
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId) && t.status === 'open'
|
|
219
|
+
? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
|
|
220
|
+
: t));
|
|
221
|
+
persistTerminals();
|
|
171
222
|
try {
|
|
172
223
|
// Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
|
|
173
224
|
send(
|
|
@@ -205,31 +256,29 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
205
256
|
name: 'terminal',
|
|
206
257
|
label: 'Terminal',
|
|
207
258
|
description: [
|
|
208
|
-
'
|
|
209
|
-
'
|
|
210
|
-
'
|
|
211
|
-
'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with regex "^TERM_DONE_[0-9]" — the shell echoes the typed command, so a literal wait on "TERM_DONE_" would match its own echo instantly; keep the sentinel distinguishable from its echo. Redirect verbose output to a log file and read the file, because output between two reads is lost.',
|
|
259
|
+
'Persistent interactive shells in dedicated herdr panes; cwd, environment and background processes survive across calls.',
|
|
260
|
+
'Actions: open, send (type a command), wait (block until output matches), read, signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close, list.',
|
|
261
|
+
'Long job: send "cmd; echo TERM_DONE_$?" and wait with regex "^TERM_DONE_[0-9]" — the shell echoes typed commands, so a literal "TERM_DONE_" would match its own echo. When output and sentinel go to a log ("cmd > run.log 2>&1; echo DONE_$? >> run.log"), wait with file: "run.log" instead of sleeping in bash.',
|
|
212
262
|
].join(' '),
|
|
213
263
|
promptSnippet: 'terminal: a persistent shell in its own pane — use for dev servers, REPLs, and multi-step shell work that must keep state between calls.',
|
|
214
264
|
promptGuidelines: [
|
|
215
|
-
'Prefer bash for one-shot commands
|
|
216
|
-
'
|
|
217
|
-
'
|
|
218
|
-
'wait is for output you expect (a sentinel, a startup line); it returns the recent tail on timeout so you can decide instead of re-polling.',
|
|
219
|
-
'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
|
|
265
|
+
'Prefer bash for one-shot commands. Use terminal only when state must persist (server, REPL, watch loop, interactive prompt) or output appears later; open once and reuse the terminal_id.',
|
|
266
|
+
'wait returns the recent tail on timeout, so decide from it instead of re-polling.',
|
|
267
|
+
'Close the terminal when the work is done — it occupies a pane and is never auto-reclaimed.',
|
|
220
268
|
],
|
|
221
269
|
parameters: Type.Object({
|
|
222
|
-
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name))
|
|
223
|
-
cwd: Type.Optional(Type.String({ description: '[open]
|
|
224
|
-
terminal_id: Type.Optional(Type.String({ description: '[open]
|
|
225
|
-
text: Type.Optional(Type.String({ description: '[send] Command text
|
|
226
|
-
wait_prompt: Type.Optional(Type.Boolean({ description: '[send]
|
|
227
|
-
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
270
|
+
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name))),
|
|
271
|
+
cwd: Type.Optional(Type.String({ description: '[open] Shell working directory (default: session cwd)' })),
|
|
272
|
+
terminal_id: Type.Optional(Type.String({ description: '[open] Optional id for the new terminal (default term-N); [others] id returned by open' })),
|
|
273
|
+
text: Type.Optional(Type.String({ description: '[send] Command text (Enter appended; multi-line runs line by line)' })),
|
|
274
|
+
wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Refuse instead of queueing when the shell is not at a prompt' })),
|
|
275
|
+
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regex to wait for (must not occur in the sent command text)' })),
|
|
276
|
+
file: Type.Optional(Type.String({ description: '[wait] Watch this log file instead of the pane (relative to the terminal cwd; may not exist yet)' })),
|
|
277
|
+
regex: Type.Optional(Type.Boolean({ description: '[wait] pattern is a regex (default literal)' })),
|
|
278
|
+
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Default 120000, max 600000' })),
|
|
279
|
+
pane_id: Type.Optional(Type.String({ description: '[read] Read another pane in your own tab' })),
|
|
280
|
+
max_chars: Type.Optional(Type.Number({ description: `[read] Output cap (default ${READ_MAX_CHARS})` })),
|
|
281
|
+
key: Type.Optional(Type.String({ description: '[signal] ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
233
282
|
}),
|
|
234
283
|
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
235
284
|
const action = typeof params?.action === 'string' ? params.action : '';
|
|
@@ -240,7 +289,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
240
289
|
? `unknown action "${action}" (valid: ${valid})`
|
|
241
290
|
: `action is required (${valid})`);
|
|
242
291
|
}
|
|
243
|
-
return handler(params, ctx);
|
|
292
|
+
return handler(params, ctx, _sig);
|
|
244
293
|
},
|
|
245
294
|
});
|
|
246
295
|
|
|
@@ -257,8 +306,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
257
306
|
}
|
|
258
307
|
}
|
|
259
308
|
|
|
260
|
-
/** Best-effort
|
|
261
|
-
* returns whether the command was injected, so a shell not
|
|
309
|
+
/** Best-effort history-expansion init right after a shell becomes usable (zsh needs
|
|
310
|
+
* `setopt NO_BANG_HIST`, bash `set +H`); returns whether the command was injected, so a shell not
|
|
311
|
+
* yet at its prompt retries later. */
|
|
262
312
|
async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
|
|
263
313
|
const init = planShellInit({ strategy: promptStrategyFor(), readiness });
|
|
264
314
|
if (!init.shouldInit || !init.command) return false;
|
|
@@ -284,12 +334,28 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
284
334
|
createdAt: Date.now(),
|
|
285
335
|
});
|
|
286
336
|
if (!r.ok) return fail(r.error);
|
|
337
|
+
// Stack new terminals below the most recent one instead of halving the pi master pane again:
|
|
338
|
+
// every right-split of the master shrinks both (45 → 22 → 11 columns), which breaks output
|
|
339
|
+
// wrapping and multi-word waits. The first terminal still splits the master to the right.
|
|
340
|
+
const anchor = [...terminals].reverse().find((t) => t.status === 'open') ?? null;
|
|
341
|
+
const targetPaneId = anchor?.paneId ?? env.paneId;
|
|
342
|
+
const direction = anchor ? 'down' : 'right';
|
|
287
343
|
let paneId: string;
|
|
288
344
|
try {
|
|
289
|
-
paneId = await client.splitPane({ direction
|
|
345
|
+
paneId = await client.splitPane({ direction, cwd, focus: false, targetPaneId });
|
|
290
346
|
} catch (e) {
|
|
291
|
-
|
|
292
|
-
|
|
347
|
+
if (!anchor) {
|
|
348
|
+
const hint = herdrUnavailableHint(e);
|
|
349
|
+
return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
|
|
350
|
+
}
|
|
351
|
+
// A terminal restored from an earlier session can point at a pane herdr no longer has: fall
|
|
352
|
+
// back to the master instead of failing to open at all.
|
|
353
|
+
try {
|
|
354
|
+
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
355
|
+
} catch (fallbackError) {
|
|
356
|
+
const hint = herdrUnavailableHint(fallbackError);
|
|
357
|
+
return fail(hint ?? `failed to split terminal pane: ${(fallbackError as Error).message}`);
|
|
358
|
+
}
|
|
293
359
|
}
|
|
294
360
|
r.entry.paneId = paneId;
|
|
295
361
|
terminals = r.entries;
|
|
@@ -313,9 +379,10 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
313
379
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
314
380
|
if (!v.ok) return fail(v.error);
|
|
315
381
|
if (!entry.initialized) {
|
|
316
|
-
await injectShellInit(entry)
|
|
317
|
-
|
|
318
|
-
|
|
382
|
+
if (await injectShellInit(entry)) {
|
|
383
|
+
entry.initialized = true;
|
|
384
|
+
persistTerminals();
|
|
385
|
+
}
|
|
319
386
|
}
|
|
320
387
|
if (params?.wait_prompt === true) {
|
|
321
388
|
// Orchestration convention: read before writing. Text typed while a previous command still owns the
|
|
@@ -339,34 +406,68 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
339
406
|
// received, because the shell echoes typed commands back into the pane.
|
|
340
407
|
lastSentText.set(entry.terminalId, v.text);
|
|
341
408
|
touchTerminal(entry);
|
|
342
|
-
|
|
409
|
+
// An incomplete command leaves the shell at a secondary prompt (`dquote>`, `heredoc>`, …):
|
|
410
|
+
// every later send is swallowed by it, so surface the wedge instead of reporting success.
|
|
411
|
+
let pending: string | null = null;
|
|
412
|
+
try {
|
|
413
|
+
const snapshot = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
414
|
+
pending = detectContinuationPrompt(stripAnsi(snapshot.text));
|
|
415
|
+
} catch {
|
|
416
|
+
/* A pane read is advisory here: a transport failure must not turn a successful send into an error. */
|
|
417
|
+
}
|
|
418
|
+
if (pending) {
|
|
419
|
+
return fail(
|
|
420
|
+
`shell is waiting for more input (${pending}) — the sent command left an incomplete quote, heredoc, or block, `
|
|
421
|
+
+ 'and later text is swallowed by that continuation prompt. Send the missing terminator, or interrupt with '
|
|
422
|
+
+ 'terminal(action: "signal", key: "ctrl+c"), then re-send a complete command.',
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
const hint = redirectedSentinel(v.text);
|
|
426
|
+
const fileHint = hint
|
|
427
|
+
? `\nnote: the sentinel ${hint.prefix}$? is written to ${hint.file}, not the pane — wait for it with `
|
|
428
|
+
+ `terminal(action: "wait", terminal_id: "${entry.terminalId}", file: "${hint.file}", pattern: "${echoSafeWaitSuggestion(hint.prefix) ?? hint.prefix}", regex: true) instead of sleeping.`
|
|
429
|
+
: '';
|
|
430
|
+
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)${fileHint}` }], details: { terminal_id: entry.terminalId } };
|
|
343
431
|
}
|
|
344
432
|
|
|
345
|
-
async function executeTerminalWait(params: ToolParams) {
|
|
433
|
+
async function executeTerminalWait(params: ToolParams, _ctx?: unknown, signal?: AbortSignal) {
|
|
346
434
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
347
435
|
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
436
|
+
// Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
|
|
437
|
+
touchTerminal(entry);
|
|
348
438
|
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
349
439
|
if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
|
|
350
440
|
const useRegex = params?.regex === true;
|
|
351
441
|
if (useRegex) {
|
|
352
442
|
try {
|
|
353
|
-
|
|
443
|
+
compilePattern(raw);
|
|
354
444
|
} catch (e) {
|
|
355
445
|
return fail(`invalid regex: ${(e as Error).message}`);
|
|
356
446
|
}
|
|
357
447
|
}
|
|
448
|
+
if (typeof params?.file === 'string' && params.file.trim()) {
|
|
449
|
+
const file = params.file.trim();
|
|
450
|
+
const path = file.startsWith('~/') ? resolvePath(homedir(), file.slice(2)) : resolvePath(entry.cwd, file);
|
|
451
|
+
return waitForFile(entry, path, raw, useRegex, params, signal);
|
|
452
|
+
}
|
|
358
453
|
// The shell echoes typed commands into the pane, so a pattern contained in the last sent text
|
|
359
454
|
// matches its own echo and "succeeds" instantly, before the job finishes (observed: a
|
|
360
455
|
// minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
|
|
361
456
|
if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
|
|
362
457
|
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
458
|
+
const literal = useRegex ? regexLiteralPrefix(raw) : raw;
|
|
459
|
+
const suggestion = echoSafeWaitSuggestion(literal);
|
|
460
|
+
const advice = suggestion
|
|
461
|
+
? `For future sends, use a sentinel distinguishable from its echo: send \`cmd; echo ${literal}$?\` and wait with regex "${suggestion}" `
|
|
462
|
+
+ '(the echoed command shows `$?`, which that regex cannot match).'
|
|
463
|
+
: 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
|
|
464
|
+
+ '(the echo contains `DONE_$?`, which that regex cannot match).';
|
|
363
465
|
return fail(
|
|
364
466
|
`pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
|
|
365
467
|
+ 'so the wait would match its own echo and return before the job finishes. '
|
|
366
468
|
+ 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
|
|
367
469
|
+ 'Watch progress with action read, or wait for a marker the running command itself prints that does not occur in the command text. '
|
|
368
|
-
+
|
|
369
|
-
+ '(the echo contains `DONE_$?`, which that regex cannot match).',
|
|
470
|
+
+ advice,
|
|
370
471
|
);
|
|
371
472
|
}
|
|
372
473
|
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
@@ -387,7 +488,10 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
387
488
|
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
388
489
|
} catch {
|
|
389
490
|
}
|
|
390
|
-
|
|
491
|
+
// A wait that consumed its budget is a timeout even when the failing RPC reports itself as
|
|
492
|
+
// unavailable: the distinction tells the model whether to re-check the command or the server.
|
|
493
|
+
const consumedBudget = Date.now() - started >= timeoutMs * 0.95;
|
|
494
|
+
const reason = waitResult.reason === 'timeout' || consumedBudget ? 'timeout' : 'wait unavailable';
|
|
391
495
|
return {
|
|
392
496
|
content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
|
|
393
497
|
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
@@ -399,7 +503,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
399
503
|
// waitForOutput (that busy-loops on the old match).
|
|
400
504
|
const deadline = started + timeoutMs;
|
|
401
505
|
const cursor: ReadCursor | null = entry.readRevision != null
|
|
402
|
-
? {
|
|
506
|
+
? {
|
|
507
|
+
revision: entry.readRevision,
|
|
508
|
+
len: entry.readLen ?? 0,
|
|
509
|
+
tail: entry.readTail ?? '',
|
|
510
|
+
textLen: entry.readTextLen ?? 0,
|
|
511
|
+
textHash: entry.readTextHash ?? 0,
|
|
512
|
+
}
|
|
403
513
|
: null;
|
|
404
514
|
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
405
515
|
let sawStale = false;
|
|
@@ -441,6 +551,53 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
441
551
|
}
|
|
442
552
|
}
|
|
443
553
|
|
|
554
|
+
/** `wait` on a log file: the sentinel of a redirected job never reaches the pane. A match already in
|
|
555
|
+
* the file when the wait starts returns at once, labelled, so a leftover from an earlier run is visible. */
|
|
556
|
+
async function waitForFile(
|
|
557
|
+
entry: TerminalEntry,
|
|
558
|
+
path: string,
|
|
559
|
+
pattern: string,
|
|
560
|
+
useRegex: boolean,
|
|
561
|
+
params: ToolParams,
|
|
562
|
+
signal: AbortSignal | undefined,
|
|
563
|
+
) {
|
|
564
|
+
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
565
|
+
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
566
|
+
const started = Date.now();
|
|
567
|
+
const matches = (text: string) => (useRegex ? compilePattern(pattern).test(text) : text.includes(pattern));
|
|
568
|
+
let text: string | null = null;
|
|
569
|
+
let wake: (() => void) | null = null;
|
|
570
|
+
const onAbort = () => wake?.();
|
|
571
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
572
|
+
try {
|
|
573
|
+
for (let tick = 0; ; tick += 1) {
|
|
574
|
+
text = await readFileTail(path, FILE_WAIT_TAIL_BYTES);
|
|
575
|
+
if (text !== null && matches(text)) {
|
|
576
|
+
const when = tick === 0 ? 'already present when the wait started (truncate the log per run if this is an old one)' : `appeared after ${Math.round((Date.now() - started) / 1000)}s`;
|
|
577
|
+
return {
|
|
578
|
+
content: [{ type: 'text', text: `matched in ${path} — ${when}:\n${excerptAroundMatch(text, pattern, useRegex)}` }],
|
|
579
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern, file: path, alreadyPresent: tick === 0 },
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const left = started + timeoutMs - Date.now();
|
|
583
|
+
if (left <= 0 || signal?.aborted) break;
|
|
584
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
585
|
+
wake = resolve;
|
|
586
|
+
const timer = setTimeout(resolve, Math.min(FILE_WAIT_POLL_MS, left));
|
|
587
|
+
await promise;
|
|
588
|
+
clearTimeout(timer);
|
|
589
|
+
}
|
|
590
|
+
} finally {
|
|
591
|
+
signal?.removeEventListener('abort', onAbort);
|
|
592
|
+
}
|
|
593
|
+
const reason = signal?.aborted ? 'aborted' : 'timeout';
|
|
594
|
+
const tail = text === null ? `\n${path} does not exist.` : text ? `\nrecent file tail:\n${text.slice(-WAIT_TAIL_CHARS)}` : `\n${path} is empty.`;
|
|
595
|
+
return {
|
|
596
|
+
content: [{ type: 'text', text: `no match in ${path} within ${timeoutMs}ms (${reason}).${tail}` }],
|
|
597
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern, file: path, reason },
|
|
598
|
+
};
|
|
599
|
+
}
|
|
600
|
+
|
|
444
601
|
/** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
|
|
445
602
|
* panes, so one task can never read another's pane. Returns the error text instead of throwing. */
|
|
446
603
|
async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
|
|
@@ -495,7 +652,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
495
652
|
}
|
|
496
653
|
const prev: ReadCursor | null = entry
|
|
497
654
|
? (entry.readRevision != null
|
|
498
|
-
? {
|
|
655
|
+
? {
|
|
656
|
+
revision: entry.readRevision,
|
|
657
|
+
len: entry.readLen ?? 0,
|
|
658
|
+
tail: entry.readTail ?? '',
|
|
659
|
+
textLen: entry.readTextLen ?? 0,
|
|
660
|
+
textHash: entry.readTextHash ?? 0,
|
|
661
|
+
}
|
|
499
662
|
: null)
|
|
500
663
|
: null;
|
|
501
664
|
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
@@ -503,7 +666,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
503
666
|
entry.readRevision = inc.cursor.revision;
|
|
504
667
|
entry.readLen = inc.cursor.len;
|
|
505
668
|
entry.readTail = inc.cursor.tail;
|
|
506
|
-
entry.
|
|
669
|
+
entry.readTextLen = inc.cursor.textLen;
|
|
670
|
+
entry.readTextHash = inc.cursor.textHash;
|
|
507
671
|
touchTerminal(entry);
|
|
508
672
|
}
|
|
509
673
|
if (inc.mode === 'none') {
|