pi-pier 0.3.0 → 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 +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
|
@@ -1,10 +1,8 @@
|
|
|
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';
|
|
@@ -12,9 +10,6 @@ import type { PiSurface } from '../pi-surface.ts';
|
|
|
12
10
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
13
11
|
import { herdrUnavailableHint } from '../herdr-client.ts';
|
|
14
12
|
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;;
|
|
18
13
|
import {
|
|
19
14
|
READINESS_TIMEOUT_MS,
|
|
20
15
|
READ_MAX_CHARS,
|
|
@@ -24,6 +19,7 @@ import {
|
|
|
24
19
|
classifyReadiness,
|
|
25
20
|
closeTerminal,
|
|
26
21
|
computeIncrement,
|
|
22
|
+
classifyWaitMatch,
|
|
27
23
|
detectFullscreenTUI,
|
|
28
24
|
foldTerminalsRegistry,
|
|
29
25
|
makeTerminalsRegistry,
|
|
@@ -31,17 +27,26 @@ import {
|
|
|
31
27
|
planShellInit,
|
|
32
28
|
promptStrategyFor,
|
|
33
29
|
registerTerminal,
|
|
30
|
+
sentinelEchoHazard,
|
|
34
31
|
stripAnsi,
|
|
35
32
|
summarizeSessions,
|
|
36
33
|
terminalReminderGraceMs,
|
|
37
34
|
validateSendText,
|
|
38
35
|
validateSignal,
|
|
39
36
|
type ReadCursor,
|
|
37
|
+
type ReadinessTier,
|
|
40
38
|
type TerminalEntry,
|
|
41
39
|
} from '../terminal-core.ts';
|
|
42
40
|
import { swallow } from '../swallow.ts';
|
|
43
41
|
import { pierOption } from '../pier-options.ts';
|
|
44
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>;
|
|
49
|
+
|
|
45
50
|
export interface TerminalStateSlot {
|
|
46
51
|
/** Lets index GC preserve panes that host active terminals. */
|
|
47
52
|
activePaneIds: () => Set<string>;
|
|
@@ -62,12 +67,14 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
62
67
|
// Keying the surface by this file lets HMR tombstone and replace exactly this registration.
|
|
63
68
|
const scoped = surface.forModule(import.meta.url);
|
|
64
69
|
|
|
65
|
-
/*
|
|
66
|
-
*
|
|
67
|
-
* 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. */
|
|
68
72
|
|
|
69
73
|
let terminals: TerminalEntry[] = [];
|
|
70
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
|
+
|
|
71
78
|
function persistTerminals(): void {
|
|
72
79
|
try {
|
|
73
80
|
pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
|
|
@@ -97,7 +104,6 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
97
104
|
persistTerminals();
|
|
98
105
|
}
|
|
99
106
|
|
|
100
|
-
// Expose active panes so index GC does not collect resident terminal shells.
|
|
101
107
|
state.activePaneIds = () => activeTerminalPaneIds(terminals);
|
|
102
108
|
|
|
103
109
|
scoped.on('session_start', async (_event: unknown, eventCtx: unknown) => {
|
|
@@ -108,9 +114,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
108
114
|
});
|
|
109
115
|
|
|
110
116
|
// Session teardown (quit / Ctrl+D / SIGTERM) is the last chance to reclaim resident shells:
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
// 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.
|
|
114
119
|
scoped.on('session_shutdown', async () => {
|
|
115
120
|
idleReminderTimer && clearTimeout(idleReminderTimer);
|
|
116
121
|
idleReminderTimer = null;
|
|
@@ -124,14 +129,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
124
129
|
persistTerminals();
|
|
125
130
|
});
|
|
126
131
|
|
|
127
|
-
// Turn-end nudge: remind the model to close terminals idle past the threshold
|
|
128
|
-
// 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
|
|
129
133
|
// shape as the todo stop reminder: grace window, cancel on agent_start, capped followUp.
|
|
130
134
|
const piSend = surface.raw as {
|
|
131
135
|
sendMessage?: (
|
|
132
136
|
message: { customType: string; content: string; display?: boolean },
|
|
133
137
|
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
134
|
-
) =>
|
|
138
|
+
) => void;
|
|
135
139
|
};
|
|
136
140
|
let idleReminders = 0;
|
|
137
141
|
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
@@ -152,9 +156,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
152
156
|
reminders: idleReminders,
|
|
153
157
|
});
|
|
154
158
|
if (!plan.due || plan.content == null) return;
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
// 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.
|
|
158
161
|
const now = Date.now();
|
|
159
162
|
const ids = new Set(plan.ids);
|
|
160
163
|
terminals = terminals.map((t) => (ids.has(t.terminalId) ? { ...t, nudgedAt: now } : t));
|
|
@@ -163,18 +166,16 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
163
166
|
const content = plan.content;
|
|
164
167
|
idleReminderTimer = setTimeout(() => {
|
|
165
168
|
idleReminderTimer = null;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
})();
|
|
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
|
+
}
|
|
178
179
|
}, terminalReminderGraceMs());
|
|
179
180
|
idleReminderTimer.unref?.();
|
|
180
181
|
});
|
|
@@ -189,6 +190,17 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
189
190
|
/** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
|
|
190
191
|
const fail = (msg: string): never => toolError(msg);
|
|
191
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
|
+
};
|
|
203
|
+
|
|
192
204
|
scoped.registerTool({
|
|
193
205
|
name: 'terminal',
|
|
194
206
|
label: 'Terminal',
|
|
@@ -196,10 +208,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
196
208
|
'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
|
|
197
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).',
|
|
198
210
|
'The shell keeps cwd, environment variables, and background processes across calls.',
|
|
199
|
-
'
|
|
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.',
|
|
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.',
|
|
203
212
|
].join(' '),
|
|
204
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.',
|
|
205
214
|
promptGuidelines: [
|
|
@@ -210,20 +219,12 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
210
219
|
'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
|
|
211
220
|
],
|
|
212
221
|
parameters: Type.Object({
|
|
213
|
-
action: Type.Union(
|
|
214
|
-
Type.Literal('open'),
|
|
215
|
-
Type.Literal('send'),
|
|
216
|
-
Type.Literal('wait'),
|
|
217
|
-
Type.Literal('read'),
|
|
218
|
-
Type.Literal('signal'),
|
|
219
|
-
Type.Literal('close'),
|
|
220
|
-
Type.Literal('list'),
|
|
221
|
-
], { description: 'Terminal operation to perform' }),
|
|
222
|
+
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
|
|
222
223
|
cwd: Type.Optional(Type.String({ description: '[open] Working directory for the shell (defaults to session cwd)' })),
|
|
223
|
-
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' })),
|
|
224
225
|
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
225
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' })),
|
|
226
|
-
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)' })),
|
|
227
228
|
regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
|
|
228
229
|
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
|
|
229
230
|
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
@@ -232,39 +233,43 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
232
233
|
}),
|
|
233
234
|
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
234
235
|
const action = typeof params?.action === 'string' ? params.action : '';
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
case 'close': return executeTerminalClose(params);
|
|
242
|
-
case 'list': return executeTerminalList();
|
|
243
|
-
default:
|
|
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)');
|
|
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})`);
|
|
247
242
|
}
|
|
243
|
+
return handler(params, ctx);
|
|
248
244
|
},
|
|
249
245
|
});
|
|
250
246
|
|
|
251
247
|
/** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
|
|
252
|
-
async function probeReadiness(paneId: string): Promise<
|
|
248
|
+
async function probeReadiness(paneId: string): Promise<ReadinessTier> {
|
|
253
249
|
const prompt = promptStrategyFor();
|
|
254
250
|
try {
|
|
255
251
|
const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
256
|
-
|
|
257
|
-
? (waitRes as { matched: boolean }).matched
|
|
258
|
-
: Boolean(waitRes);
|
|
259
|
-
if (isMatched) return 'prompt';
|
|
252
|
+
if (waitRes.matched) return 'prompt';
|
|
260
253
|
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
261
254
|
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
262
255
|
} catch {
|
|
263
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
264
256
|
return 'busy';
|
|
265
257
|
}
|
|
266
258
|
}
|
|
267
259
|
|
|
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
|
+
|
|
268
273
|
async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
|
|
269
274
|
if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
|
|
270
275
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
@@ -273,6 +278,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
273
278
|
paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
|
|
274
279
|
tabId: env.tabId,
|
|
275
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,
|
|
276
284
|
createdAt: Date.now(),
|
|
277
285
|
});
|
|
278
286
|
if (!r.ok) return fail(r.error);
|
|
@@ -287,16 +295,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
287
295
|
terminals = r.entries;
|
|
288
296
|
persistTerminals();
|
|
289
297
|
const readiness = await probeReadiness(paneId);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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 */
|
|
299
|
-
}
|
|
298
|
+
if (await injectShellInit(r.entry, readiness)) {
|
|
299
|
+
r.entry.initialized = true;
|
|
300
|
+
persistTerminals();
|
|
300
301
|
}
|
|
301
302
|
const text = [
|
|
302
303
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
@@ -312,22 +313,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
312
313
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
313
314
|
if (!v.ok) return fail(v.error);
|
|
314
315
|
if (!entry.initialized) {
|
|
315
|
-
|
|
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
|
-
}
|
|
316
|
+
await injectShellInit(entry);
|
|
324
317
|
entry.initialized = true;
|
|
325
318
|
persistTerminals();
|
|
326
319
|
}
|
|
327
320
|
if (params?.wait_prompt === true) {
|
|
328
|
-
// Orchestration convention: read before writing. Text typed while a previous command still
|
|
329
|
-
//
|
|
330
|
-
// 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.
|
|
331
323
|
const readiness = await probeReadiness(entry.paneId);
|
|
332
324
|
if (readiness !== 'prompt') {
|
|
333
325
|
return fail(
|
|
@@ -343,6 +335,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
343
335
|
// model hunting for a pane that is fine. Prefer the actionable transport sentence.
|
|
344
336
|
return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
|
|
345
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);
|
|
346
341
|
touchTerminal(entry);
|
|
347
342
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
348
343
|
}
|
|
@@ -360,33 +355,37 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
360
355
|
return fail(`invalid regex: ${(e as Error).message}`);
|
|
361
356
|
}
|
|
362
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
|
+
}
|
|
363
372
|
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
364
373
|
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
374
|
+
const started = Date.now();
|
|
365
375
|
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
366
376
|
let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
367
377
|
try {
|
|
368
|
-
|
|
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
|
+
waitResult = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
378
379
|
} catch (e) {
|
|
379
380
|
return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
|
|
380
381
|
}
|
|
381
382
|
if (!waitResult.matched) {
|
|
382
|
-
// wait-for-text convention: on timeout, hand back the recent tail
|
|
383
|
-
// the next move instead of re-polling blind.
|
|
383
|
+
// wait-for-text convention: on timeout, hand back the recent tail instead of re-polling blind.
|
|
384
384
|
let tail = '';
|
|
385
385
|
try {
|
|
386
386
|
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
387
387
|
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
388
388
|
} catch {
|
|
389
|
-
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
390
389
|
}
|
|
391
390
|
const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
|
|
392
391
|
return {
|
|
@@ -394,47 +393,87 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
394
393
|
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
395
394
|
};
|
|
396
395
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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 },
|
|
400
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
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
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 };
|
|
401
459
|
}
|
|
402
460
|
|
|
403
461
|
async function executeTerminalRead(params: ToolParams) {
|
|
404
|
-
let paneId: string
|
|
462
|
+
let paneId: string;
|
|
405
463
|
let entry: TerminalEntry | null = null;
|
|
406
464
|
if (typeof params?.terminal_id === 'string') {
|
|
407
465
|
entry = findOpenTerminal(params.terminal_id);
|
|
408
466
|
if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
|
|
409
467
|
paneId = entry.paneId;
|
|
410
468
|
} else if (typeof params?.pane_id === 'string') {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
let allowed = false;
|
|
415
|
-
let lookupError: unknown = null;
|
|
416
|
-
try {
|
|
417
|
-
const panes = await client.listPanes();
|
|
418
|
-
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
419
|
-
const ownTab = target?.tabId && target.tabId === env?.tabId;
|
|
420
|
-
const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
|
|
421
|
-
allowed = Boolean(target) && Boolean(ownTab || ownTerminal);
|
|
422
|
-
if (allowed) paneId = params.pane_id;
|
|
423
|
-
} catch (e) {
|
|
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');
|
|
431
|
-
}
|
|
469
|
+
const resolved = await resolveDirectPaneId(params.pane_id);
|
|
470
|
+
if (!resolved.ok) return fail(resolved.error);
|
|
471
|
+
paneId = resolved.paneId;
|
|
432
472
|
} else {
|
|
433
473
|
return fail('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
434
474
|
}
|
|
435
475
|
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
436
476
|
? 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)');
|
|
438
477
|
let read: { text: string; revision: number; truncated: boolean };
|
|
439
478
|
try {
|
|
440
479
|
read = await client.readPane(paneId, { stripAnsi: false });
|
|
@@ -504,6 +543,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
504
543
|
}
|
|
505
544
|
}
|
|
506
545
|
terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
|
|
546
|
+
lastSentText.delete(entry.terminalId);
|
|
507
547
|
persistTerminals();
|
|
508
548
|
return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
|
|
509
549
|
}
|