pi-pier 0.2.7 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -7
- package/package.json +7 -2
- package/schemas/efficiency-config.schema.json +77 -0
- package/skills/pi-herdr-workflow/SKILL.md +12 -0
- package/src/ansi-text.ts +83 -0
- package/src/ask-multi.ts +260 -0
- package/src/ask-user.ts +60 -10
- package/src/bootstrap.ts +5 -2
- package/src/compact-coordinator.ts +454 -0
- package/src/compact-economics-core.ts +396 -0
- package/src/config-catalog-core.ts +452 -0
- package/src/config-command.ts +181 -0
- package/src/config-guide.ts +361 -0
- package/src/core/grid-shape.ts +4 -2
- package/src/core/heat-plan.ts +160 -0
- package/src/core/observation.ts +555 -0
- package/src/core/subagent.ts +187 -393
- package/src/core/terminal.ts +121 -47
- package/src/core/todo.ts +25 -11
- package/src/dashboard-command.ts +89 -0
- package/src/efficiency-config-core.ts +661 -0
- package/src/efficiency-config-io.ts +53 -0
- package/src/efficiency-store.ts +312 -0
- package/src/focus-poller.ts +244 -0
- package/src/gc-core.ts +85 -0
- package/src/git-adapter.ts +12 -3
- package/src/herdr-client.ts +292 -36
- package/src/index-locks.ts +27 -9
- package/src/index-master.ts +5 -1
- package/src/index-notices.ts +13 -2
- package/src/index-pipe.ts +5 -0
- package/src/index-worker.ts +3 -1
- package/src/index.ts +330 -34
- package/src/jev-client.ts +146 -0
- package/src/jev-core.ts +381 -0
- package/src/lock-core.ts +105 -16
- package/src/observation-core.ts +248 -0
- package/src/pier-options.ts +96 -0
- package/src/pipe-channel.ts +52 -7
- package/src/reducer-core.ts +358 -0
- package/src/reducer-invoker.ts +392 -0
- package/src/renderers.ts +198 -0
- package/src/role-loader.ts +2 -1
- package/src/role-manifest.ts +0 -4
- package/src/runtime-policy.ts +5 -8
- package/src/session-tail.ts +60 -5
- package/src/settle-wake-core.ts +3 -1
- package/src/slim-frame.ts +4 -2
- package/src/storage-layout.ts +23 -0
- package/src/subagent-core.ts +3 -1
- package/src/subagent-execute.ts +328 -0
- package/src/subagent-gc.ts +33 -13
- package/src/subagent-launch.ts +4 -0
- package/src/subagent-list-action.ts +69 -0
- package/src/subagent-output-action.ts +104 -0
- package/src/subagent-output-adapter.ts +27 -0
- package/src/subagent-output-core.ts +263 -0
- package/src/subagent-poll-loop.ts +59 -39
- package/src/subagent-poller.ts +46 -0
- package/src/subagent-ready.ts +89 -0
- package/src/subagent-registry.ts +70 -0
- package/src/subagent-resolution.ts +41 -0
- package/src/subagent-scope.ts +3 -1
- package/src/subagent-session-io.ts +115 -32
- package/src/subagent-spawn.ts +153 -8
- package/src/swallow.ts +75 -0
- package/src/terminal-core.ts +82 -10
- package/src/todo-core.ts +5 -4
- package/src/todo-reminder-core.ts +8 -1
- package/src/todos-service.ts +18 -6
- package/src/tool-error.ts +26 -0
- package/src/vocab.ts +7 -4
package/src/core/terminal.ts
CHANGED
|
@@ -10,6 +10,11 @@ import { Context } from '@deepseek-ai/cordis';
|
|
|
10
10
|
import { Type } from 'typebox';
|
|
11
11
|
import type { PiSurface } from '../pi-surface.ts';
|
|
12
12
|
import type { HerdrClientLike } from '../herdr-client.ts';
|
|
13
|
+
import { herdrUnavailableHint } from '../herdr-client.ts';
|
|
14
|
+
import { toolError } from '../tool-error.ts'
|
|
15
|
+
|
|
16
|
+
/** Raw tool arguments: every field is validated inside the action handlers. */
|
|
17
|
+
type ToolParams = Record<string, unknown> | undefined;;
|
|
13
18
|
import {
|
|
14
19
|
READINESS_TIMEOUT_MS,
|
|
15
20
|
READ_MAX_CHARS,
|
|
@@ -23,6 +28,7 @@ import {
|
|
|
23
28
|
foldTerminalsRegistry,
|
|
24
29
|
makeTerminalsRegistry,
|
|
25
30
|
planIdleTerminalReminder,
|
|
31
|
+
planShellInit,
|
|
26
32
|
promptStrategyFor,
|
|
27
33
|
registerTerminal,
|
|
28
34
|
stripAnsi,
|
|
@@ -33,6 +39,8 @@ import {
|
|
|
33
39
|
type ReadCursor,
|
|
34
40
|
type TerminalEntry,
|
|
35
41
|
} from '../terminal-core.ts';
|
|
42
|
+
import { swallow } from '../swallow.ts';
|
|
43
|
+
import { pierOption } from '../pier-options.ts';
|
|
36
44
|
|
|
37
45
|
export interface TerminalStateSlot {
|
|
38
46
|
/** Lets index GC preserve panes that host active terminals. */
|
|
@@ -63,7 +71,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
63
71
|
function persistTerminals(): void {
|
|
64
72
|
try {
|
|
65
73
|
pi.appendEntry?.(TERMINALS_CUSTOM_TYPE, makeTerminalsRegistry(terminals));
|
|
66
|
-
} catch {
|
|
74
|
+
} catch (err) {
|
|
75
|
+
swallow('terminal.persist-registry', err);
|
|
67
76
|
/* Persistence is best-effort because terminal operation must not depend on session logging. */
|
|
68
77
|
}
|
|
69
78
|
}
|
|
@@ -170,14 +179,15 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
170
179
|
idleReminderTimer.unref?.();
|
|
171
180
|
});
|
|
172
181
|
|
|
173
|
-
const TERM_READ_MAX = Number(
|
|
182
|
+
const TERM_READ_MAX = Number(pierOption('PIER_TERM_READ_MAX') ?? READ_MAX_CHARS) || READ_MAX_CHARS;
|
|
174
183
|
/** wait action: default and ceiling for the blocking output wait (an LLM call must not hang forever). */
|
|
175
184
|
const WAIT_DEFAULT_MS = 120_000;
|
|
176
185
|
const WAIT_MAX_MS = 600_000;
|
|
177
186
|
/** wait action: recent-tail size handed back on timeout so the model can decide the next move. */
|
|
178
187
|
const WAIT_TAIL_CHARS = 2_000;
|
|
179
188
|
|
|
180
|
-
|
|
189
|
+
/** A1: a hard failure must throw (pi sets isError only for throws); non-failures stay results. */
|
|
190
|
+
const fail = (msg: string): never => toolError(msg);
|
|
181
191
|
|
|
182
192
|
scoped.registerTool({
|
|
183
193
|
name: 'terminal',
|
|
@@ -191,6 +201,14 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
191
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.',
|
|
192
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.',
|
|
193
203
|
].join(' '),
|
|
204
|
+
promptSnippet: 'terminal: a persistent shell in its own pane — use for dev servers, REPLs, and multi-step shell work that must keep state between calls.',
|
|
205
|
+
promptGuidelines: [
|
|
206
|
+
'Prefer bash for one-shot commands: it is cheaper and returns output directly. Use terminal only when state must persist (server, REPL, watch loop, interactive prompt) or when you need to wait for output that appears later.',
|
|
207
|
+
'Open once, then send/read/wait against the same terminal_id; do not open a new terminal per command.',
|
|
208
|
+
'After send, read to confirm the command actually started. Multi-line text is executed line by line.',
|
|
209
|
+
'wait is for output you expect (a sentinel, a startup line); it returns the recent tail on timeout so you can decide instead of re-polling.',
|
|
210
|
+
'Close the terminal as soon as the work is done — a finished terminal keeps occupying a pane and is never auto-reclaimed.',
|
|
211
|
+
],
|
|
194
212
|
parameters: Type.Object({
|
|
195
213
|
action: Type.Union([
|
|
196
214
|
Type.Literal('open'),
|
|
@@ -212,7 +230,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
212
230
|
max_chars: Type.Optional(Type.Number({ description: '[read] Output cap for this read (default 10000)' })),
|
|
213
231
|
key: Type.Optional(Type.String({ description: '[signal] Control key: ctrl+c | ctrl+d | ctrl+z | esc | enter' })),
|
|
214
232
|
}),
|
|
215
|
-
async execute(_tc, params, _sig, _upd, ctx) {
|
|
233
|
+
async execute(_tc: string, params: ToolParams, _sig: AbortSignal | undefined, _upd: unknown, ctx: unknown) {
|
|
216
234
|
const action = typeof params?.action === 'string' ? params.action : '';
|
|
217
235
|
switch (action) {
|
|
218
236
|
case 'open': return executeTerminalOpen(params, ctx);
|
|
@@ -223,7 +241,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
223
241
|
case 'close': return executeTerminalClose(params);
|
|
224
242
|
case 'list': return executeTerminalList();
|
|
225
243
|
default:
|
|
226
|
-
return
|
|
244
|
+
return fail(action
|
|
227
245
|
? `unknown action "${action}" (valid: open, send, wait, read, signal, close, list)`
|
|
228
246
|
: 'action is required (open, send, wait, read, signal, close, list)');
|
|
229
247
|
}
|
|
@@ -234,8 +252,11 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
234
252
|
async function probeReadiness(paneId: string): Promise<'prompt' | 'silent' | 'busy'> {
|
|
235
253
|
const prompt = promptStrategyFor();
|
|
236
254
|
try {
|
|
237
|
-
const
|
|
238
|
-
|
|
255
|
+
const waitRes = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
|
|
256
|
+
const isMatched = waitRes && typeof waitRes === 'object' && 'matched' in waitRes
|
|
257
|
+
? (waitRes as { matched: boolean }).matched
|
|
258
|
+
: Boolean(waitRes);
|
|
259
|
+
if (isMatched) return 'prompt';
|
|
239
260
|
const read = await client.readPane(paneId, { stripAnsi: false });
|
|
240
261
|
return classifyReadiness(stripAnsi(read.text), { silentMs: 0, prompt });
|
|
241
262
|
} catch {
|
|
@@ -244,8 +265,8 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
244
265
|
}
|
|
245
266
|
}
|
|
246
267
|
|
|
247
|
-
async function executeTerminalOpen(params, ctx) {
|
|
248
|
-
if (!client.available || !env) return
|
|
268
|
+
async function executeTerminalOpen(params: ToolParams, ctx: unknown) {
|
|
269
|
+
if (!client.available || !env) return fail('terminal tools require a herdr-managed pane');
|
|
249
270
|
const cwd = typeof params?.cwd === 'string' && params.cwd ? params.cwd
|
|
250
271
|
: (ctx as { cwd?: string }).cwd ?? process.cwd();
|
|
251
272
|
const r = registerTerminal(terminals, {
|
|
@@ -254,17 +275,29 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
254
275
|
cwd,
|
|
255
276
|
createdAt: Date.now(),
|
|
256
277
|
});
|
|
257
|
-
if (!r.ok) return
|
|
278
|
+
if (!r.ok) return fail(r.error);
|
|
258
279
|
let paneId: string;
|
|
259
280
|
try {
|
|
260
281
|
paneId = await client.splitPane({ direction: 'right', cwd, focus: false, targetPaneId: env.paneId });
|
|
261
282
|
} catch (e) {
|
|
262
|
-
|
|
283
|
+
const hint = herdrUnavailableHint(e);
|
|
284
|
+
return fail(hint ?? `failed to split terminal pane: ${(e as Error).message}`);
|
|
263
285
|
}
|
|
264
286
|
r.entry.paneId = paneId;
|
|
265
287
|
terminals = r.entries;
|
|
266
288
|
persistTerminals();
|
|
267
289
|
const readiness = await probeReadiness(paneId);
|
|
290
|
+
const prompt = promptStrategyFor();
|
|
291
|
+
const init = planShellInit({ strategy: prompt, readiness });
|
|
292
|
+
if (init.shouldInit && init.command) {
|
|
293
|
+
try {
|
|
294
|
+
await client.sendPaneText(paneId, init.command);
|
|
295
|
+
r.entry.initialized = true;
|
|
296
|
+
persistTerminals();
|
|
297
|
+
} catch {
|
|
298
|
+
/* Shell init is best-effort: failure to set +H does not block terminal usage */
|
|
299
|
+
}
|
|
300
|
+
}
|
|
268
301
|
const text = [
|
|
269
302
|
`terminal ${r.entry.terminalId} open (pane ${paneId})`,
|
|
270
303
|
`cwd: ${cwd}`,
|
|
@@ -273,18 +306,31 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
273
306
|
return { content: [{ type: 'text', text }], details: { terminal_id: r.entry.terminalId, pane_id: paneId, readiness } };
|
|
274
307
|
}
|
|
275
308
|
|
|
276
|
-
async function executeTerminalSend(params) {
|
|
309
|
+
async function executeTerminalSend(params: ToolParams) {
|
|
277
310
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
278
|
-
if (!entry) return
|
|
311
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
279
312
|
const v = validateSendText(typeof params?.text === 'string' ? params.text : '');
|
|
280
|
-
if (!v.ok) return
|
|
313
|
+
if (!v.ok) return fail(v.error);
|
|
314
|
+
if (!entry.initialized) {
|
|
315
|
+
const prompt = promptStrategyFor();
|
|
316
|
+
const init = planShellInit({ strategy: prompt });
|
|
317
|
+
if (init.shouldInit && init.command) {
|
|
318
|
+
try {
|
|
319
|
+
await client.sendPaneText(entry.paneId, init.command);
|
|
320
|
+
} catch {
|
|
321
|
+
/* Shell init is best-effort */
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
entry.initialized = true;
|
|
325
|
+
persistTerminals();
|
|
326
|
+
}
|
|
281
327
|
if (params?.wait_prompt === true) {
|
|
282
328
|
// Orchestration convention: read before writing. Text typed while a previous command still
|
|
283
329
|
// owns the foreground gets queued and fires later, which reads as "the shell ignored me".
|
|
284
330
|
// Refuse with the observed readiness instead of guessing.
|
|
285
331
|
const readiness = await probeReadiness(entry.paneId);
|
|
286
332
|
if (readiness !== 'prompt') {
|
|
287
|
-
return
|
|
333
|
+
return fail(
|
|
288
334
|
`shell is ${readiness === 'busy' ? 'busy (a previous command may still be running)' : 'silent (no prompt detected)'} — text was NOT sent. `
|
|
289
335
|
+ 'Use action read to inspect the pane, or omit wait_prompt to send anyway.',
|
|
290
336
|
);
|
|
@@ -293,35 +339,46 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
293
339
|
try {
|
|
294
340
|
await client.sendPaneText(entry.paneId, v.text);
|
|
295
341
|
} catch (e) {
|
|
296
|
-
|
|
342
|
+
// B5: a dead herdr socket would otherwise read as "pane may be closed", which sends the
|
|
343
|
+
// model hunting for a pane that is fine. Prefer the actionable transport sentence.
|
|
344
|
+
return fail(herdrUnavailableHint(e) ?? `send failed (pane may be closed): ${(e as Error).message}`);
|
|
297
345
|
}
|
|
298
346
|
touchTerminal(entry);
|
|
299
347
|
return { content: [{ type: 'text', text: `sent to ${entry.terminalId} (${v.text.length} chars)` }], details: { terminal_id: entry.terminalId } };
|
|
300
348
|
}
|
|
301
349
|
|
|
302
|
-
async function executeTerminalWait(params) {
|
|
350
|
+
async function executeTerminalWait(params: ToolParams) {
|
|
303
351
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
304
|
-
if (!entry) return
|
|
352
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
305
353
|
const raw = typeof params?.pattern === 'string' ? params.pattern : '';
|
|
306
|
-
if (!raw) return
|
|
354
|
+
if (!raw) return fail('pattern is required (literal text, or a regular expression with regex: true)');
|
|
307
355
|
const useRegex = params?.regex === true;
|
|
308
356
|
if (useRegex) {
|
|
309
357
|
try {
|
|
310
358
|
new RegExp(raw);
|
|
311
359
|
} catch (e) {
|
|
312
|
-
return
|
|
360
|
+
return fail(`invalid regex: ${(e as Error).message}`);
|
|
313
361
|
}
|
|
314
362
|
}
|
|
315
363
|
const requested = typeof params?.timeout_ms === 'number' && params.timeout_ms > 0 ? params.timeout_ms : WAIT_DEFAULT_MS;
|
|
316
364
|
const timeoutMs = Math.min(Math.max(requested, 1000), WAIT_MAX_MS);
|
|
317
365
|
const matcher = useRegex ? { type: 'regex' as const, value: raw } : { type: 'substring' as const, value: raw };
|
|
318
|
-
let matched: boolean |
|
|
366
|
+
let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
319
367
|
try {
|
|
320
|
-
|
|
368
|
+
const res = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
|
|
369
|
+
if (typeof res === 'object' && res !== null && 'matched' in res) {
|
|
370
|
+
waitResult = res as { matched: boolean; reason?: 'timeout' | 'unavailable' };
|
|
371
|
+
} else if (res === true) {
|
|
372
|
+
waitResult = { matched: true };
|
|
373
|
+
} else if (res === false) {
|
|
374
|
+
waitResult = { matched: false, reason: 'timeout' };
|
|
375
|
+
} else {
|
|
376
|
+
waitResult = { matched: false, reason: 'unavailable' };
|
|
377
|
+
}
|
|
321
378
|
} catch (e) {
|
|
322
|
-
return
|
|
379
|
+
return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
|
|
323
380
|
}
|
|
324
|
-
if (matched
|
|
381
|
+
if (!waitResult.matched) {
|
|
325
382
|
// wait-for-text convention: on timeout, hand back the recent tail so the model can decide
|
|
326
383
|
// the next move instead of re-polling blind.
|
|
327
384
|
let tail = '';
|
|
@@ -331,10 +388,10 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
331
388
|
} catch {
|
|
332
389
|
/* The pane is gone; the timeout text alone still tells the model it is stuck. */
|
|
333
390
|
}
|
|
334
|
-
const reason =
|
|
391
|
+
const reason = waitResult.reason === 'timeout' ? 'timeout' : 'wait unavailable';
|
|
335
392
|
return {
|
|
336
393
|
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 },
|
|
394
|
+
details: { terminal_id: entry.terminalId, matched: false, pattern: raw, reason },
|
|
338
395
|
};
|
|
339
396
|
}
|
|
340
397
|
return {
|
|
@@ -343,37 +400,46 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
343
400
|
};
|
|
344
401
|
}
|
|
345
402
|
|
|
346
|
-
async function executeTerminalRead(params) {
|
|
403
|
+
async function executeTerminalRead(params: ToolParams) {
|
|
347
404
|
let paneId: string | null = null;
|
|
348
405
|
let entry: TerminalEntry | null = null;
|
|
349
406
|
if (typeof params?.terminal_id === 'string') {
|
|
350
407
|
entry = findOpenTerminal(params.terminal_id);
|
|
351
|
-
if (!entry) return
|
|
408
|
+
if (!entry) return fail(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
|
|
352
409
|
paneId = entry.paneId;
|
|
353
410
|
} else if (typeof params?.pane_id === 'string') {
|
|
354
411
|
// T5 limits direct reads to this task tab or owned terminals to prevent cross-task access.
|
|
412
|
+
// A1: the guard result is carried out of the try block — a thrown tool error must not be eaten
|
|
413
|
+
// by the lookup catch below (it would be reported as a bogus "pane lookup failed").
|
|
414
|
+
let allowed = false;
|
|
415
|
+
let lookupError: unknown = null;
|
|
355
416
|
try {
|
|
356
417
|
const panes = await client.listPanes();
|
|
357
418
|
const target = panes.find((p) => p.paneId === params.pane_id);
|
|
358
419
|
const ownTab = target?.tabId && target.tabId === env?.tabId;
|
|
359
420
|
const ownTerminal = activeTerminalPaneIds(terminals).has(params.pane_id);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
}
|
|
363
|
-
paneId = params.pane_id;
|
|
421
|
+
allowed = Boolean(target) && Boolean(ownTab || ownTerminal);
|
|
422
|
+
if (allowed) paneId = params.pane_id;
|
|
364
423
|
} catch (e) {
|
|
365
|
-
|
|
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');
|
|
366
431
|
}
|
|
367
432
|
} else {
|
|
368
|
-
return
|
|
433
|
+
return fail('provide terminal_id (or pane_id for a direct own-tab read)');
|
|
369
434
|
}
|
|
370
435
|
const maxChars = typeof params?.max_chars === 'number' && params.max_chars > 0
|
|
371
436
|
? Math.min(params.max_chars, TERM_READ_MAX) : TERM_READ_MAX;
|
|
437
|
+
if (paneId === null) return fail('no readable pane resolved (pass terminal_id, or pane_id for an own-tab pane)');
|
|
372
438
|
let read: { text: string; revision: number; truncated: boolean };
|
|
373
439
|
try {
|
|
374
440
|
read = await client.readPane(paneId, { stripAnsi: false });
|
|
375
441
|
} catch (e) {
|
|
376
|
-
return
|
|
442
|
+
return fail(herdrUnavailableHint(e) ?? `read failed (pane may be closed): ${(e as Error).message}`);
|
|
377
443
|
}
|
|
378
444
|
// T6 inspects raw output because ANSI stripping would erase alternate-screen evidence.
|
|
379
445
|
const tui = detectFullscreenTUI(read.text);
|
|
@@ -390,7 +456,7 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
390
456
|
}
|
|
391
457
|
const prev: ReadCursor | null = entry
|
|
392
458
|
? (entry.readRevision != null
|
|
393
|
-
? { revision: entry.readRevision, len: entry.readLen, tail: entry.readTail, eoTail: entry.readEoTail }
|
|
459
|
+
? { revision: entry.readRevision, len: entry.readLen ?? 0, tail: entry.readTail ?? '', eoTail: entry.readEoTail ?? '' }
|
|
394
460
|
: null)
|
|
395
461
|
: null;
|
|
396
462
|
const inc = computeIncrement(prev, { text: read.text, revision: read.revision }, maxChars);
|
|
@@ -412,24 +478,24 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
412
478
|
};
|
|
413
479
|
}
|
|
414
480
|
|
|
415
|
-
async function executeTerminalSignal(params) {
|
|
481
|
+
async function executeTerminalSignal(params: ToolParams) {
|
|
416
482
|
const entry = findOpenTerminal(params?.terminal_id);
|
|
417
|
-
if (!entry) return
|
|
483
|
+
if (!entry) return fail(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
|
|
418
484
|
const v = validateSignal(typeof params?.key === 'string' ? params.key : '');
|
|
419
|
-
if (!v.ok) return
|
|
485
|
+
if (!v.ok) return fail(v.error);
|
|
420
486
|
try {
|
|
421
487
|
await client.sendPaneKeys(entry.paneId, [v.key]);
|
|
422
488
|
} catch (e) {
|
|
423
|
-
return
|
|
489
|
+
return fail(herdrUnavailableHint(e) ?? `signal failed (pane may be closed): ${(e as Error).message}`);
|
|
424
490
|
}
|
|
425
491
|
touchTerminal(entry);
|
|
426
492
|
return { content: [{ type: 'text', text: `signal ${v.key} sent to ${entry.terminalId}` }], details: { terminal_id: entry.terminalId, key: v.key } };
|
|
427
493
|
}
|
|
428
494
|
|
|
429
|
-
async function executeTerminalClose(params) {
|
|
495
|
+
async function executeTerminalClose(params: ToolParams) {
|
|
430
496
|
const id = params?.terminal_id;
|
|
431
497
|
const entry = terminals.find((t) => t.terminalId === id);
|
|
432
|
-
if (!entry) return
|
|
498
|
+
if (!entry) return fail(`unknown terminal "${String(id)}" (see action list)`);
|
|
433
499
|
if (entry.status === 'open') {
|
|
434
500
|
try {
|
|
435
501
|
await client.closePane(entry.paneId);
|
|
@@ -443,12 +509,18 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
443
509
|
}
|
|
444
510
|
|
|
445
511
|
async function executeTerminalList() {
|
|
446
|
-
let
|
|
512
|
+
let livePanes: import('../herdr-client.ts').PaneListItem[] = [];
|
|
447
513
|
try {
|
|
448
|
-
|
|
514
|
+
livePanes = await client.listPanes();
|
|
449
515
|
} catch {
|
|
450
516
|
/* Treat a failed query as unknown rather than closed, because an empty live set only affects stale detection. */
|
|
451
517
|
}
|
|
518
|
+
const livePaneIds = livePanes.map((p) => p.paneId);
|
|
519
|
+
const titleByPaneId = new Map<string, string>();
|
|
520
|
+
for (const p of livePanes) {
|
|
521
|
+
const cleanTitle = p.terminalTitleStripped ?? p.terminalTitle;
|
|
522
|
+
if (cleanTitle) titleByPaneId.set(p.paneId, cleanTitle);
|
|
523
|
+
}
|
|
452
524
|
const s = summarizeSessions(terminals, livePaneIds);
|
|
453
525
|
// T6 check 3: record vanished panes as closed across restarts rather than reviving them; tell the user to persist state.
|
|
454
526
|
if (s.stalePaneIds.length > 0) {
|
|
@@ -462,9 +534,11 @@ export default function terminalPlugin(ctx: Context): void {
|
|
|
462
534
|
if (s.terminals.length === 0) {
|
|
463
535
|
return { content: [{ type: 'text', text: 'no terminals (open one with action open)' }], details: { terminals: [] } };
|
|
464
536
|
}
|
|
465
|
-
const lines = s.terminals.map((t) =>
|
|
466
|
-
|
|
467
|
-
|
|
537
|
+
const lines = s.terminals.map((t) => {
|
|
538
|
+
const cleanTitle = titleByPaneId.get(t.paneId);
|
|
539
|
+
const titleTag = cleanTitle && cleanTitle !== t.label ? ` title="${cleanTitle}"` : '';
|
|
540
|
+
return `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}${titleTag}`;
|
|
541
|
+
});
|
|
468
542
|
const note = s.stalePaneIds.length > 0
|
|
469
543
|
? '\nnote: terminal sessions do not survive pane closure/restart — persist results to files.'
|
|
470
544
|
: '';
|
package/src/core/todo.ts
CHANGED
|
@@ -15,6 +15,10 @@ import { planTodoReadHook } from '../todo-read-hook.ts';
|
|
|
15
15
|
import { TODO_REMINDER_CUSTOM_TYPE, planStopTodoReminder, todoReminderGraceMs } from '../todo-reminder-core.ts';
|
|
16
16
|
import { makeProgressUpdate } from '../subagent-core.ts';
|
|
17
17
|
import { TODO_DETAILS_KEY, TODO_TOOL_NAME, formatTodoConfirmation, type TodoItem } from '../vocab.ts';
|
|
18
|
+
import { toolError } from '../tool-error.ts'
|
|
19
|
+
|
|
20
|
+
/** Raw tool arguments: every field is validated inside the action handlers. */
|
|
21
|
+
type ToolParams = Record<string, unknown> | undefined;;
|
|
18
22
|
import { formatAge, isArchived } from '../stale-core.ts';
|
|
19
23
|
import {
|
|
20
24
|
TODO_EDIT_CUSTOM_TYPE,
|
|
@@ -27,12 +31,15 @@ import {
|
|
|
27
31
|
validateTodos,
|
|
28
32
|
} from '../todo-core.ts';
|
|
29
33
|
import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from '../todo-window.ts';
|
|
34
|
+
import { swallow } from '../swallow.ts';
|
|
30
35
|
|
|
31
36
|
export interface TodoUiSlot {
|
|
32
37
|
/** Lets index lifecycle events render through the currently mounted plugin. */
|
|
33
38
|
renderWidget: (ctx: unknown) => void;
|
|
34
39
|
/** Re-render from the last seen context without a fresh event (human-gate open/close). */
|
|
35
40
|
rerenderWidget?: () => void;
|
|
41
|
+
/** Cancel any pending unfinished-todo reminder timer. */
|
|
42
|
+
cancelReminder?: () => void;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
45
|
interface TodoDeps {
|
|
@@ -49,6 +56,8 @@ interface TodoDeps {
|
|
|
49
56
|
stopReminder?: {
|
|
50
57
|
getBlockedDepth: () => number;
|
|
51
58
|
getRunningSubs: () => number;
|
|
59
|
+
isCompactionInFlight?: () => boolean;
|
|
60
|
+
isIntentionalAbort?: () => boolean;
|
|
52
61
|
};
|
|
53
62
|
}
|
|
54
63
|
|
|
@@ -154,7 +163,6 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
154
163
|
let lastWriteTurn: number | null = null;
|
|
155
164
|
let staleNotices = 0;
|
|
156
165
|
let lastStaleGuardTurn: number | null = null;
|
|
157
|
-
let lastArchivedMirror = false;
|
|
158
166
|
|
|
159
167
|
// Any real edit ends the current stale period because the plan is active again.
|
|
160
168
|
todos.on('todo.updated', () => {
|
|
@@ -189,10 +197,12 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
189
197
|
const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
|
|
190
198
|
try {
|
|
191
199
|
appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
|
|
192
|
-
} catch {
|
|
193
|
-
|
|
200
|
+
} catch (err) {
|
|
201
|
+
// Persistence is best-effort because the in-memory clear must still proceed, but a silent
|
|
202
|
+
// failure here is exactly how the archive/replay path broke unnoticed before (SA-13).
|
|
203
|
+
swallow('todo.persist-archive', err);
|
|
194
204
|
}
|
|
195
|
-
todos.replace([]);
|
|
205
|
+
todos.replace([], { source: 'archive' });
|
|
196
206
|
}
|
|
197
207
|
todoReadTurn += 1;
|
|
198
208
|
if (!plan.inject) return;
|
|
@@ -221,6 +231,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
221
231
|
todoReminderTimer = null;
|
|
222
232
|
}
|
|
223
233
|
}
|
|
234
|
+
state.cancelReminder = cancelTodoReminder;
|
|
224
235
|
scoped.on('turn_end', async (event: unknown) => {
|
|
225
236
|
if (event === null || typeof event !== 'object' || !('message' in event)) return;
|
|
226
237
|
const msg = (event as { message: unknown }).message;
|
|
@@ -234,8 +245,12 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
234
245
|
scoped.on('session_shutdown', () => cancelTodoReminder());
|
|
235
246
|
scoped.on('agent_settled', async () => {
|
|
236
247
|
cancelTodoReminder();
|
|
248
|
+
const inFlight = stopReminder.isCompactionInFlight?.() ?? false;
|
|
249
|
+
const intentional = stopReminder.isIntentionalAbort?.() ?? false;
|
|
237
250
|
const plan = planStopTodoReminder({
|
|
238
251
|
lastStopReason: lastAssistantStopReason,
|
|
252
|
+
intentionalAbort: intentional,
|
|
253
|
+
compactionInFlight: inFlight,
|
|
239
254
|
reminders: todoReminders,
|
|
240
255
|
runningSubs: stopReminder.getRunningSubs(),
|
|
241
256
|
blockedDepth: stopReminder.getBlockedDepth(),
|
|
@@ -245,6 +260,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
245
260
|
const content = plan.content;
|
|
246
261
|
todoReminderTimer = setTimeout(() => {
|
|
247
262
|
todoReminderTimer = null;
|
|
263
|
+
if (stopReminder.isCompactionInFlight?.()) return;
|
|
248
264
|
void (async () => {
|
|
249
265
|
const send = pi.sendMessage;
|
|
250
266
|
if (typeof send !== 'function') return;
|
|
@@ -291,15 +307,13 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
291
307
|
{ description: 'The COMPLETE task list; this call replaces the previous list' },
|
|
292
308
|
),
|
|
293
309
|
}),
|
|
294
|
-
async execute(toolCallId, params, signal, onUpdate, eventCtx) {
|
|
310
|
+
async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
|
|
295
311
|
void toolCallId;
|
|
296
312
|
void signal;
|
|
297
313
|
const result = validateTodos(params?.todos, allowParallelInProgress);
|
|
298
314
|
if (!result.ok) {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
details: {},
|
|
302
|
-
};
|
|
315
|
+
// A1: an invalid list is a hard failure — throw so pi flags isError for the model.
|
|
316
|
+
return toolError(result.error ?? 'invalid todo list');
|
|
303
317
|
}
|
|
304
318
|
const strictMode = todos.config.strict; // D75 phase 2 keeps policy in the service config.
|
|
305
319
|
let next = result.items!;
|
|
@@ -322,7 +336,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
322
336
|
// D36/D37 surface completion transitions and regressions to the caller.
|
|
323
337
|
const completed = completionTransitions(todos.items, next);
|
|
324
338
|
const reverted = revertedCompleted(todos.items, next);
|
|
325
|
-
todos.replace(next);
|
|
339
|
+
todos.replace(next, { source: 'tool' });
|
|
326
340
|
if (todos.items.length > maxItems) {
|
|
327
341
|
onUpdate?.(makeProgressUpdate(`Note: list has ${todos.items.length} entries; consider keeping it under ${maxItems}.`));
|
|
328
342
|
}
|
|
@@ -372,7 +386,7 @@ export default function todoPlugin(ctx: Context): void {
|
|
|
372
386
|
const content = candidates[0];
|
|
373
387
|
const edit = { op: opArg as 'done' | 'drop' | 'rm' | 'unblock', content };
|
|
374
388
|
const before = todos.items;
|
|
375
|
-
todos.applyEdits([edit]);
|
|
389
|
+
todos.applyEdits([edit], { source: 'human' });
|
|
376
390
|
if (listsEqual(before, todos.items)) {
|
|
377
391
|
ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
|
|
378
392
|
return;
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { HerdrClientLike, HerdrEnv } from './herdr-client.ts';
|
|
2
|
+
import type { TodoItem } from './vocab.ts';
|
|
3
|
+
|
|
4
|
+
export interface DashboardCommandDeps {
|
|
5
|
+
pi: {
|
|
6
|
+
registerCommand(name: string, options: { description: string; handler: (args: unknown, ctx: unknown) => Promise<void> }): void;
|
|
7
|
+
};
|
|
8
|
+
client: HerdrClientLike;
|
|
9
|
+
env: HerdrEnv | null;
|
|
10
|
+
getTodoItems?: () => readonly TodoItem[];
|
|
11
|
+
getHeldLocks?: () => readonly string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatStandaloneDashboard(opts: {
|
|
15
|
+
todos: readonly TodoItem[];
|
|
16
|
+
locks: readonly string[];
|
|
17
|
+
now?: number;
|
|
18
|
+
}): string {
|
|
19
|
+
const lines: string[] = [];
|
|
20
|
+
const timeStr = new Date(opts.now ?? Date.now()).toTimeString().split(' ')[0] ?? '';
|
|
21
|
+
lines.push(`==================== PIER OPS DASHBOARD ==================== [${timeStr}] (standalone)`);
|
|
22
|
+
lines.push('Mode: Standalone (Non-Herdr / Local Session)');
|
|
23
|
+
lines.push('--------------------------------------------------------------------------------');
|
|
24
|
+
|
|
25
|
+
const todos = opts.todos;
|
|
26
|
+
if (todos.length > 0) {
|
|
27
|
+
const inProg = todos.filter((t) => t.status === 'in_progress');
|
|
28
|
+
const blocked = todos.filter((t) => t.status === 'blocked');
|
|
29
|
+
const done = todos.filter((t) => t.status === 'completed');
|
|
30
|
+
const pending = todos.filter((t) => t.status === 'pending');
|
|
31
|
+
lines.push(
|
|
32
|
+
`Todos: ${todos.length} total (${done.length} done, ${inProg.length} working, ${blocked.length} blocked, ${pending.length} pending)`,
|
|
33
|
+
);
|
|
34
|
+
if (inProg.length > 0) {
|
|
35
|
+
lines.push(`Active: ▶ ${inProg.map((t) => t.content).join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
if (blocked.length > 0) {
|
|
38
|
+
lines.push(`Blocked: ■ ${blocked.map((t) => `${t.content}${t.blocker ? ` (${t.blocker})` : ''}`).join(', ')}`);
|
|
39
|
+
}
|
|
40
|
+
if (inProg.length === 0 && blocked.length === 0 && done.length > 0) {
|
|
41
|
+
lines.push(`Completed: ✓ ${done.slice(-3).map((t) => t.content).join(', ')}`);
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
lines.push('Todos: (none)');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const locks = opts.locks;
|
|
48
|
+
lines.push(`Write Locks: ${locks.length > 0 ? locks.join(', ') : '(none)'}`);
|
|
49
|
+
lines.push('--------------------------------------------------------------------------------');
|
|
50
|
+
lines.push('Tip: Inside Herdr 0.9.1, /dashboard opens the interactive modal popup.');
|
|
51
|
+
lines.push('================================================================================');
|
|
52
|
+
return lines.join('\n');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function installDashboardCommand(deps: DashboardCommandDeps): void {
|
|
56
|
+
deps.pi.registerCommand('dashboard', {
|
|
57
|
+
description: 'Open the Pier Ops Dashboard (modal popup in Herdr 0.9.1, tab fallback, or local TUI view)',
|
|
58
|
+
handler: async (_args, ctx) => {
|
|
59
|
+
const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
|
|
60
|
+
|
|
61
|
+
// Level 1 / Level 2: Herdr environment
|
|
62
|
+
if (deps.client.available && deps.env) {
|
|
63
|
+
try {
|
|
64
|
+
const res = await deps.client.openPluginPane({
|
|
65
|
+
pluginId: 'pier.workbench',
|
|
66
|
+
entrypoint: 'dashboard',
|
|
67
|
+
placement: 'popup',
|
|
68
|
+
width: '80%',
|
|
69
|
+
height: '80%',
|
|
70
|
+
focus: true,
|
|
71
|
+
});
|
|
72
|
+
if (res.mode === 'fallback_tab') {
|
|
73
|
+
ui?.notify?.('Herdr < 0.9.1: Pier Dashboard opened in new tab', 'info');
|
|
74
|
+
}
|
|
75
|
+
return;
|
|
76
|
+
} catch {
|
|
77
|
+
// If plugin is not installed or socket error occurs, fall through to Level 3 local view
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Level 3: Standalone / Non-Herdr fallback
|
|
82
|
+
const text = formatStandaloneDashboard({
|
|
83
|
+
todos: deps.getTodoItems?.() ?? [],
|
|
84
|
+
locks: deps.getHeldLocks?.() ?? [],
|
|
85
|
+
});
|
|
86
|
+
ui?.notify?.(text, 'info');
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
}
|