pi-pier 0.3.0 → 0.4.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 +16 -12
- package/package.json +3 -3
- 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 +10 -13
- package/src/compact-coordinator.ts +279 -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/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- 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/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -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 +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- 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 +61 -22
- 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 +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -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/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- 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/focus-poller.ts +0 -244
- 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,20 +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
|
-
import
|
|
9
|
+
import { cordisGet } from '../cordis-services.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,50 +19,62 @@ import {
|
|
|
24
19
|
classifyReadiness,
|
|
25
20
|
closeTerminal,
|
|
26
21
|
computeIncrement,
|
|
22
|
+
classifyWaitMatch,
|
|
27
23
|
detectFullscreenTUI,
|
|
28
24
|
foldTerminalsRegistry,
|
|
29
25
|
makeTerminalsRegistry,
|
|
30
|
-
|
|
26
|
+
planTerminalReminder,
|
|
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>;
|
|
48
53
|
}
|
|
49
54
|
|
|
50
|
-
interface TerminalDeps {
|
|
55
|
+
export interface TerminalDeps {
|
|
51
56
|
client: HerdrClientLike;
|
|
52
57
|
env: { paneId: string; tabId: string } | null;
|
|
53
58
|
state: TerminalStateSlot;
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
export default function terminalPlugin(ctx: Context): void {
|
|
57
|
-
const surface = ctx
|
|
58
|
-
const { client, env, state } = ctx
|
|
62
|
+
const surface = cordisGet(ctx, 'pi-herdr.surface');
|
|
63
|
+
const { client, env, state } = cordisGet(ctx, 'pi-herdr.terminal-deps');
|
|
59
64
|
const pi = surface.raw as {
|
|
60
65
|
appendEntry?: (customType: string, data: unknown) => void;
|
|
61
66
|
};
|
|
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,18 +129,20 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
124
129
|
persistTerminals();
|
|
125
130
|
});
|
|
126
131
|
|
|
127
|
-
// Turn-end nudge: remind the model to close terminals
|
|
128
|
-
//
|
|
129
|
-
//
|
|
132
|
+
// Turn-end nudge: remind the model to close terminals it just used, then once more after the idle
|
|
133
|
+
// threshold. Same delivery shape as the todo stop reminder: grace window, cancel on agent_start,
|
|
134
|
+
// capped per terminal.
|
|
130
135
|
const piSend = surface.raw as {
|
|
131
136
|
sendMessage?: (
|
|
132
137
|
message: { customType: string; content: string; display?: boolean },
|
|
133
138
|
opts?: { deliverAs?: string; triggerTurn?: boolean },
|
|
134
|
-
) =>
|
|
139
|
+
) => void;
|
|
135
140
|
};
|
|
136
|
-
|
|
141
|
+
/** Start of the current agent run; terminals touched since then count as "used this turn". */
|
|
142
|
+
let turnStartedAt: number | null = null;
|
|
137
143
|
let idleReminderTimer: NodeJS.Timeout | null = null;
|
|
138
144
|
scoped.on('agent_start', () => {
|
|
145
|
+
turnStartedAt = Date.now();
|
|
139
146
|
if (idleReminderTimer !== null) {
|
|
140
147
|
clearTimeout(idleReminderTimer);
|
|
141
148
|
idleReminderTimer = null;
|
|
@@ -146,35 +153,46 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
146
153
|
clearTimeout(idleReminderTimer);
|
|
147
154
|
idleReminderTimer = null;
|
|
148
155
|
}
|
|
149
|
-
const
|
|
150
|
-
|
|
156
|
+
const open = await Promise.all(
|
|
157
|
+
terminals
|
|
158
|
+
.filter((t) => t.status === 'open')
|
|
159
|
+
.map(async (t) => {
|
|
160
|
+
// A pane running a foreground job (dev server, REPL, load test) is in use even when the
|
|
161
|
+
// last tool interaction is old: nudge only after it is back at a shell prompt.
|
|
162
|
+
const info = await client.processInfo(t.paneId).catch(() => null);
|
|
163
|
+
const busy = info?.shellPid != null
|
|
164
|
+
&& info.foreground.some((p) => p.pid != null && p.pid !== info.shellPid);
|
|
165
|
+
return { ...t, busy };
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
const plan = planTerminalReminder({
|
|
169
|
+
open,
|
|
151
170
|
now: Date.now(),
|
|
152
|
-
|
|
171
|
+
turnStartedAt,
|
|
153
172
|
});
|
|
173
|
+
turnStartedAt = null;
|
|
154
174
|
if (!plan.due || plan.content == null) return;
|
|
155
|
-
//
|
|
156
|
-
// notice as a queued followUp WITHOUT triggerTurn — a reminder
|
|
157
|
-
// agent, or a farewell exchange loops: wake → polite goodbye → settle → nudge → wake …
|
|
175
|
+
// Goodbye-loop guard: bump each covered terminal's nudge count NOW (persisted), and deliver the
|
|
176
|
+
// notice as a queued followUp WITHOUT triggerTurn — a reminder must never wake the agent.
|
|
158
177
|
const now = Date.now();
|
|
159
178
|
const ids = new Set(plan.ids);
|
|
160
|
-
terminals = terminals.map((t) => (ids.has(t.terminalId)
|
|
179
|
+
terminals = terminals.map((t) => (ids.has(t.terminalId)
|
|
180
|
+
? { ...t, nudges: (t.nudges ?? (t.nudgedAt != null ? 1 : 0)) + 1, nudgedAt: now }
|
|
181
|
+
: t));
|
|
161
182
|
persistTerminals();
|
|
162
|
-
idleReminders += 1;
|
|
163
183
|
const content = plan.content;
|
|
164
184
|
idleReminderTimer = setTimeout(() => {
|
|
165
185
|
idleReminderTimer = null;
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
})();
|
|
186
|
+
const send = piSend.sendMessage;
|
|
187
|
+
if (typeof send !== 'function') return;
|
|
188
|
+
try {
|
|
189
|
+
// Fire-and-forget in pi (void); a synchronous throw is still swallowed here.
|
|
190
|
+
send(
|
|
191
|
+
{ customType: TERM_REMINDER_CUSTOM_TYPE, content, display: true },
|
|
192
|
+
{ deliverAs: 'followUp' },
|
|
193
|
+
);
|
|
194
|
+
} catch {
|
|
195
|
+
}
|
|
178
196
|
}, terminalReminderGraceMs());
|
|
179
197
|
idleReminderTimer.unref?.();
|
|
180
198
|
});
|
|
@@ -189,6 +207,17 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
189
207
|
/** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
|
|
190
208
|
const fail = (msg: string): never => toolError(msg);
|
|
191
209
|
|
|
210
|
+
// Action routing: each handler validates its own params, and `open` additionally needs the tool ctx.
|
|
211
|
+
const handlers: Record<TerminalAction, ActionHandler> = {
|
|
212
|
+
open: executeTerminalOpen,
|
|
213
|
+
send: executeTerminalSend,
|
|
214
|
+
wait: executeTerminalWait,
|
|
215
|
+
read: executeTerminalRead,
|
|
216
|
+
signal: executeTerminalSignal,
|
|
217
|
+
close: executeTerminalClose,
|
|
218
|
+
list: executeTerminalList,
|
|
219
|
+
};
|
|
220
|
+
|
|
192
221
|
scoped.registerTool({
|
|
193
222
|
name: 'terminal',
|
|
194
223
|
label: 'Terminal',
|
|
@@ -196,10 +225,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
196
225
|
'Manage persistent interactive terminals (resident shells in dedicated herdr panes).',
|
|
197
226
|
'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
227
|
'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.',
|
|
228
|
+
'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
229
|
].join(' '),
|
|
204
230
|
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
231
|
promptGuidelines: [
|
|
@@ -210,20 +236,12 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
210
236
|
'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
|
|
211
237
|
],
|
|
212
238
|
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' }),
|
|
239
|
+
action: Type.Union(TERMINAL_ACTIONS.map((name) => Type.Literal(name)), { description: 'Terminal operation to perform' }),
|
|
222
240
|
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]
|
|
241
|
+
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
242
|
text: Type.Optional(Type.String({ description: '[send] Command text to type and run (Enter appended automatically)' })),
|
|
225
243
|
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' })),
|
|
244
|
+
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
245
|
regex: Type.Optional(Type.Boolean({ description: '[wait] Treat pattern as a regular expression (default: literal substring)' })),
|
|
228
246
|
timeout_ms: Type.Optional(Type.Number({ description: '[wait] Give up after this many ms (default 120000, max 600000)' })),
|
|
229
247
|
pane_id: Type.Optional(Type.String({ description: '[read] Direct pane read (limited to own tab panes only)' })),
|
|
@@ -232,39 +250,43 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
232
250
|
}),
|
|
233
251
|
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
234
252
|
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)');
|
|
253
|
+
const handler: ActionHandler | undefined = handlers[action as TerminalAction];
|
|
254
|
+
if (!handler) {
|
|
255
|
+
const valid = TERMINAL_ACTIONS.join(', ');
|
|
256
|
+
return fail(action
|
|
257
|
+
? `unknown action "${action}" (valid: ${valid})`
|
|
258
|
+
: `action is required (${valid})`);
|
|
247
259
|
}
|
|
260
|
+
return handler(params, ctx);
|
|
248
261
|
},
|
|
249
262
|
});
|
|
250
263
|
|
|
251
264
|
/** Probe whether the shell sits at its interactive prompt (advisory: probe failures degrade to 'busy'). */
|
|
252
|
-
async function probeReadiness(paneId: string): Promise<
|
|
265
|
+
async function probeReadiness(paneId: string): Promise<ReadinessTier> {
|
|
253
266
|
const prompt = promptStrategyFor();
|
|
254
267
|
try {
|
|
255
268
|
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';
|
|
269
|
+
if (waitRes.matched) return 'prompt';
|
|
260
270
|
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
261
271
|
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
262
272
|
} catch {
|
|
263
|
-
/* Readiness is advisory, so probe failures still leave a usable terminal. */
|
|
264
273
|
return 'busy';
|
|
265
274
|
}
|
|
266
275
|
}
|
|
267
276
|
|
|
277
|
+
/** Best-effort `set +H` right after a shell becomes usable (history expansion would wedge it);
|
|
278
|
+
* returns whether the command was injected, so a shell not yet at its prompt retries later. */
|
|
279
|
+
async function injectShellInit(entry: TerminalEntry, readiness?: ReadinessTier): Promise<boolean> {
|
|
280
|
+
const init = planShellInit({ strategy: promptStrategyFor(), readiness });
|
|
281
|
+
if (!init.shouldInit || !init.command) return false;
|
|
282
|
+
try {
|
|
283
|
+
await client.sendPaneText(entry.paneId, init.command);
|
|
284
|
+
return true;
|
|
285
|
+
} catch {
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
268
290
|
async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
|
|
269
291
|
if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
|
|
270
292
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
@@ -273,6 +295,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
273
295
|
paneId: env.paneId, // Reserve a valid id until split returns the terminal pane.
|
|
274
296
|
tabId: env.tabId,
|
|
275
297
|
cwd,
|
|
298
|
+
// Adopt the caller-requested id so `open {terminal_id}` + `send {terminal_id}` agree;
|
|
299
|
+
// silently ignoring it made the follow-up send fail on an unknown terminal.
|
|
300
|
+
terminalId: typeof params?.terminal_id === 'string' ? params.terminal_id : null,
|
|
276
301
|
createdAt: Date.now(),
|
|
277
302
|
});
|
|
278
303
|
if (!r.ok) return fail(r.error);
|
|
@@ -287,16 +312,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
287
312
|
terminals = r.entries;
|
|
288
313
|
persistTerminals();
|
|
289
314
|
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
|
-
}
|
|
315
|
+
if (await injectShellInit(r.entry, readiness)) {
|
|
316
|
+
r.entry.initialized = true;
|
|
317
|
+
persistTerminals();
|
|
300
318
|
}
|
|
301
319
|
const text = [
|
|
302
320
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
@@ -312,22 +330,13 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
312
330
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
313
331
|
if (!v.ok) return fail(v.error);
|
|
314
332
|
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
|
-
}
|
|
333
|
+
await injectShellInit(entry);
|
|
324
334
|
entry.initialized = true;
|
|
325
335
|
persistTerminals();
|
|
326
336
|
}
|
|
327
337
|
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.
|
|
338
|
+
// Orchestration convention: read before writing. Text typed while a previous command still owns the
|
|
339
|
+
// foreground gets queued and fires later, which reads as "the shell ignored me"; refuse with the observed readiness.
|
|
331
340
|
const readiness = await probeReadiness(entry.paneId);
|
|
332
341
|
if (readiness !== 'prompt') {
|
|
333
342
|
return fail(
|
|
@@ -343,6 +352,9 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
343
352
|
// model hunting for a pane that is fine. Prefer the actionable transport sentence.
|
|
344
353
|
return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
|
|
345
354
|
}
|
|
355
|
+
// In-memory only (never persisted): the wait echo-hazard check needs the text this pane last
|
|
356
|
+
// received, because the shell echoes typed commands back into the pane.
|
|
357
|
+
lastSentText.set(entry.terminalId, v.text);
|
|
346
358
|
touchTerminal(entry);
|
|
347
359
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
348
360
|
}
|
|
@@ -350,6 +362,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
350
362
|
async function executeTerminalWait(params: ToolParams) {
|
|
351
363
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
352
364
|
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
365
|
+
// Waiting on a pane is use: keep it out of the "used this turn" cleanup nudge at settle.
|
|
366
|
+
touchTerminal(entry);
|
|
353
367
|
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
354
368
|
if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
|
|
355
369
|
const useRegex = params?.regex === true;
|
|
@@ -360,33 +374,37 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
360
374
|
return fail(`invalid regex: ${(e as Error).message}`);
|
|
361
375
|
}
|
|
362
376
|
}
|
|
377
|
+
// The shell echoes typed commands into the pane, so a pattern contained in the last sent text
|
|
378
|
+
// matches its own echo and "succeeds" instantly, before the job finishes (observed: a
|
|
379
|
+
// minutes-long batch matched in 112ms on the echo of its own `echo SENTINEL`).
|
|
380
|
+
if (sentinelEchoHazard({ pattern: raw, regex: useRegex, lastSentText: lastSentText.get(entry.terminalId) })) {
|
|
381
|
+
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
382
|
+
return fail(
|
|
383
|
+
`pattern "${shown}" also appears in the command text sent to ${entry.terminalId} — the shell echoes typed commands, `
|
|
384
|
+
+ 'so the wait would match its own echo and return before the job finishes. '
|
|
385
|
+
+ 'That command is still running: do NOT re-send it (text typed behind a running command just queues). '
|
|
386
|
+
+ 'Watch progress with action read, or wait for a marker the running command itself prints that does not occur in the command text. '
|
|
387
|
+
+ 'For future sends, use a sentinel distinguishable from its echo: send `cmd; echo DONE_$?` and wait with regex "^DONE_[0-9]" '
|
|
388
|
+
+ '(the echo contains `DONE_$?`, which that regex cannot match).',
|
|
389
|
+
);
|
|
390
|
+
}
|
|
363
391
|
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
364
392
|
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
393
|
+
const started = Date.now();
|
|
365
394
|
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
366
395
|
let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
367
396
|
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
|
-
}
|
|
397
|
+
waitResult = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
378
398
|
} catch (e) {
|
|
379
399
|
return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
|
|
380
400
|
}
|
|
381
401
|
if (!waitResult.matched) {
|
|
382
|
-
// wait-for-text convention: on timeout, hand back the recent tail
|
|
383
|
-
// the next move instead of re-polling blind.
|
|
402
|
+
// wait-for-text convention: on timeout, hand back the recent tail instead of re-polling blind.
|
|
384
403
|
let tail = '';
|
|
385
404
|
try {
|
|
386
405
|
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
387
406
|
tail = stripAnsi(read.text).slice(-WAIT_TAIL_CHARS);
|
|
388
407
|
} catch {
|
|
389
|
-
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
390
408
|
}
|
|
391
409
|
const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
|
|
392
410
|
return {
|
|
@@ -394,47 +412,87 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
394
412
|
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
395
413
|
};
|
|
396
414
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
415
|
+
// herdr matches the whole pane, including text a prior read already returned, and a second
|
|
416
|
+
// waitForOutput on that same text returns immediately. After a stale hit, poll the unread
|
|
417
|
+
// increment until a new occurrence appears or the original budget is gone — do not re-enter
|
|
418
|
+
// waitForOutput (that busy-loops on the old match).
|
|
419
|
+
const deadline = started + timeoutMs;
|
|
420
|
+
const cursor: ReadCursor | null = entry.readRevision != null
|
|
421
|
+
? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
|
|
422
|
+
: null;
|
|
423
|
+
const shown = raw.length > 80 ? `${raw.slice(0, 80)}…` : raw;
|
|
424
|
+
let sawStale = false;
|
|
425
|
+
const staleFailure = {
|
|
426
|
+
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.` }],
|
|
427
|
+
details: { terminal_id: entry.terminalId, matched: false, stale: true, pattern: raw },
|
|
400
428
|
};
|
|
429
|
+
while (true) {
|
|
430
|
+
let paneText = '';
|
|
431
|
+
try {
|
|
432
|
+
const read = await client.readPane(entry.paneId, { stripAnsi: false });
|
|
433
|
+
paneText = stripAnsi(read.text);
|
|
434
|
+
} catch {
|
|
435
|
+
if (sawStale) return staleFailure;
|
|
436
|
+
return {
|
|
437
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
438
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
const classified = classifyWaitMatch(cursor, paneText, raw, useRegex);
|
|
442
|
+
if (classified.kind === 'fresh') {
|
|
443
|
+
return {
|
|
444
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — new output contains the pattern:\n${classified.excerpt}` }],
|
|
445
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
if (classified.kind !== 'stale') {
|
|
449
|
+
return {
|
|
450
|
+
content: [{ type: 'text', text: `matched in ${entry.terminalId} — output is ready, use action read to inspect it` }],
|
|
451
|
+
details: { terminal_id: entry.terminalId, matched: true, pattern: raw },
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
sawStale = true;
|
|
455
|
+
const left = deadline - Date.now();
|
|
456
|
+
if (left <= 0) return staleFailure;
|
|
457
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
458
|
+
setTimeout(resolve, Math.min(400, left));
|
|
459
|
+
await promise;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** T5: a direct `pane_id` read is limited to panes in this session's own tab or to self-created terminal
|
|
464
|
+
* panes, so one task can never read another's pane. Returns the error text instead of throwing. */
|
|
465
|
+
async function resolveDirectPaneId(paneId: string): Promise<{ ok: true; paneId: string } | { ok: false; error: string }> {
|
|
466
|
+
let panes;
|
|
467
|
+
try {
|
|
468
|
+
panes = await client.listPanes();
|
|
469
|
+
} catch (e) {
|
|
470
|
+
return { ok: false, error: herdrUnavailableHint(e) ?? `pane lookup failed: ${(e as Error).message}` };
|
|
471
|
+
}
|
|
472
|
+
const target = panes.find((p) => p.paneId === paneId);
|
|
473
|
+
const ownTab = Boolean(target?.tabId) && target?.tabId === env?.tabId;
|
|
474
|
+
if (!target || !(ownTab || activeTerminalPaneIds(terminals).has(paneId))) {
|
|
475
|
+
return { ok: false, error: 'pane_id read is limited to panes in this session\'s own tab or self-created terminal panes' };
|
|
476
|
+
}
|
|
477
|
+
return { ok: true, paneId };
|
|
401
478
|
}
|
|
402
479
|
|
|
403
480
|
async function executeTerminalRead(params: ToolParams) {
|
|
404
|
-
let paneId: string
|
|
481
|
+
let paneId: string;
|
|
405
482
|
let entry: TerminalEntry | null = null;
|
|
406
483
|
if (typeof params?.terminal_id === 'string') {
|
|
407
484
|
entry = findOpenTerminal(params.terminal_id);
|
|
408
485
|
if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
|
|
409
486
|
paneId = entry.paneId;
|
|
410
487
|
} 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
|
-
}
|
|
488
|
+
const resolved = await resolveDirectPaneId(params.pane_id);
|
|
489
|
+
if (!resolved.ok) return fail(resolved.error);
|
|
490
|
+
paneId = resolved.paneId;
|
|
432
491
|
} else {
|
|
433
492
|
return fail('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
434
493
|
}
|
|
435
494
|
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
436
495
|
? 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
496
|
let read: { text: string; revision: number; truncated: boolean };
|
|
439
497
|
try {
|
|
440
498
|
read = await client.readPane(paneId, { stripAnsi: false });
|
|
@@ -504,6 +562,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
504
562
|
}
|
|
505
563
|
}
|
|
506
564
|
terminals = closeTerminal(terminals, entry.terminalId, Date.now()).entries;
|
|
565
|
+
lastSentText.delete(entry.terminalId);
|
|
507
566
|
persistTerminals();
|
|
508
567
|
return { content: [{ type: 'text', text: `terminal ${entry.terminalId} closed` }], details: { terminal_id: entry.terminalId } };
|
|
509
568
|
}
|