pi-pier 0.2.6 → 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 +13 -1
  5. package/src/ansi-text.ts +83 -0
  6. package/src/ask-multi.ts +260 -0
  7. package/src/ask-user.ts +458 -0
  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 +270 -47
  19. package/src/core/todo.ts +49 -14
  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 -0
  30. package/src/index-notices.ts +13 -2
  31. package/src/index-pipe.ts +5 -0
  32. package/src/index-worker.ts +5 -0
  33. package/src/index.ts +360 -60
  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 +60 -3
  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 +161 -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
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,10 +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;
39
+ /** Re-render from the last seen context without a fresh event (human-gate open/close). */
40
+ rerenderWidget?: () => void;
41
+ /** Cancel any pending unfinished-todo reminder timer. */
42
+ cancelReminder?: () => void;
34
43
  }
35
44
 
36
45
  interface TodoDeps {
@@ -41,10 +50,14 @@ interface TodoDeps {
41
50
  mirrorTodos: () => void;
42
51
  appendEntry: (customType: string, data: unknown) => void;
43
52
  state: TodoUiSlot;
53
+ /** Human-gate depth (ask_user_question / herdr:blocked); >0 collapses the widget to one line. */
54
+ getBlockedDepth?: () => number;
44
55
  /** Master-only unfinished-todo reminder; omitted on worker panes. */
45
56
  stopReminder?: {
46
57
  getBlockedDepth: () => number;
47
58
  getRunningSubs: () => number;
59
+ isCompactionInFlight?: () => boolean;
60
+ isIntentionalAbort?: () => boolean;
48
61
  };
49
62
  }
50
63
 
@@ -67,8 +80,18 @@ export const WIDGET_MAX_LINES = 10;
67
80
  * completions. A +N line points to /todos when the full plan cannot fit. This fixes 01a03c0d,
68
81
  * where active work scrolled out while only the final entries remained visible.
69
82
  */
70
- export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?: number | null }): string[] {
83
+ export function widgetLines(
84
+ items: readonly TodoItem[],
85
+ opts?: { archivedAgeMs?: number | null; blockedDepth?: number | null },
86
+ ): string[] {
71
87
  if (items.length === 0) return [];
88
+ // Human gate open (ask_user_question waiting): the question + editor own the fixed area, so the
89
+ // widget collapses to the one-line summary — /todos still reaches the full list. Without this,
90
+ // a 10-line widget plus a multi-line question leaves almost no scrollable transcript
91
+ // (user-reported: both blocks visible ⇒ history range too small).
92
+ if ((opts?.blockedDepth ?? 0) > 0) {
93
+ return [`${formatTodoSummary(items)} · /todos 全量`];
94
+ }
72
95
  // Collapse archived plans to two lines so stale work cannot monopolize the widget.
73
96
  if (opts?.archivedAgeMs != null) {
74
97
  return [
@@ -95,7 +118,7 @@ export function widgetLines(items: readonly TodoItem[], opts?: { archivedAgeMs?:
95
118
 
96
119
  export default function todoPlugin(ctx: Context): void {
97
120
  const surface = ctx.get('pi-herdr.surface') as PiSurface<object>;
98
- const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder } =
121
+ const { todos, allowParallelInProgress, maxItems, mirrorTodos, appendEntry, state, stopReminder, getBlockedDepth } =
99
122
  ctx.get('pi-herdr.todo-deps') as TodoDeps;
100
123
  const scoped = surface.forModule(import.meta.url);
101
124
 
@@ -115,9 +138,15 @@ export default function todoPlugin(ctx: Context): void {
115
138
  : undefined;
116
139
  }
117
140
 
141
+ /** Gate transitions fire outside lifecycle events; remember one ctx so rerenderWidget can target the live ui. */
142
+ let lastEventCtx: unknown = null;
118
143
  function renderWidget(eventCtx: unknown): void {
144
+ if (eventCtx !== null && eventCtx !== undefined) lastEventCtx = eventCtx;
119
145
  try {
120
- widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, { archivedAgeMs: archivedAgeMs() }));
146
+ widgetUi(eventCtx)?.setWidget?.('todos', widgetLines(todos.items, {
147
+ archivedAgeMs: archivedAgeMs(),
148
+ blockedDepth: getBlockedDepth?.() ?? 0,
149
+ }));
121
150
  } catch {
122
151
  /* Older pi versions may omit widget support without disabling todo tracking. */
123
152
  }
@@ -125,6 +154,7 @@ export default function todoPlugin(ctx: Context): void {
125
154
 
126
155
  // Let index lifecycle hooks call the current plugin implementation.
127
156
  state.renderWidget = renderWidget;
157
+ state.rerenderWidget = () => renderWidget(lastEventCtx);
128
158
 
129
159
  /* ── Read hook: D39 reminders and stale-plan thawing ────────────── */
130
160
  let todoReadTurn = 0;
@@ -133,7 +163,6 @@ export default function todoPlugin(ctx: Context): void {
133
163
  let lastWriteTurn: number | null = null;
134
164
  let staleNotices = 0;
135
165
  let lastStaleGuardTurn: number | null = null;
136
- let lastArchivedMirror = false;
137
166
 
138
167
  // Any real edit ends the current stale period because the plan is active again.
139
168
  todos.on('todo.updated', () => {
@@ -168,10 +197,12 @@ export default function todoPlugin(ctx: Context): void {
168
197
  const edits = todos.items.map((it) => ({ op: 'rm' as const, content: it.content }));
169
198
  try {
170
199
  appendEntry(TODO_EDIT_CUSTOM_TYPE, { version: 1, edits, ts: Date.now() });
171
- } catch {
172
- /* 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);
173
204
  }
174
- todos.replace([]);
205
+ todos.replace([], { source: 'archive' });
175
206
  }
176
207
  todoReadTurn += 1;
177
208
  if (!plan.inject) return;
@@ -200,6 +231,7 @@ export default function todoPlugin(ctx: Context): void {
200
231
  todoReminderTimer = null;
201
232
  }
202
233
  }
234
+ state.cancelReminder = cancelTodoReminder;
203
235
  scoped.on('turn_end', async (event: unknown) => {
204
236
  if (event === null || typeof event !== 'object' || !('message' in event)) return;
205
237
  const msg = (event as { message: unknown }).message;
@@ -213,8 +245,12 @@ export default function todoPlugin(ctx: Context): void {
213
245
  scoped.on('session_shutdown', () => cancelTodoReminder());
214
246
  scoped.on('agent_settled', async () => {
215
247
  cancelTodoReminder();
248
+ const inFlight = stopReminder.isCompactionInFlight?.() ?? false;
249
+ const intentional = stopReminder.isIntentionalAbort?.() ?? false;
216
250
  const plan = planStopTodoReminder({
217
251
  lastStopReason: lastAssistantStopReason,
252
+ intentionalAbort: intentional,
253
+ compactionInFlight: inFlight,
218
254
  reminders: todoReminders,
219
255
  runningSubs: stopReminder.getRunningSubs(),
220
256
  blockedDepth: stopReminder.getBlockedDepth(),
@@ -224,6 +260,7 @@ export default function todoPlugin(ctx: Context): void {
224
260
  const content = plan.content;
225
261
  todoReminderTimer = setTimeout(() => {
226
262
  todoReminderTimer = null;
263
+ if (stopReminder.isCompactionInFlight?.()) return;
227
264
  void (async () => {
228
265
  const send = pi.sendMessage;
229
266
  if (typeof send !== 'function') return;
@@ -270,15 +307,13 @@ export default function todoPlugin(ctx: Context): void {
270
307
  { description: 'The COMPLETE task list; this call replaces the previous list' },
271
308
  ),
272
309
  }),
273
- async execute(toolCallId, params, signal, onUpdate, eventCtx) {
310
+ async execute(toolCallId: string, params: ToolParams, signal: AbortSignal | undefined, onUpdate: ((update: unknown) => void) | undefined, eventCtx: unknown) {
274
311
  void toolCallId;
275
312
  void signal;
276
313
  const result = validateTodos(params?.todos, allowParallelInProgress);
277
314
  if (!result.ok) {
278
- return {
279
- content: [{ type: 'text', text: `Error: ${result.error}` }],
280
- details: {},
281
- };
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');
282
317
  }
283
318
  const strictMode = todos.config.strict; // D75 phase 2 keeps policy in the service config.
284
319
  let next = result.items!;
@@ -301,7 +336,7 @@ export default function todoPlugin(ctx: Context): void {
301
336
  // D36/D37 surface completion transitions and regressions to the caller.
302
337
  const completed = completionTransitions(todos.items, next);
303
338
  const reverted = revertedCompleted(todos.items, next);
304
- todos.replace(next);
339
+ todos.replace(next, { source: 'tool' });
305
340
  if (todos.items.length > maxItems) {
306
341
  onUpdate?.(makeProgressUpdate(`Note: list has ${todos.items.length} entries; consider keeping it under ${maxItems}.`));
307
342
  }
@@ -351,7 +386,7 @@ export default function todoPlugin(ctx: Context): void {
351
386
  const content = candidates[0];
352
387
  const edit = { op: opArg as 'done' | 'drop' | 'rm' | 'unblock', content };
353
388
  const before = todos.items;
354
- todos.applyEdits([edit]);
389
+ todos.applyEdits([edit], { source: 'human' });
355
390
  if (listsEqual(before, todos.items)) {
356
391
  ui?.notify?.(`no change: "${content}" is already in that state`, 'info');
357
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
+ }