pi-pier 0.2.6 → 0.3.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/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +13 -1
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +458 -0
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +270 -47
- package/src/core/todo.ts +49 -14
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -0
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +5 -0
- package/src/index.ts +360 -60
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +60 -3
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +161 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/core/terminal.ts
CHANGED
|
@@ -10,9 +10,15 @@ import { Context } from '@deepseek-ai/cordis';
|
|
|
10
10
|
import { Type } from 'typebox';
|
|
11
11
|
import type { PiSurface } from '../pi-surface.ts';
|
|
12
12
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
13
|
+
import { herdrUnavailableHint } from '../herdr-client.ts';
|
|
14
|
+
import { toolError } from '../tool-error.ts'
|
|
15
|
+
|
|
16
|
+
/** Raw tool arguments: every field is validated inside the action handlers. */
|
|
17
|
+
type ToolParams = Record<string, unknown> | undefined;;
|
|
13
18
|
import {
|
|
14
19
|
READINESS_TIMEOUT_MS,
|
|
15
20
|
READ_MAX_CHARS,
|
|
21
|
+
TERM_REMINDER_CUSTOM_TYPE,
|
|
16
22
|
TERMINALS_CUSTOM_TYPE,
|
|
17
23
|
activeTerminalPaneIds,
|
|
18
24
|
classifyReadiness,
|
|
@@ -21,15 +27,20 @@ import {
|
|
|
21
27
|
detectFullscreenTUI,
|
|
22
28
|
foldTerminalsRegistry,
|
|
23
29
|
makeTerminalsRegistry,
|
|
30
|
+
planIdleTerminalReminder,
|
|
31
|
+
planShellInit,
|
|
24
32
|
promptStrategyFor,
|
|
25
33
|
registerTerminal,
|
|
26
34
|
stripAnsi,
|
|
27
35
|
summarizeSessions,
|
|
36
|
+
terminalReminderGraceMs,
|
|
28
37
|
validateSendText,
|
|
29
38
|
validateSignal,
|
|
30
39
|
type ReadCursor,
|
|
31
40
|
type TerminalEntry,
|
|
32
41
|
} from '../terminal-core.ts';
|
|
42
|
+
import { swallow } from '../swallow.ts';
|
|
43
|
+
import { pierOption } from '../pier-options.ts';
|
|
33
44
|
|
|
34
45
|
export interface TerminalStateSlot {
|
|
35
46
|
/** Lets index GC preserve panes that host active terminals. */
|
|
@@ -60,7 +71,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
60
71
|
function persistTerminals(): void {
|
|
61
72
|
try {
|
|
62
73
|
pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
|
|
63
|
-
} catch {
|
|
74
|
+
} catch (err) {
|
|
75
|
+
swallow('terminal.persist-registry', err);
|
|
64
76
|
/* Persistence is best-effort because terminal operation must not depend on session logging. */
|
|
65
77
|
}
|
|
66
78
|
}
|
|
@@ -95,53 +107,166 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
95
107
|
rebuildTerminals(eventCtx);
|
|
96
108
|
});
|
|
97
109
|
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
// Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
|
|
111
|
+
// terminals are persistent by design, so a master that exits without closing them leaks live
|
|
112
|
+
// zsh panes. Session SWITCH never fires this event (only session_start/session_tree), so
|
|
113
|
+
// switching branches cannot kill shells that the registry replays on the next session_start.
|
|
114
|
+
scoped.on('session_shutdown', async () => {
|
|
115
|
+
idleReminderTimer && clearTimeout(idleReminderTimer);
|
|
116
|
+
idleReminderTimer = null;
|
|
117
|
+
const open = terminals.filter((t) => t.status === 'open');
|
|
118
|
+
if (open.length === 0) return;
|
|
119
|
+
await Promise.allSettled(open.map((t) => client.closePane(t.paneId)));
|
|
120
|
+
const now = Date.now();
|
|
121
|
+
terminals = terminals.map((t) =>
|
|
122
|
+
t.status === 'open' ? { ...t, status: 'closed' as const, closedAt: now } : t,
|
|
123
|
+
);
|
|
124
|
+
persistTerminals();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Turn-end nudge: remind the model to close terminals idle past the threshold (wF:p7 orphan —
|
|
128
|
+
// a one-shot background compile left a dead split in the main tab forever). Same delivery
|
|
129
|
+
// shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
|
|
130
|
+
const piSend = surface.raw as {
|
|
131
|
+
sendMessage?: (
|
|
132
|
+
message: { customType: string; content: string; display?: boolean },
|
|
133
|
+
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
134
|
+
) => Promise<void>;
|
|
135
|
+
};
|
|
136
|
+
let idleReminders = 0;
|
|
137
|
+
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
138
|
+
scoped.on('agent_start', () => {
|
|
139
|
+
if (idleReminderTimer !== null) {
|
|
140
|
+
clearTimeout(idleReminderTimer);
|
|
141
|
+
idleReminderTimer = null;
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
scoped.on('agent_settled', async () => {
|
|
145
|
+
if (idleReminderTimer !== null) {
|
|
146
|
+
clearTimeout(idleReminderTimer);
|
|
147
|
+
idleReminderTimer = null;
|
|
148
|
+
}
|
|
149
|
+
const plan = planIdleTerminalReminder({
|
|
150
|
+
open: terminals.filter((t) => t.status === 'open'),
|
|
151
|
+
now: Date.now(),
|
|
152
|
+
reminders: idleReminders,
|
|
153
|
+
});
|
|
154
|
+
if (!plan.due || plan.content == null) return;
|
|
155
|
+
// 01a06ae3 goodbye-loop: mark the covered terminals nudged NOW (persisted), and deliver the
|
|
156
|
+
// notice as a queued followUp WITHOUT triggerTurn — a reminder alone must never wake the
|
|
157
|
+
// agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
|
|
158
|
+
const now = Date.now();
|
|
159
|
+
const ids = new Set(plan.ids);
|
|
160
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
|
|
161
|
+
persistTerminals();
|
|
162
|
+
idleReminders += 1;
|
|
163
|
+
const content = plan.content;
|
|
164
|
+
idleReminderTimer = setTimeout(() => {
|
|
165
|
+
idleReminderTimer = null;
|
|
166
|
+
void (async () => {
|
|
167
|
+
const send = piSend.sendMessage;
|
|
168
|
+
if (typeof send !== 'function') return;
|
|
169
|
+
try {
|
|
170
|
+
await send(
|
|
171
|
+
{ customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
172
|
+
{ deliverAs: 'followUp' },
|
|
173
|
+
);
|
|
174
|
+
} catch {
|
|
175
|
+
/* Delivery failure is non-fatal; the cap already bounds retries. */
|
|
176
|
+
}
|
|
177
|
+
})();
|
|
178
|
+
}, terminalReminderGraceMs());
|
|
179
|
+
idleReminderTimer.unref?.();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const TERM_READ_MAX = Number(pierOption('PIER_TERM_READ_MAX') ?? READ_MAX_CHARS) || READ_MAX_CHARS;
|
|
183
|
+
/** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
|
|
184
|
+
const WAIT_DEFAULT_MS = 120_000;
|
|
185
|
+
const WAIT_MAX_MS = 600_000;
|
|
186
|
+
/** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
|
|
187
|
+
const WAIT_TAIL_CHARS = 2_000;
|
|
188
|
+
|
|
189
|
+
/** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
|
|
190
|
+
const fail = (msg: string): never => toolError(msg);
|
|
100
191
|
|
|
101
192
|
scoped.registerTool({
|
|
102
193
|
name: 'terminal',
|
|
103
194
|
label: 'Terminal',
|
|
104
195
|
description: [
|
|
105
196
|
'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
|
|
106
|
-
'Operations: open (create session), send (type commands), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
|
|
197
|
+
'Operations: open (create session), send (type commands), wait (block until output matches a pattern), read (capture output), signal (ctrl+c/ctrl+d/ctrl+z/esc/enter), close (kill shell), list (show all).',
|
|
107
198
|
'The shell keeps cwd, environment variables, and background processes across calls.',
|
|
108
199
|
'Use for dev servers, REPLs, or multi-step shell work — not one-shot bash calls.',
|
|
200
|
+
'Close a terminal with action: "close" as soon as the work in it is done — finished terminals are never auto-reclaimed and keep occupying a pane.',
|
|
201
|
+
'Long job pattern: send "cmd; echo TERM_DONE_$?" then wait with pattern "TERM_DONE_" — the sentinel also carries the exit code; redirect verbose output to a log file and read the file, because output between two reads is lost.',
|
|
202
|
+
'After send, use read to confirm the command actually started; multi-line text executes line-by-line. send(wait_prompt: true) refuses to queue text while a previous command is still running.',
|
|
109
203
|
].join(' '),
|
|
204
|
+
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.',
|
|
205
|
+
promptGuidelines: [
|
|
206
|
+
'Prefer bash for one-shot commands: it is cheaper and returns output directly. Use terminal only when state must persist (server, REPL, watch loop, interactive prompt) or when you need to wait for output that appears later.',
|
|
207
|
+
'Open once, then send/read/wait against the same terminal_id; do not open a new terminal per command.',
|
|
208
|
+
'After send, read to confirm the command actually started. Multi-line text is executed line by line.',
|
|
209
|
+
'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.',
|
|
210
|
+
'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
|
|
211
|
+
],
|
|
110
212
|
parameters: Type.Object({
|
|
111
213
|
action: Type.Union([
|
|
112
214
|
Type.Literal('open'),
|
|
113
215
|
Type.Literal('send'),
|
|
216
|
+
Type.Literal('wait'),
|
|
114
217
|
Type.Literal('read'),
|
|
115
218
|
Type.Literal('signal'),
|
|
116
219
|
Type.Literal('close'),
|
|
117
220
|
Type.Literal('list'),
|
|
118
221
|
], { description: 'Terminal operation to perform' }),
|
|
119
222
|
cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
|
|
120
|
-
terminal_id: Type.Optional(Type.String({ description: '[send|read|signal|close] Terminal id returned by open action' })),
|
|
223
|
+
terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close] Terminal id returned by open action' })),
|
|
121
224
|
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
225
|
+
wait_prompt: Type.Optional(Type.Boolean({ description: '[send] Verify the shell is back at a prompt before sending; refuses with the detected readiness instead of queueing text behind a running command' })),
|
|
226
|
+
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
|
|
227
|
+
regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
|
|
228
|
+
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
|
|
122
229
|
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
123
230
|
max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
|
|
124
231
|
key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
125
232
|
}),
|
|
126
|
-
async execute(_tc, params, _sig, _upd, ctx) {
|
|
233
|
+
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
127
234
|
const action = typeof params?.action === 'string' ? params.action : '';
|
|
128
235
|
switch (action) {
|
|
129
236
|
case 'open': return executeTerminalOpen(params, ctx);
|
|
130
237
|
case 'send': return executeTerminalSend(params);
|
|
238
|
+
case 'wait': return executeTerminalWait(params);
|
|
131
239
|
case 'read': return executeTerminalRead(params);
|
|
132
240
|
case 'signal': return executeTerminalSignal(params);
|
|
133
241
|
case 'close': return executeTerminalClose(params);
|
|
134
242
|
case 'list': return executeTerminalList();
|
|
135
243
|
default:
|
|
136
|
-
return
|
|
137
|
-
? `unknown action "${action}" (valid: open, send, read, signal, close, list)`
|
|
138
|
-
: 'action is required (open, send, read, signal, close, list)');
|
|
244
|
+
return fail(action
|
|
245
|
+
? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
|
|
246
|
+
: 'action is required (open, send, wait, read, signal, close, list)');
|
|
139
247
|
}
|
|
140
248
|
},
|
|
141
249
|
});
|
|
142
250
|
|
|
143
|
-
|
|
144
|
-
|
|
251
|
+
/** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
|
|
252
|
+
async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
|
|
253
|
+
const prompt = promptStrategyFor();
|
|
254
|
+
try {
|
|
255
|
+
const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
256
|
+
const isMatched = waitRes && typeof waitRes === 'object' && 'matched' in waitRes
|
|
257
|
+
? (waitRes as { matched: boolean }).matched
|
|
258
|
+
: Boolean(waitRes);
|
|
259
|
+
if (isMatched) return 'prompt';
|
|
260
|
+
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
261
|
+
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
262
|
+
} catch {
|
|
263
|
+
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
264
|
+
return 'busy';
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
|
|
269
|
+
if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
|
|
145
270
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
146
271
|
: (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
147
272
|
const r = registerTerminal(terminals, {
|
|
@@ -150,27 +275,28 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
150
275
|
cwd,
|
|
151
276
|
createdAt: Date.now(),
|
|
152
277
|
});
|
|
153
|
-
if (!r.ok) return
|
|
278
|
+
if (!r.ok) return fail(r.error);
|
|
154
279
|
let paneId: string;
|
|
155
280
|
try {
|
|
156
281
|
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
157
282
|
} catch (e) {
|
|
158
|
-
|
|
283
|
+
const hint = herdrUnavailableHint(e);
|
|
284
|
+
return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
|
|
159
285
|
}
|
|
160
286
|
r.entry.paneId = paneId;
|
|
161
287
|
terminals = r.entries;
|
|
162
288
|
persistTerminals();
|
|
289
|
+
const readiness = await probeReadiness(paneId);
|
|
163
290
|
const prompt = promptStrategyFor();
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
291
|
+
const init = planShellInit({ strategy: prompt, readiness });
|
|
292
|
+
if (init.shouldInit && init.command) {
|
|
293
|
+
try {
|
|
294
|
+
await client.sendPaneText(paneId, init.command);
|
|
295
|
+
r.entry.initialized = true;
|
|
296
|
+
persistTerminals();
|
|
297
|
+
} catch {
|
|
298
|
+
/* Shell init is best-effort: failure to set +H does not block terminal usage */
|
|
171
299
|
}
|
|
172
|
-
} catch {
|
|
173
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
174
300
|
}
|
|
175
301
|
const text = [
|
|
176
302
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
@@ -180,51 +306,140 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
180
306
|
return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
|
|
181
307
|
}
|
|
182
308
|
|
|
183
|
-
async function executeTerminalSend(params) {
|
|
309
|
+
async function executeTerminalSend(params: ToolParams) {
|
|
184
310
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
185
|
-
if (!entry) return
|
|
311
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
186
312
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
187
|
-
if (!v.ok) return
|
|
313
|
+
if (!v.ok) return fail(v.error);
|
|
314
|
+
if (!entry.initialized) {
|
|
315
|
+
const prompt = promptStrategyFor();
|
|
316
|
+
const init = planShellInit({ strategy: prompt });
|
|
317
|
+
if (init.shouldInit && init.command) {
|
|
318
|
+
try {
|
|
319
|
+
await client.sendPaneText(entry.paneId, init.command);
|
|
320
|
+
} catch {
|
|
321
|
+
/* Shell init is best-effort */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
entry.initialized = true;
|
|
325
|
+
persistTerminals();
|
|
326
|
+
}
|
|
327
|
+
if (params?.wait_prompt === true) {
|
|
328
|
+
// Orchestration convention: read before writing. Text typed while a previous command still
|
|
329
|
+
// owns the foreground gets queued and fires later, which reads as "the shell ignored me".
|
|
330
|
+
// Refuse with the observed readiness instead of guessing.
|
|
331
|
+
const readiness = await probeReadiness(entry.paneId);
|
|
332
|
+
if (readiness !== 'prompt') {
|
|
333
|
+
return fail(
|
|
334
|
+
`shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
|
|
335
|
+
+ 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
188
339
|
try {
|
|
189
340
|
await client.sendPaneText(entry.paneId, v.text);
|
|
190
341
|
} catch (e) {
|
|
191
|
-
|
|
342
|
+
// B5: a dead herdr socket would otherwise read as "pane may be closed", which sends the
|
|
343
|
+
// model hunting for a pane that is fine. Prefer the actionable transport sentence.
|
|
344
|
+
return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
|
|
192
345
|
}
|
|
193
346
|
touchTerminal(entry);
|
|
194
347
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
195
348
|
}
|
|
196
349
|
|
|
197
|
-
async function
|
|
350
|
+
async function executeTerminalWait(params: ToolParams) {
|
|
351
|
+
const entry = findOpenTerminal(params?.terminal_id);
|
|
352
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
353
|
+
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
354
|
+
if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
|
|
355
|
+
const useRegex = params?.regex === true;
|
|
356
|
+
if (useRegex) {
|
|
357
|
+
try {
|
|
358
|
+
new RegExp(raw);
|
|
359
|
+
} catch (e) {
|
|
360
|
+
return fail(`invalid regex: ${(e as Error).message}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
364
|
+
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
365
|
+
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
366
|
+
let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
367
|
+
try {
|
|
368
|
+
const res = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
369
|
+
if (typeof res === 'object' && res !== null && 'matched' in res) {
|
|
370
|
+
waitResult = res as { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
371
|
+
} else if (res === true) {
|
|
372
|
+
waitResult = { matched: true };
|
|
373
|
+
} else if (res === false) {
|
|
374
|
+
waitResult = { matched: false, reason: 'timeout' };
|
|
375
|
+
} else {
|
|
376
|
+
waitResult = { matched: false, reason: 'unavailable' };
|
|
377
|
+
}
|
|
378
|
+
} catch (e) {
|
|
379
|
+
return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
|
|
380
|
+
}
|
|
381
|
+
if (!waitResult.matched) {
|
|
382
|
+
// wait-for-text convention: on timeout, hand back the recent tail so the model can decide
|
|
383
|
+
// the next move instead of re-polling blind.
|
|
384
|
+
let tail = '';
|
|
385
|
+
try {
|
|
386
|
+
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
387
|
+
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
388
|
+
} catch {
|
|
389
|
+
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
390
|
+
}
|
|
391
|
+
const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
|
|
392
|
+
return {
|
|
393
|
+
content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
|
|
394
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
return {
|
|
398
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
399
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function executeTerminalRead(params: ToolParams) {
|
|
198
404
|
let paneId: string | null = null;
|
|
199
405
|
let entry: TerminalEntry | null = null;
|
|
200
406
|
if (typeof params?.terminal_id === 'string') {
|
|
201
407
|
entry = findOpenTerminal(params.terminal_id);
|
|
202
|
-
if (!entry) return
|
|
408
|
+
if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
|
|
203
409
|
paneId = entry.paneId;
|
|
204
410
|
} else if (typeof params?.pane_id === 'string') {
|
|
205
411
|
// T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
|
|
412
|
+
// A1: the guard result is carried out of the try block — a thrown tool error must not be eaten
|
|
413
|
+
// by the lookup catch below (it would be reported as a bogus "pane lookup failed").
|
|
414
|
+
let allowed = false;
|
|
415
|
+
let lookupError: unknown = null;
|
|
206
416
|
try {
|
|
207
417
|
const panes = await client.listPanes();
|
|
208
418
|
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
209
419
|
const ownTab = target?.tabId && target.tabId === env?.tabId;
|
|
210
420
|
const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
}
|
|
214
|
-
paneId = params.pane_id;
|
|
421
|
+
allowed = Boolean(target) && Boolean(ownTab || ownTerminal);
|
|
422
|
+
if (allowed) paneId = params.pane_id;
|
|
215
423
|
} catch (e) {
|
|
216
|
-
|
|
424
|
+
lookupError = e;
|
|
425
|
+
}
|
|
426
|
+
if (lookupError !== null) {
|
|
427
|
+
return fail(herdrUnavailableHint(lookupError) ?? `pane lookup failed: ${(lookupError as Error).message}`);
|
|
428
|
+
}
|
|
429
|
+
if (!allowed) {
|
|
430
|
+
return fail('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
|
|
217
431
|
}
|
|
218
432
|
} else {
|
|
219
|
-
return
|
|
433
|
+
return fail('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
220
434
|
}
|
|
221
435
|
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
222
436
|
? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
|
|
437
|
+
if (paneId === null) return fail('no readable pane resolved (pass terminal_id, or pane_id for an own-tab pane)');
|
|
223
438
|
let read: { text: string; revision: number; truncated: boolean };
|
|
224
439
|
try {
|
|
225
440
|
read = await client.readPane(paneId, { stripAnsi: false });
|
|
226
441
|
} catch (e) {
|
|
227
|
-
return
|
|
442
|
+
return fail(herdrUnavailableHint(e) ?? `read failed (pane may be closed): ${(e as Error).message}`);
|
|
228
443
|
}
|
|
229
444
|
// T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
|
|
230
445
|
const tui = detectFullscreenTUI(read.text);
|
|
@@ -241,7 +456,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
241
456
|
}
|
|
242
457
|
const prev: ReadCursor | null = entry
|
|
243
458
|
? (entry.readRevision != null
|
|
244
|
-
? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
|
|
459
|
+
? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
|
|
245
460
|
: null)
|
|
246
461
|
: null;
|
|
247
462
|
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
@@ -263,24 +478,24 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
263
478
|
};
|
|
264
479
|
}
|
|
265
480
|
|
|
266
|
-
async function executeTerminalSignal(params) {
|
|
481
|
+
async function executeTerminalSignal(params: ToolParams) {
|
|
267
482
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
268
|
-
if (!entry) return
|
|
483
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
269
484
|
const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
|
|
270
|
-
if (!v.ok) return
|
|
485
|
+
if (!v.ok) return fail(v.error);
|
|
271
486
|
try {
|
|
272
487
|
await client.sendPaneKeys(entry.paneId, [v.key]);
|
|
273
488
|
} catch (e) {
|
|
274
|
-
return
|
|
489
|
+
return fail(herdrUnavailableHint(e) ?? `signal failed (pane may be closed): ${(e as Error).message}`);
|
|
275
490
|
}
|
|
276
491
|
touchTerminal(entry);
|
|
277
492
|
return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
|
|
278
493
|
}
|
|
279
494
|
|
|
280
|
-
async function executeTerminalClose(params) {
|
|
495
|
+
async function executeTerminalClose(params: ToolParams) {
|
|
281
496
|
const id = params?.terminal_id;
|
|
282
497
|
const entry = terminals.find((t) => t.terminalId === id);
|
|
283
|
-
if (!entry) return
|
|
498
|
+
if (!entry) return fail(`unknown terminal "${String(id)}" (see action list)`);
|
|
284
499
|
if (entry.status === 'open') {
|
|
285
500
|
try {
|
|
286
501
|
await client.closePane(entry.paneId);
|
|
@@ -294,12 +509,18 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
294
509
|
}
|
|
295
510
|
|
|
296
511
|
async function executeTerminalList() {
|
|
297
|
-
let
|
|
512
|
+
let livePanes: import('../herdr-client.ts').PaneListItem[] = [];
|
|
298
513
|
try {
|
|
299
|
-
|
|
514
|
+
livePanes = await client.listPanes();
|
|
300
515
|
} catch {
|
|
301
516
|
/* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
|
|
302
517
|
}
|
|
518
|
+
const livePaneIds = livePanes.map((p) => p.paneId);
|
|
519
|
+
const titleByPaneId = new Map<string, string>();
|
|
520
|
+
for (const p of livePanes) {
|
|
521
|
+
const cleanTitle = p.terminalTitleStripped ?? p.terminalTitle;
|
|
522
|
+
if (cleanTitle) titleByPaneId.set(p.paneId, cleanTitle);
|
|
523
|
+
}
|
|
303
524
|
const s = summarizeSessions(terminals, livePaneIds);
|
|
304
525
|
// T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
|
|
305
526
|
if (s.stalePaneIds.length > 0) {
|
|
@@ -313,9 +534,11 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
313
534
|
if (s.terminals.length === 0) {
|
|
314
535
|
return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
|
|
315
536
|
}
|
|
316
|
-
const lines = s.terminals.map((t) =>
|
|
317
|
-
|
|
318
|
-
|
|
537
|
+
const lines = s.terminals.map((t) => {
|
|
538
|
+
const cleanTitle = titleByPaneId.get(t.paneId);
|
|
539
|
+
const titleTag = cleanTitle && cleanTitle !== t.label ? ` title="${cleanTitle}"` : '';
|
|
540
|
+
return `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}${titleTag}`;
|
|
541
|
+
});
|
|
319
542
|
const note = s.stalePaneIds.length > 0
|
|
320
543
|
? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
|
|
321
544
|
: '';
|