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.
Files changed (72) hide show
  1. package/README.md +20 -7
  2. package/package.json +7 -2
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/skills/pi-herdr-workflow/SKILL.md +12 -0
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +60 -10
  8. package/src/bootstrap.ts +5 -2
  9. package/src/compact-coordinator.ts +454 -0
  10. package/src/compact-economics-core.ts +396 -0
  11. package/src/config-catalog-core.ts +452 -0
  12. package/src/config-command.ts +181 -0
  13. package/src/config-guide.ts +361 -0
  14. package/src/core/grid-shape.ts +4 -2
  15. package/src/core/heat-plan.ts +160 -0
  16. package/src/core/observation.ts +555 -0
  17. package/src/core/subagent.ts +187 -393
  18. package/src/core/terminal.ts +121 -47
  19. package/src/core/todo.ts +25 -11
  20. package/src/dashboard-command.ts +89 -0
  21. package/src/efficiency-config-core.ts +661 -0
  22. package/src/efficiency-config-io.ts +53 -0
  23. package/src/efficiency-store.ts +312 -0
  24. package/src/focus-poller.ts +244 -0
  25. package/src/gc-core.ts +85 -0
  26. package/src/git-adapter.ts +12 -3
  27. package/src/herdr-client.ts +292 -36
  28. package/src/index-locks.ts +27 -9
  29. package/src/index-master.ts +5 -1
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +3 -1
  33. package/src/index.ts +330 -34
  34. package/src/jev-client.ts +146 -0
  35. package/src/jev-core.ts +381 -0
  36. package/src/lock-core.ts +105 -16
  37. package/src/observation-core.ts +248 -0
  38. package/src/pier-options.ts +96 -0
  39. package/src/pipe-channel.ts +52 -7
  40. package/src/reducer-core.ts +358 -0
  41. package/src/reducer-invoker.ts +392 -0
  42. package/src/renderers.ts +198 -0
  43. package/src/role-loader.ts +2 -1
  44. package/src/role-manifest.ts +0 -4
  45. package/src/runtime-policy.ts +5 -8
  46. package/src/session-tail.ts +60 -5
  47. package/src/settle-wake-core.ts +3 -1
  48. package/src/slim-frame.ts +4 -2
  49. package/src/storage-layout.ts +23 -0
  50. package/src/subagent-core.ts +3 -1
  51. package/src/subagent-execute.ts +328 -0
  52. package/src/subagent-gc.ts +33 -13
  53. package/src/subagent-launch.ts +4 -0
  54. package/src/subagent-list-action.ts +69 -0
  55. package/src/subagent-output-action.ts +104 -0
  56. package/src/subagent-output-adapter.ts +27 -0
  57. package/src/subagent-output-core.ts +263 -0
  58. package/src/subagent-poll-loop.ts +59 -39
  59. package/src/subagent-poller.ts +46 -0
  60. package/src/subagent-ready.ts +89 -0
  61. package/src/subagent-registry.ts +70 -0
  62. package/src/subagent-resolution.ts +41 -0
  63. package/src/subagent-scope.ts +3 -1
  64. package/src/subagent-session-io.ts +115 -32
  65. package/src/subagent-spawn.ts +153 -8
  66. package/src/swallow.ts +75 -0
  67. package/src/terminal-core.ts +82 -10
  68. package/src/todo-core.ts +5 -4
  69. package/src/todo-reminder-core.ts +8 -1
  70. package/src/todos-service.ts +18 -6
  71. package/src/tool-error.ts +26 -0
  72. package/src/vocab.ts +7 -4
@@ -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(process.env.PI_HERDR_TERM_READ_MAX ?? READ_MAX_CHARS) || READ_MAX_CHARS;
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
- const errText = (msg: string) => ({ content: [{ type: 'text' as const, text: `Error: ${msg}` }], details: {} });
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 errText(action
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 matched = await client.waitForOutput(paneId, { type: 'regex', value: prompt.waitPattern }, READINESS_TIMEOUT_MS);
238
- if (matched) return 'prompt';
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 errText('terminal tools require a herdr-managed pane');
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 errText(r.error);
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
- return errText(`failed to split terminal pane: ${(e as Error).message}`);
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 errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
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 errText(v.error);
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 errText(
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
- return errText(`send failed (pane may be closed): ${(e as Error).message}`);
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 errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
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 errText('pattern is required (literal text, or a regular expression with regex: true)');
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 errText(`invalid regex: ${(e as Error).message}`);
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 | null;
366
+ let waitResult: { matched: boolean; reason?: 'timeout' | 'unavailable' };
319
367
  try {
320
- matched = await client.waitForOutput(entry.paneId, matcher, timeoutMs);
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 errText(`wait failed (pane may be closed): ${(e as Error).message}`);
379
+ return fail(herdrUnavailableHint(e) ?? `wait failed (pane may be closed): ${(e as Error).message}`);
323
380
  }
324
- if (matched !== true) {
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 = matched === false ? 'no match' : 'wait unavailable';
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 errText(`unknown or closed terminal "${params.terminal_id}" (see action list)`);
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
- if (!target || (!ownTab && !ownTerminal)) {
361
- return errText('pane_id read is limited to panes in this session\'s own tab or self-created terminal panes');
362
- }
363
- paneId = params.pane_id;
421
+ allowed = Boolean(target) && Boolean(ownTab || ownTerminal);
422
+ if (allowed) paneId = params.pane_id;
364
423
  } catch (e) {
365
- return errText(`pane lookup failed: ${(e as Error).message}`);
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 errText('provide terminal_id (or pane_id for a direct own-tab read)');
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 errText(`read failed (pane may be closed): ${(e as Error).message}`);
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 errText(`unknown or closed terminal "${String(params?.terminal_id)}" (see action list)`);
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 errText(v.error);
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 errText(`signal failed (pane may be closed): ${(e as Error).message}`);
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 errText(`unknown terminal "${String(id)}" (see action list)`);
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 livePaneIds: string[] = [];
512
+ let livePanes: import('../herdr-client.ts').PaneListItem[] = [];
447
513
  try {
448
- livePaneIds = (await client.listPanes()).map((p) => p.paneId);
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
- `- ${t.terminalId} [${t.live ? 'open' : 'closed'}] pane=${t.paneId} cwd=${t.cwd}${t.label ? ` (${t.label})` : ''}`,
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
- /* Persistence is best-effort because the in-memory clear must still proceed. */
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
- return {
300
- content: [{ type: 'text', text: `Error: ${result.error}` }],
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
+ }