pi-pier 0.2.7 → 0.3.1
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 +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* M14 terminal loader entry for the D78 mount tree and D81 responsibility split.
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* used by index GC. Avoiding an index.ts import preserves an acyclic dependency graph, while
|
|
7
|
-
* terminal-core remains independently testable pure logic.
|
|
3
|
+
* A Cordis loader plugin so the terminal surface stays hot-swappable: services provide the tombstone-aware
|
|
4
|
+
* pi surface, the herdr client/environment and the state slot used by index GC; terminal-core holds the
|
|
5
|
+
* pure, independently testable logic.
|
|
8
6
|
*/
|
|
9
7
|
import { Context } from '@deepseek-ai/cordis';
|
|
10
8
|
import { Type } from 'typebox';
|
|
11
9
|
import type { PiSurface } from '../pi-surface.ts';
|
|
12
10
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
11
|
+
import { herdrUnavailableHint } from '../herdr-client.ts';
|
|
12
|
+
import { toolError } from '../tool-error.ts'
|
|
13
13
|
import {
|
|
14
14
|
READINESS_TIMEOUT_MS,
|
|
15
15
|
READ_MAX_CHARS,
|
|
@@ -19,20 +19,33 @@ import {
|
|
|
19
19
|
classifyReadiness,
|
|
20
20
|
closeTerminal,
|
|
21
21
|
computeIncrement,
|
|
22
|
+
classifyWaitMatch,
|
|
22
23
|
detectFullscreenTUI,
|
|
23
24
|
foldTerminalsRegistry,
|
|
24
25
|
makeTerminalsRegistry,
|
|
25
26
|
planIdleTerminalReminder,
|
|
27
|
+
planShellInit,
|
|
26
28
|
promptStrategyFor,
|
|
27
29
|
registerTerminal,
|
|
30
|
+
sentinelEchoHazard,
|
|
28
31
|
stripAnsi,
|
|
29
32
|
summarizeSessions,
|
|
30
33
|
terminalReminderGraceMs,
|
|
31
34
|
validateSendText,
|
|
32
35
|
validateSignal,
|
|
33
36
|
type ReadCursor,
|
|
37
|
+
type ReadinessTier,
|
|
34
38
|
type TerminalEntry,
|
|
35
39
|
} from '../terminal-core.ts';
|
|
40
|
+
import { swallow } from '../swallow.ts';
|
|
41
|
+
import { pierOption } from '../pier-options.ts';
|
|
42
|
+
|
|
43
|
+
type ToolParams = Record<string, unknown> | undefined;
|
|
44
|
+
|
|
45
|
+
/** Terminal tool actions; the tool schema union and the handler map are both keyed by this list. */
|
|
46
|
+
const TERMINAL_ACTIONS = ['open', 'send', 'wait', 'read', 'signal', 'close', 'list'] as const;
|
|
47
|
+
type TerminalAction = (typeof TERMINAL_ACTIONS)[number];
|
|
48
|
+
type ActionHandler = (params: ToolParams, ctx: unknown) => Promise<unknown>;
|
|
36
49
|
|
|
37
50
|
export interface TerminalStateSlot {
|
|
38
51
|
/** Lets index GC preserve panes that host active terminals. */
|
|
@@ -54,16 +67,19 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
54
67
|
// Keying the surface by this file lets HMR tombstone and replace exactly this registration.
|
|
55
68
|
const scoped = surface.forModule(import.meta.url);
|
|
56
69
|
|
|
57
|
-
/*
|
|
58
|
-
*
|
|
59
|
-
* terminal panes remain exempt from index GC. Workers omit this master-side entry. */
|
|
70
|
+
/* M14 resident terminal tools: dedicated herdr panes preserve shell state without reusing the pi TUI pane,
|
|
71
|
+
* and active terminal panes remain exempt from index GC. Workers omit this master-side entry. */
|
|
60
72
|
|
|
61
73
|
let terminals: TerminalEntry[] = [];
|
|
62
74
|
|
|
75
|
+
/** Last text sent per terminal (in-memory): lets `wait` refuse patterns that match their own echo. */
|
|
76
|
+
const lastSentText = new Map<string, string>();
|
|
77
|
+
|
|
63
78
|
function persistTerminals(): void {
|
|
64
79
|
try {
|
|
65
80
|
pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
|
|
66
|
-
} catch {
|
|
81
|
+
} catch (err) {
|
|
82
|
+
swallow('terminal.persist-registry', err);
|
|
67
83
|
/* Persistence is best-effort because terminal operation must not depend on session logging. */
|
|
68
84
|
}
|
|
69
85
|
}
|
|
@@ -88,7 +104,6 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
88
104
|
persistTerminals();
|
|
89
105
|
}
|
|
90
106
|
|
|
91
|
-
// Expose active panes so index GC does not collect resident terminal shells.
|
|
92
107
|
state.activePaneIds = () => activeTerminalPaneIds(terminals);
|
|
93
108
|
|
|
94
109
|
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
@@ -99,9 +114,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
99
114
|
});
|
|
100
115
|
|
|
101
116
|
// Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// switching branches cannot kill shells that the registry replays on the next session_start.
|
|
117
|
+
// a master that exits without closing them leaks live zsh panes. Session SWITCH never fires this
|
|
118
|
+
// event, so switching branches cannot kill shells that session_start replays from the registry.
|
|
105
119
|
scoped.on('session_shutdown', async () => {
|
|
106
120
|
idleReminderTimer && clearTimeout(idleReminderTimer);
|
|
107
121
|
idleReminderTimer = null;
|
|
@@ -115,14 +129,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
115
129
|
persistTerminals();
|
|
116
130
|
});
|
|
117
131
|
|
|
118
|
-
// Turn-end nudge: remind the model to close terminals idle past the threshold
|
|
119
|
-
// a one-shot background compile left a dead split in the main tab forever). Same delivery
|
|
132
|
+
// Turn-end nudge: remind the model to close terminals idle past the threshold. Same delivery
|
|
120
133
|
// shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
|
|
121
134
|
const piSend = surface.raw as {
|
|
122
135
|
sendMessage?: (
|
|
123
136
|
message: { customType: string; content: string; display?: boolean },
|
|
124
137
|
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
125
|
-
) =>
|
|
138
|
+
) => void;
|
|
126
139
|
};
|
|
127
140
|
let idleReminders = 0;
|
|
128
141
|
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
@@ -143,9 +156,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
143
156
|
reminders: idleReminders,
|
|
144
157
|
});
|
|
145
158
|
if (!plan.due || plan.content == null) return;
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
// agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
|
|
159
|
+
// Goodbye-loop guard: mark the covered terminals nudged NOW (persisted), and deliver the notice as a
|
|
160
|
+
// queued followUp WITHOUT triggerTurn — a reminder must never wake the agent, or the loop never ends.
|
|
149
161
|
const now = Date.now();
|
|
150
162
|
const ids = new Set(plan.ids);
|
|
151
163
|
terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
|
|
@@ -154,30 +166,40 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
154
166
|
const content = plan.content;
|
|
155
167
|
idleReminderTimer = setTimeout(() => {
|
|
156
168
|
idleReminderTimer = null;
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
})();
|
|
169
|
+
const send = piSend.sendMessage;
|
|
170
|
+
if (typeof send !== 'function') return;
|
|
171
|
+
try {
|
|
172
|
+
// Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
|
|
173
|
+
send(
|
|
174
|
+
{ customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
175
|
+
{ deliverAs: 'followUp' },
|
|
176
|
+
);
|
|
177
|
+
} catch {
|
|
178
|
+
}
|
|
169
179
|
}, terminalReminderGraceMs());
|
|
170
180
|
idleReminderTimer.unref?.();
|
|
171
181
|
});
|
|
172
182
|
|
|
173
|
-
const TERM_READ_MAX = Number(
|
|
183
|
+
const TERM_READ_MAX = Number(pierOption('PIER_TERM_READ_MAX') ?? READ_MAX_CHARS) || READ_MAX_CHARS;
|
|
174
184
|
/** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
|
|
175
185
|
const WAIT_DEFAULT_MS = 120_000;
|
|
176
186
|
const WAIT_MAX_MS = 600_000;
|
|
177
187
|
/** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
|
|
178
188
|
const WAIT_TAIL_CHARS = 2_000;
|
|
179
189
|
|
|
180
|
-
|
|
190
|
+
/** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
|
|
191
|
+
const fail = (msg: string): never => toolError(msg);
|
|
192
|
+
|
|
193
|
+
// Action routing: each handler validates its own params, and `open` additionally needs the tool ctx.
|
|
194
|
+
const handlers: Record<TerminalAction, ActionHandler> = {
|
|
195
|
+
open: executeTerminalOpen,
|
|
196
|
+
send: executeTerminalSend,
|
|
197
|
+
wait: executeTerminalWait,
|
|
198
|
+
read: executeTerminalRead,
|
|
199
|
+
signal: executeTerminalSignal,
|
|
200
|
+
close: executeTerminalClose,
|
|
201
|
+
list: executeTerminalList,
|
|
202
|
+
};
|
|
181
203
|
|
|
182
204
|
scoped.registerTool({
|
|
183
205
|
name: 'terminal',
|
|
@@ -186,85 +208,97 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
186
208
|
'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
|
|
187
209
|
'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).',
|
|
188
210
|
'The shell keeps cwd, environment variables, and background processes across calls.',
|
|
189
|
-
'
|
|
190
|
-
'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.',
|
|
191
|
-
'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.',
|
|
192
|
-
'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.',
|
|
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.',
|
|
193
212
|
].join(' '),
|
|
213
|
+
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
|
+
promptGuidelines: [
|
|
215
|
+
'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.',
|
|
216
|
+
'Open once, then send/read/wait against the same terminal_id; do not open a new terminal per command.',
|
|
217
|
+
'After send, read to confirm the command actually started. Multi-line text is executed line by line.',
|
|
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.',
|
|
220
|
+
],
|
|
194
221
|
parameters: Type.Object({
|
|
195
|
-
action: Type.Union(
|
|
196
|
-
Type.Literal('open'),
|
|
197
|
-
Type.Literal('send'),
|
|
198
|
-
Type.Literal('wait'),
|
|
199
|
-
Type.Literal('read'),
|
|
200
|
-
Type.Literal('signal'),
|
|
201
|
-
Type.Literal('close'),
|
|
202
|
-
Type.Literal('list'),
|
|
203
|
-
], { description: 'Terminal operation to perform' }),
|
|
222
|
+
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
|
|
204
223
|
cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
|
|
205
|
-
terminal_id: Type.Optional(Type.String({ description: '[send|wait|read|signal|close]
|
|
224
|
+
terminal_id: Type.Optional(Type.String({ description: '[open] optional requested id for the new terminal (defaults to auto-assigned term-N); [send|wait|read|signal|close] terminal id returned by open' })),
|
|
206
225
|
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
207
226
|
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' })),
|
|
208
|
-
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output' })),
|
|
227
|
+
pattern: Type.Optional(Type.String({ description: '[wait] Literal text or regular expression to wait for in pane output (must not occur in the command text you sent — echoed input matches too)' })),
|
|
209
228
|
regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
|
|
210
229
|
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
|
|
211
230
|
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
212
231
|
max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
|
|
213
232
|
key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
214
233
|
}),
|
|
215
|
-
async execute(_tc, params, _sig, _upd, ctx) {
|
|
234
|
+
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
216
235
|
const action = typeof params?.action === 'string' ? params.action : '';
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
case 'close': return executeTerminalClose(params);
|
|
224
|
-
case 'list': return executeTerminalList();
|
|
225
|
-
default:
|
|
226
|
-
return errText(action
|
|
227
|
-
? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
|
|
228
|
-
: 'action is required (open, send, wait, read, signal, close, list)');
|
|
236
|
+
const handler: ActionHandler | undefined = handlers[action as TerminalAction];
|
|
237
|
+
if (!handler) {
|
|
238
|
+
const valid = TERMINAL_ACTIONS.join(', ');
|
|
239
|
+
return fail(action
|
|
240
|
+
? `unknown action "${action}" (valid: ${valid})`
|
|
241
|
+
: `action is required (${valid})`);
|
|
229
242
|
}
|
|
243
|
+
return handler(params, ctx);
|
|
230
244
|
},
|
|
231
245
|
});
|
|
232
246
|
|
|
233
247
|
/** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
|
|
234
|
-
async function probeReadiness(paneId: string): Promise<
|
|
248
|
+
async function probeReadiness(paneId: string): Promise<ReadinessTier> {
|
|
235
249
|
const prompt = promptStrategyFor();
|
|
236
250
|
try {
|
|
237
|
-
const
|
|
238
|
-
if (matched) return 'prompt';
|
|
251
|
+
const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
252
|
+
if (waitRes.matched) return 'prompt';
|
|
239
253
|
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
240
254
|
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
241
255
|
} catch {
|
|
242
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
243
256
|
return 'busy';
|
|
244
257
|
}
|
|
245
258
|
}
|
|
246
259
|
|
|
247
|
-
|
|
248
|
-
|
|
260
|
+
/** Best-effort `set +H` right after a shell becomes usable (history expansion would wedge it);
|
|
261
|
+
* returns whether the command was injected, so a shell not yet at its prompt retries later. */
|
|
262
|
+
async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
|
|
263
|
+
const init = planShellInit({ strategy: promptStrategyFor(), readiness });
|
|
264
|
+
if (!init.shouldInit || !init.command) return false;
|
|
265
|
+
try {
|
|
266
|
+
await client.sendPaneText(entry.paneId, init.command);
|
|
267
|
+
return true;
|
|
268
|
+
} catch {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
|
|
274
|
+
if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
|
|
249
275
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
250
276
|
: (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
251
277
|
const r = registerTerminal(terminals, {
|
|
252
278
|
paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
|
|
253
279
|
tabId: env.tabId,
|
|
254
280
|
cwd,
|
|
281
|
+
// Adopt the caller-requested id so `open {terminal_id}` + `send {terminal_id}` agree;
|
|
282
|
+
// silently ignoring it made the follow-up send fail on an unknown terminal.
|
|
283
|
+
terminalId: typeof params?.terminal_id === 'string' ? params.terminal_id : null,
|
|
255
284
|
createdAt: Date.now(),
|
|
256
285
|
});
|
|
257
|
-
if (!r.ok) return
|
|
286
|
+
if (!r.ok) return fail(r.error);
|
|
258
287
|
let paneId: string;
|
|
259
288
|
try {
|
|
260
289
|
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
261
290
|
} catch (e) {
|
|
262
|
-
|
|
291
|
+
const hint = herdrUnavailableHint(e);
|
|
292
|
+
return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
|
|
263
293
|
}
|
|
264
294
|
r.entry.paneId = paneId;
|
|
265
295
|
terminals = r.entries;
|
|
266
296
|
persistTerminals();
|
|
267
297
|
const readiness = await probeReadiness(paneId);
|
|
298
|
+
if (await injectShellInit(r.entry, readiness)) {
|
|
299
|
+
r.entry.initialized = true;
|
|
300
|
+
persistTerminals();
|
|
301
|
+
}
|
|
268
302
|
const text = [
|
|
269
303
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
270
304
|
`cwd: ${cwd}`,
|
|
@@ -273,18 +307,22 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
273
307
|
return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
|
|
274
308
|
}
|
|
275
309
|
|
|
276
|
-
async function executeTerminalSend(params) {
|
|
310
|
+
async function executeTerminalSend(params: ToolParams) {
|
|
277
311
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
278
|
-
if (!entry) return
|
|
312
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
279
313
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
280
|
-
if (!v.ok) return
|
|
314
|
+
if (!v.ok) return fail(v.error);
|
|
315
|
+
if (!entry.initialized) {
|
|
316
|
+
await injectShellInit(entry);
|
|
317
|
+
entry.initialized = true;
|
|
318
|
+
persistTerminals();
|
|
319
|
+
}
|
|
281
320
|
if (params?.wait_prompt === true) {
|
|
282
|
-
// Orchestration convention: read before writing. Text typed while a previous command still
|
|
283
|
-
//
|
|
284
|
-
// Refuse with the observed readiness instead of guessing.
|
|
321
|
+
// Orchestration convention: read before writing. Text typed while a previous command still owns the
|
|
322
|
+
// foreground gets queued and fires later, which reads as "the shell ignored me"; refuse with the observed readiness.
|
|
285
323
|
const readiness = await probeReadiness(entry.paneId);
|
|
286
324
|
if (readiness !== 'prompt') {
|
|
287
|
-
return
|
|
325
|
+
return fail(
|
|
288
326
|
`shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
|
|
289
327
|
+ 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
|
|
290
328
|
);
|
|
@@ -293,79 +331,146 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
293
331
|
try {
|
|
294
332
|
await client.sendPaneText(entry.paneId, v.text);
|
|
295
333
|
} catch (e) {
|
|
296
|
-
|
|
334
|
+
// B5: a dead herdr socket would otherwise read as "pane may be closed", which sends the
|
|
335
|
+
// model hunting for a pane that is fine. Prefer the actionable transport sentence.
|
|
336
|
+
return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
|
|
297
337
|
}
|
|
338
|
+
// In-memory only (never persisted): the wait echo-hazard check needs the text this pane last
|
|
339
|
+
// received, because the shell echoes typed commands back into the pane.
|
|
340
|
+
lastSentText.set(entry.terminalId, v.text);
|
|
298
341
|
touchTerminal(entry);
|
|
299
342
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
300
343
|
}
|
|
301
344
|
|
|
302
|
-
async function executeTerminalWait(params) {
|
|
345
|
+
async function executeTerminalWait(params: ToolParams) {
|
|
303
346
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
304
|
-
if (!entry) return
|
|
347
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
305
348
|
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
306
|
-
if (!raw) return
|
|
349
|
+
if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
|
|
307
350
|
const useRegex = params?.regex === true;
|
|
308
351
|
if (useRegex) {
|
|
309
352
|
try {
|
|
310
353
|
new RegExp(raw);
|
|
311
354
|
} catch (e) {
|
|
312
|
-
return
|
|
355
|
+
return fail(`invalid regex: ${(e as Error).message}`);
|
|
313
356
|
}
|
|
314
357
|
}
|
|
358
|
+
// The shell echoes typed commands into the pane, so a pattern contained in the last sent text
|
|
359
|
+
// matches its own echo and "succeeds" instantly, before the job finishes (observed: a
|
|
360
|
+
// minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
|
|
361
|
+
if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
|
|
362
|
+
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
363
|
+
return fail(
|
|
364
|
+
`pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
|
|
365
|
+
+ 'so the wait would match its own echo and return before the job finishes. '
|
|
366
|
+
+ 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
|
|
367
|
+
+ 'Watch progress with action read, or wait for a marker the running command itself prints that does not occur in the command text. '
|
|
368
|
+
+ 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
|
|
369
|
+
+ '(the echo contains `DONE_$?`, which that regex cannot match).',
|
|
370
|
+
);
|
|
371
|
+
}
|
|
315
372
|
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
316
373
|
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
374
|
+
const started = Date.now();
|
|
317
375
|
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
318
|
-
let matched: boolean |
|
|
376
|
+
let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
319
377
|
try {
|
|
320
|
-
|
|
378
|
+
waitResult = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
321
379
|
} catch (e) {
|
|
322
|
-
return
|
|
380
|
+
return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
|
|
323
381
|
}
|
|
324
|
-
if (matched
|
|
325
|
-
// wait-for-text convention: on timeout, hand back the recent tail
|
|
326
|
-
// the next move instead of re-polling blind.
|
|
382
|
+
if (!waitResult.matched) {
|
|
383
|
+
// wait-for-text convention: on timeout, hand back the recent tail instead of re-polling blind.
|
|
327
384
|
let tail = '';
|
|
328
385
|
try {
|
|
329
386
|
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
330
387
|
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
331
388
|
} catch {
|
|
332
|
-
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
333
389
|
}
|
|
334
|
-
const reason =
|
|
390
|
+
const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
|
|
335
391
|
return {
|
|
336
392
|
content: [{ type: 'text', text: `no match within ${timeoutMs}ms (${reason}).${tail ? `\nrecent output tail:\n${tail}` : ''}` }],
|
|
337
|
-
details: { terminal_id: entry.terminalId, matched: false, pattern: raw },
|
|
393
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
338
394
|
};
|
|
339
395
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
396
|
+
// herdr matches the whole pane, including text a prior read already returned, and a second
|
|
397
|
+
// waitForOutput on that same text returns immediately. After a stale hit, poll the unread
|
|
398
|
+
// increment until a new occurrence appears or the original budget is gone — do not re-enter
|
|
399
|
+
// waitForOutput (that busy-loops on the old match).
|
|
400
|
+
const deadline = started + timeoutMs;
|
|
401
|
+
const cursor: ReadCursor | null = entry.readRevision != null
|
|
402
|
+
? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
|
|
403
|
+
: null;
|
|
404
|
+
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
405
|
+
let sawStale = false;
|
|
406
|
+
const staleFailure = {
|
|
407
|
+
content: [{ type: 'text' as const, text: `pattern "${shown}" matched only in already-read output of ${entry.terminalId}; no new output contains it. This is not a new event — do not treat the job as finished. Wait for a marker that has not appeared yet, or use action read.` }],
|
|
408
|
+
details: { terminal_id: entry.terminalId, matched: false, stale: true, pattern: raw },
|
|
343
409
|
};
|
|
410
|
+
while (true) {
|
|
411
|
+
let paneText = '';
|
|
412
|
+
try {
|
|
413
|
+
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
414
|
+
paneText = stripAnsi(read.text);
|
|
415
|
+
} catch {
|
|
416
|
+
if (sawStale) return staleFailure;
|
|
417
|
+
return {
|
|
418
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
419
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
const classified = classifyWaitMatch(cursor, paneText, raw, useRegex);
|
|
423
|
+
if (classified.kind === 'fresh') {
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — new output contains the pattern:\n${classified.excerpt}` }],
|
|
426
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
if (classified.kind !== 'stale') {
|
|
430
|
+
return {
|
|
431
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
432
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
sawStale = true;
|
|
436
|
+
const left = deadline - Date.now();
|
|
437
|
+
if (left <= 0) return staleFailure;
|
|
438
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
439
|
+
setTimeout(resolve, Math.min(400, left));
|
|
440
|
+
await promise;
|
|
441
|
+
}
|
|
344
442
|
}
|
|
345
443
|
|
|
346
|
-
|
|
347
|
-
|
|
444
|
+
/** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
|
|
445
|
+
* panes, so one task can never read another's pane. Returns the error text instead of throwing. */
|
|
446
|
+
async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
|
|
447
|
+
let panes;
|
|
448
|
+
try {
|
|
449
|
+
panes = await client.listPanes();
|
|
450
|
+
} catch (e) {
|
|
451
|
+
return { ok: false, error: herdrUnavailableHint(e) ?? `pane lookup failed: ${(e as Error).message}` };
|
|
452
|
+
}
|
|
453
|
+
const target = panes.find((p) => p.paneId === paneId);
|
|
454
|
+
const ownTab = Boolean(target?.tabId) && target?.tabId === env?.tabId;
|
|
455
|
+
if (!target || !(ownTab || activeTerminalPaneIds(terminals).has(paneId))) {
|
|
456
|
+
return { ok: false, error: 'pane_id read is limited to panes in this session\'s own tab or self-created terminal panes' };
|
|
457
|
+
}
|
|
458
|
+
return { ok: true, paneId };
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
async function executeTerminalRead(params: ToolParams) {
|
|
462
|
+
let paneId: string;
|
|
348
463
|
let entry: TerminalEntry | null = null;
|
|
349
464
|
if (typeof params?.terminal_id === 'string') {
|
|
350
465
|
entry = findOpenTerminal(params.terminal_id);
|
|
351
|
-
if (!entry) return
|
|
466
|
+
if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
|
|
352
467
|
paneId = entry.paneId;
|
|
353
468
|
} else if (typeof params?.pane_id === 'string') {
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
358
|
-
const ownTab = target?.tabId && target.tabId === env?.tabId;
|
|
359
|
-
const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
|
|
360
|
-
if (!target || (!ownTab && !ownTerminal)) {
|
|
361
|
-
return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
|
|
362
|
-
}
|
|
363
|
-
paneId = params.pane_id;
|
|
364
|
-
} catch (e) {
|
|
365
|
-
return errText(`pane lookup failed: ${(e as Error).message}`);
|
|
366
|
-
}
|
|
469
|
+
const resolved = await resolveDirectPaneId(params.pane_id);
|
|
470
|
+
if (!resolved.ok) return fail(resolved.error);
|
|
471
|
+
paneId = resolved.paneId;
|
|
367
472
|
} else {
|
|
368
|
-
return
|
|
473
|
+
return fail('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
369
474
|
}
|
|
370
475
|
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
371
476
|
? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
|
|
@@ -373,7 +478,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
373
478
|
try {
|
|
374
479
|
read = await client.readPane(paneId, { stripAnsi: false });
|
|
375
480
|
} catch (e) {
|
|
376
|
-
return
|
|
481
|
+
return fail(herdrUnavailableHint(e) ?? `read failed (pane may be closed): ${(e as Error).message}`);
|
|
377
482
|
}
|
|
378
483
|
// T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
|
|
379
484
|
const tui = detectFullscreenTUI(read.text);
|
|
@@ -390,7 +495,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
390
495
|
}
|
|
391
496
|
const prev: ReadCursor | null = entry
|
|
392
497
|
? (entry.readRevision != null
|
|
393
|
-
? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
|
|
498
|
+
? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
|
|
394
499
|
: null)
|
|
395
500
|
: null;
|
|
396
501
|
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
@@ -412,24 +517,24 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
412
517
|
};
|
|
413
518
|
}
|
|
414
519
|
|
|
415
|
-
async function executeTerminalSignal(params) {
|
|
520
|
+
async function executeTerminalSignal(params: ToolParams) {
|
|
416
521
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
417
|
-
if (!entry) return
|
|
522
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
418
523
|
const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
|
|
419
|
-
if (!v.ok) return
|
|
524
|
+
if (!v.ok) return fail(v.error);
|
|
420
525
|
try {
|
|
421
526
|
await client.sendPaneKeys(entry.paneId, [v.key]);
|
|
422
527
|
} catch (e) {
|
|
423
|
-
return
|
|
528
|
+
return fail(herdrUnavailableHint(e) ?? `signal failed (pane may be closed): ${(e as Error).message}`);
|
|
424
529
|
}
|
|
425
530
|
touchTerminal(entry);
|
|
426
531
|
return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
|
|
427
532
|
}
|
|
428
533
|
|
|
429
|
-
async function executeTerminalClose(params) {
|
|
534
|
+
async function executeTerminalClose(params: ToolParams) {
|
|
430
535
|
const id = params?.terminal_id;
|
|
431
536
|
const entry = terminals.find((t) => t.terminalId === id);
|
|
432
|
-
if (!entry) return
|
|
537
|
+
if (!entry) return fail(`unknown terminal "${String(id)}" (see action list)`);
|
|
433
538
|
if (entry.status === 'open') {
|
|
434
539
|
try {
|
|
435
540
|
await client.closePane(entry.paneId);
|
|
@@ -438,17 +543,24 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
438
543
|
}
|
|
439
544
|
}
|
|
440
545
|
terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
|
|
546
|
+
lastSentText.delete(entry.terminalId);
|
|
441
547
|
persistTerminals();
|
|
442
548
|
return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
|
|
443
549
|
}
|
|
444
550
|
|
|
445
551
|
async function executeTerminalList() {
|
|
446
|
-
let
|
|
552
|
+
let livePanes: import('../herdr-client.ts').PaneListItem[] = [];
|
|
447
553
|
try {
|
|
448
|
-
|
|
554
|
+
livePanes = await client.listPanes();
|
|
449
555
|
} catch {
|
|
450
556
|
/* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
|
|
451
557
|
}
|
|
558
|
+
const livePaneIds = livePanes.map((p) => p.paneId);
|
|
559
|
+
const titleByPaneId = new Map<string, string>();
|
|
560
|
+
for (const p of livePanes) {
|
|
561
|
+
const cleanTitle = p.terminalTitleStripped ?? p.terminalTitle;
|
|
562
|
+
if (cleanTitle) titleByPaneId.set(p.paneId, cleanTitle);
|
|
563
|
+
}
|
|
452
564
|
const s = summarizeSessions(terminals, livePaneIds);
|
|
453
565
|
// T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
|
|
454
566
|
if (s.stalePaneIds.length > 0) {
|
|
@@ -462,9 +574,11 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
462
574
|
if (s.terminals.length === 0) {
|
|
463
575
|
return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
|
|
464
576
|
}
|
|
465
|
-
const lines = s.terminals.map((t) =>
|
|
466
|
-
|
|
467
|
-
|
|
577
|
+
const lines = s.terminals.map((t) => {
|
|
578
|
+
const cleanTitle = titleByPaneId.get(t.paneId);
|
|
579
|
+
const titleTag = cleanTitle && cleanTitle !== t.label ? ` title="${cleanTitle}"` : '';
|
|
580
|
+
return `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}${titleTag}`;
|
|
581
|
+
});
|
|
468
582
|
const note = s.stalePaneIds.length > 0
|
|
469
583
|
? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
|
|
470
584
|
: '';
|