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
package/src/slim-frame.ts
CHANGED
|
@@ -1,39 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D97 slim frame:
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* D97 slim frame: a pane too narrow/short for the minimum usable TUI gets a non-capturing overlay
|
|
3
|
+
* instead of a continuously repainting one — full TUI when it fits, an activity-anchored todo
|
|
4
|
+
* window when ≥3 rows fit, else the pane title. Content updates only on todo/status reports, so
|
|
5
|
+
* streaming thinking tokens can never reach it and the alt-screen diff produces no PTY output.
|
|
5
6
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Requires fullscreen TUI mode (buildLaunchParts injects it): the composite layer sits above the
|
|
8
|
+
* document, so only the alt-screen path can be covered. Registered only inside a herdr pane
|
|
9
|
+
* (PIER_SLIM_FRAME=0 opts out), as a process-local singleton whose overlay stays resident —
|
|
10
|
+
* `done()` is never called, so the pending promise is swallowed and never awaited.
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* covered by the overlay: composite is above the document, while the dump is inside the document.
|
|
14
|
-
*
|
|
15
|
-
* Registration gate: only inside a herdr pane (index.ts checks the environment), because covering an interactive
|
|
16
|
-
* normal terminal window would be hazardous. Clicking a narrow herdr pane focuses it and the heat view expands it;
|
|
17
|
-
* the visible predicate re-evaluates on every frame as SIGWINCH arrives, so the frame disappears automatically and
|
|
18
|
-
* the real TUI remains underneath (agent/session/widget execution never stops).
|
|
19
|
-
*
|
|
20
|
-
* Lifecycle: one process-local singleton, registered once on session_start (not resume); the overlay stays resident
|
|
21
|
-
* (done() is never called, leaving the Promise pending, swallowing rejection, and never awaiting it). If switching
|
|
22
|
-
* pi sessions removes the overlay (resetExtensionUI), the static frame naturally exits without affecting the main
|
|
23
|
-
* flow. PI_HERDR_SLIM_FRAME=0 is the escape hatch.
|
|
24
|
-
* Resize watchdog (D98): the component owns a SIGWINCH listener plus a 1s size poll that call tui.requestRender() on
|
|
25
|
-
* any PTY size change. Why: long-running sessions stop re-rendering on resize (observed in herdr — an idle master
|
|
26
|
-
* shrunk by heat reflow froze on a stale clipped frame: no todo, no title), so pi's own resize wiring cannot be relied
|
|
27
|
-
* on; columns/rows are read live per render, so one render is the whole fix. Stopped in dispose().
|
|
12
|
+
* Resize watchdog (D98): a SIGWINCH listener plus a 1 s poll, since long-running sessions stop
|
|
13
|
+
* re-rendering on resize; sizes are read live per render, both stop in dispose().
|
|
28
14
|
*/
|
|
29
15
|
|
|
30
16
|
import { isArchived } from './stale-core.ts';
|
|
31
17
|
import type { TodoItem } from './vocab.ts';
|
|
32
18
|
import { anchorTodoRange, formatTodoSummary, renderTodoGroups } from './todo-window.ts';
|
|
33
19
|
import { pierOption } from './pier-options.ts';
|
|
20
|
+
import { styledWidth, wrapStyled } from './ansi-text.ts';
|
|
34
21
|
|
|
35
|
-
/** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing).
|
|
36
|
-
* Below either threshold the pane cannot display a readable TUI, so the overlay covers it. */
|
|
22
|
+
/** Minimum usable TUI dimensions (pi interactive-mode layout: editor 3 rows + footer 1 + transcript ≥3 + spacing). */
|
|
37
23
|
|
|
38
24
|
export const SLIM_MIN_COLS = 24;
|
|
39
25
|
export const SLIM_MIN_ROWS = 12;
|
|
@@ -51,107 +37,37 @@ export function isSlimFrame(cols: number, rows: number): boolean {
|
|
|
51
37
|
return cols < SLIM_MIN_COLS || rows < SLIM_MIN_ROWS;
|
|
52
38
|
}
|
|
53
39
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return (
|
|
58
|
-
(cp >= 0x1100 && cp <= 0x115f) // Hangul compatibility jamo
|
|
59
|
-
|| (cp >= 0x2e80 && cp <= 0xa4cf) // CJK radicals through Yi (including kana)
|
|
60
|
-
|| (cp >= 0xac00 && cp <= 0xd7a3) // Hangul syllables
|
|
61
|
-
|| (cp >= 0xf900 && cp <= 0xfaff) // CJK compatibility ideographs
|
|
62
|
-
|| (cp >= 0xfe30 && cp <= 0xfe4f) // CJK compatibility forms
|
|
63
|
-
|| (cp >= 0xff00 && cp <= 0xff60) // Fullwidth forms
|
|
64
|
-
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export function displayWidth(s: string): number {
|
|
69
|
-
let w = 0;
|
|
70
|
-
for (const ch of s) w += isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
71
|
-
return w;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Wrap at visible width: prefer word breaks (retreat to the last space instead of cutting an English phrase);
|
|
76
|
-
* CJK text without spaces hard-wraps naturally. Consume the space at the break point.
|
|
77
|
-
*/
|
|
78
|
-
export function wrapByWidth(text: string, width: number): string[] {
|
|
79
|
-
const w = Math.max(1, Math.floor(width));
|
|
80
|
-
const lines: string[] = [];
|
|
81
|
-
let line = '';
|
|
82
|
-
let lineW = 0;
|
|
83
|
-
const push = () => {
|
|
84
|
-
lines.push(line);
|
|
85
|
-
line = '';
|
|
86
|
-
lineW = 0;
|
|
87
|
-
};
|
|
88
|
-
for (const ch of text) {
|
|
89
|
-
const cw = isWide(ch.codePointAt(0) ?? 0) ? 2 : 1;
|
|
90
|
-
if (lineW + cw > w) {
|
|
91
|
-
const lastSpace = line.lastIndexOf(' ');
|
|
92
|
-
if (lastSpace > 0) {
|
|
93
|
-
lines.push(line.slice(0, lastSpace));
|
|
94
|
-
line = line.slice(lastSpace + 1) + ch;
|
|
95
|
-
lineW = displayWidth(line);
|
|
96
|
-
} else {
|
|
97
|
-
push();
|
|
98
|
-
line = ch;
|
|
99
|
-
lineW = cw;
|
|
100
|
-
}
|
|
101
|
-
} else {
|
|
102
|
-
line += ch;
|
|
103
|
-
lineW += cw;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
if (line !== '' || lines.length === 0) lines.push(line);
|
|
107
|
-
return lines;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* Frame lines: wrap the title, center it vertically, and clamp to rows. Pad every line to full visible width:
|
|
112
|
-
* row-wise overlay compositing would let the TUI underneath show through blank lines (leaking flicker),
|
|
113
|
-
* while full-width spaces make the freeze genuinely opaque. Without a title, center one `·` (alive, no plan).
|
|
114
|
-
*/
|
|
40
|
+
/** Frame lines: wrap the title, center it vertically, clamp to rows. Every line is padded to the
|
|
41
|
+
* full visible width — row-wise compositing would otherwise let the TUI underneath show through
|
|
42
|
+
* blank lines and leak the flicker this frame exists to hide. No title → one centered `·`. */
|
|
115
43
|
export function frameLines(
|
|
116
44
|
text: string,
|
|
117
45
|
opts: { width: number; rows: number; colorize?: (s: string) => string },
|
|
118
46
|
): string[] {
|
|
119
47
|
const width = Math.max(1, Math.floor(opts.width));
|
|
120
48
|
const rows = Math.max(1, Math.floor(opts.rows));
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const body = text.trim()
|
|
125
|
-
? wrapByWidth(text.trim(), width).slice(0, rows)
|
|
126
|
-
: ['·'];
|
|
127
|
-
const top = Math.max(0, Math.floor((rows - body.length) / 2));
|
|
128
|
-
const out: string[] = [];
|
|
129
|
-
for (let i = 0; i < rows; i++) {
|
|
130
|
-
const idx = i - top;
|
|
131
|
-
out.push(idx >= 0 && idx < body.length ? pad(colorize(body[idx])) : ' '.repeat(width));
|
|
132
|
-
}
|
|
133
|
-
return out;
|
|
49
|
+
const body = text.trim() ? wrapStyled(text.trim(), width).slice(0, rows) : ['·'];
|
|
50
|
+
return padFrame(body, { width, rows, colorize: opts.colorize ?? ((s: string) => s), vAlign: 'center' });
|
|
134
51
|
}
|
|
135
52
|
|
|
136
53
|
function padFrame(
|
|
137
54
|
content: readonly string[],
|
|
138
55
|
opts: { width: number; rows: number; colorize: (s: string) => string; vAlign: 'center' | 'top' },
|
|
139
56
|
): string[] {
|
|
140
|
-
const width =
|
|
141
|
-
const rows = Math.max(1, Math.floor(opts.rows));
|
|
142
|
-
const pad = (s: string) => s + ' '.repeat(Math.max(0, width - displayWidth(s)));
|
|
57
|
+
const { width, rows } = opts;
|
|
143
58
|
const body = content.slice(0, rows);
|
|
144
59
|
const top = opts.vAlign === 'center' ? Math.max(0, Math.floor((rows - body.length) / 2)) : 0;
|
|
145
60
|
const out: string[] = [];
|
|
146
61
|
for (let i = 0; i < rows; i++) {
|
|
147
62
|
const idx = i - top;
|
|
148
|
-
|
|
63
|
+
const line = idx >= 0 && idx < body.length ? opts.colorize(body[idx]) : '';
|
|
64
|
+
out.push(line + ' '.repeat(Math.max(0, width - styledWidth(line))));
|
|
149
65
|
}
|
|
150
66
|
return out;
|
|
151
67
|
}
|
|
152
68
|
|
|
153
69
|
function wrapTodoBody(items: readonly TodoItem[], width: number): string[] {
|
|
154
|
-
return renderTodoGroups(items).flatMap((line) =>
|
|
70
|
+
return renderTodoGroups(items).flatMap((line) => wrapStyled(line, width));
|
|
155
71
|
}
|
|
156
72
|
|
|
157
73
|
function itemsEqual(a: readonly TodoItem[], b: readonly TodoItem[]): boolean {
|
|
@@ -173,10 +89,6 @@ export interface SlimFrameInput {
|
|
|
173
89
|
now?: number;
|
|
174
90
|
}
|
|
175
91
|
|
|
176
|
-
/**
|
|
177
|
-
* Choose overlay content: todo window when the pane can show ≥3 wrapped rows at ≥16 cols;
|
|
178
|
-
* otherwise the pane title (or SLIM_EMPTY_COPY when the list is empty).
|
|
179
|
-
*/
|
|
180
92
|
export function slimContentLines(
|
|
181
93
|
input: SlimFrameInput & { width: number; rows: number; colorize?: (s: string) => string },
|
|
182
94
|
): string[] {
|
|
@@ -192,7 +104,7 @@ export function slimContentLines(
|
|
|
192
104
|
if (items.length === 0) return titleFrame();
|
|
193
105
|
if (isArchived(items, input.lastWriteAt ?? null, input.now ?? Date.now())) return titleFrame();
|
|
194
106
|
|
|
195
|
-
const header =
|
|
107
|
+
const header = wrapStyled(formatTodoSummary(items), width);
|
|
196
108
|
const fullBody = wrapTodoBody(items, width);
|
|
197
109
|
if (header.length + fullBody.length <= rows) {
|
|
198
110
|
const packed = [...header, ...fullBody];
|
|
@@ -200,7 +112,7 @@ export function slimContentLines(
|
|
|
200
112
|
return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
|
|
201
113
|
}
|
|
202
114
|
|
|
203
|
-
const footerProbe =
|
|
115
|
+
const footerProbe = wrapStyled(' +99 hidden (99✓) · /todos', width);
|
|
204
116
|
const itemBudget = rows - header.length - footerProbe.length;
|
|
205
117
|
if (itemBudget < 1) return titleFrame();
|
|
206
118
|
|
|
@@ -214,14 +126,12 @@ export function slimContentLines(
|
|
|
214
126
|
|
|
215
127
|
const hidden = items.filter((_, i) => i < start || i >= end);
|
|
216
128
|
const hiddenCompleted = hidden.filter((it) => it.status === 'completed').length;
|
|
217
|
-
const footer =
|
|
129
|
+
const footer = wrapStyled(` +${hidden.length} hidden (${hiddenCompleted}✓) · /todos`, width);
|
|
218
130
|
const packed = [...header, ...body, ...footer];
|
|
219
131
|
if (packed.length < SLIM_TODO_MIN_ROWS) return titleFrame();
|
|
220
132
|
return padFrame(packed, { width, rows, colorize, vAlign: 'top' });
|
|
221
133
|
}
|
|
222
134
|
|
|
223
|
-
/* ── Overlay component plus registration/update (process-local singleton) ─────────────────────────── */
|
|
224
|
-
|
|
225
135
|
interface FrameTui {
|
|
226
136
|
requestRender(): void;
|
|
227
137
|
}
|
|
@@ -301,7 +211,6 @@ class SlimFrameComponent {
|
|
|
301
211
|
this.tui.requestRender();
|
|
302
212
|
};
|
|
303
213
|
|
|
304
|
-
/** Start the D98 watchdog (idempotent). SIGWINCH covers the delivered signal; the poll covers lost ones. */
|
|
305
214
|
startResizeWatchdog(): void {
|
|
306
215
|
if (this.poll) return;
|
|
307
216
|
const size = this.viewportSize();
|
|
@@ -329,7 +238,6 @@ class SlimFrameComponent {
|
|
|
329
238
|
}
|
|
330
239
|
}
|
|
331
240
|
|
|
332
|
-
|
|
333
241
|
/** Test injection point (reset module state); dispose first so an undisposed component's watchdog cannot leak. */
|
|
334
242
|
export function resetForTest(): void {
|
|
335
243
|
active?.dispose();
|
|
@@ -338,10 +246,7 @@ export function resetForTest(): void {
|
|
|
338
246
|
|
|
339
247
|
let active: SlimFrameComponent | null = null;
|
|
340
248
|
|
|
341
|
-
/**
|
|
342
|
-
* Register the static-frame overlay (idempotent: once per process). eventCtx is the pi lifecycle event context
|
|
343
|
-
* (the session_start ctx, sourced from the same place as the todo widget's widgetUi).
|
|
344
|
-
*/
|
|
249
|
+
/** Register the static-frame overlay (idempotent, once per process) from the session_start ctx. */
|
|
345
250
|
export function registerSlimFrame(eventCtx: unknown): void {
|
|
346
251
|
if (active) return;
|
|
347
252
|
if (pierOption('PIER_SLIM_FRAME') === '0') return;
|
|
@@ -373,14 +278,12 @@ export function registerSlimFrame(eventCtx: unknown): void {
|
|
|
373
278
|
},
|
|
374
279
|
},
|
|
375
280
|
);
|
|
376
|
-
// Never call done(), leaving the Promise pending so the overlay stays resident; swallow rejection and never await it.
|
|
377
281
|
Promise.resolve(pending).catch(() => {});
|
|
378
282
|
} catch {
|
|
379
283
|
/* Silently degrade on pi versions without overlay support. */
|
|
380
284
|
}
|
|
381
285
|
}
|
|
382
286
|
|
|
383
|
-
/** Update overlay snapshot. Title remains the fallback; items drive the todo window. */
|
|
384
287
|
export function updateSlimFrame(input: SlimFrameInput): void {
|
|
385
288
|
active?.setSnapshot(input.title ?? '', input.items ?? [], input.lastWriteAt ?? null);
|
|
386
289
|
}
|
package/src/stale-core.ts
CHANGED
|
@@ -1,50 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Anti-freeze core: a fully completed list that new work never updates must stop impersonating
|
|
3
|
+
* current state.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - stale (A): open==0 with ≥ STALE_TURNS turns since the last write → the read hook injects a rate-limited,
|
|
9
|
-
* capped stale warning;
|
|
10
|
-
* - archived (B): open==0 with wall-clock age ≥ STALE_CLOCK_MS since the last write → treat it as absent
|
|
11
|
-
* (injection/projection no longer rereads details; entries remain queryable through /todos; session JSONL stays authoritative);
|
|
12
|
-
* - title (D): when archived, pane/sidebar renders `✓N done <age>` instead of impersonating current state.
|
|
5
|
+
* - stale (A): open==0 and ≥ STALE_TURNS turns since the last write → the read hook injects a
|
|
6
|
+
* rate-limited, capped warning;
|
|
7
|
+
* - archived (B): open==0 and age ≥ STALE_CLOCK_MS → the list is treated as absent;
|
|
8
|
+
* - title (D): an archived list renders `✓N done <age>` instead of impersonating current state.
|
|
13
9
|
*
|
|
14
|
-
* A list with open items
|
|
15
|
-
*
|
|
16
|
-
* the turns axis remains unaffected.
|
|
10
|
+
* A list with open items is never stale — agent_settled covers unfinished work. Unknown
|
|
11
|
+
* lastWriteAt (old sessions) conservatively suppresses the clock axis; turns is unaffected.
|
|
17
12
|
*/
|
|
18
13
|
import { countTodos, type TodoItem } from './vocab.ts';
|
|
19
14
|
|
|
20
|
-
/** A: turn-based expiry threshold
|
|
15
|
+
/** A: turn-based expiry threshold, counted from the last todo write. */
|
|
21
16
|
export const STALE_TURNS = 6;
|
|
22
17
|
|
|
23
|
-
/** B:
|
|
18
|
+
/** B: age of the last write above which a completed list is archived. */
|
|
24
19
|
export const STALE_CLOCK_MS = 60 * 60 * 1000;
|
|
25
20
|
|
|
26
|
-
/** A:
|
|
21
|
+
/** A: cap on stale warnings injected during one stalled period. */
|
|
27
22
|
export const STALE_NOTICE_MAX = 3;
|
|
28
23
|
|
|
29
24
|
export type StalenessKind = 'fresh' | 'stale' | 'archived';
|
|
30
25
|
|
|
31
26
|
export interface Staleness {
|
|
32
27
|
kind: StalenessKind;
|
|
33
|
-
/** open = pending + in_progress + blocked (abandoned
|
|
28
|
+
/** open = pending + in_progress + blocked (abandoned is not unfinished). */
|
|
34
29
|
open: number;
|
|
35
|
-
/** now - lastWriteAt; unknown lastWriteAt → null. */
|
|
36
30
|
ageMs: number | null;
|
|
37
31
|
}
|
|
38
32
|
|
|
39
|
-
/** Count open items: stale/archived apply only to fully completed (or fully abandoned) lists with open==0. */
|
|
40
33
|
export function openTodos(items: readonly TodoItem[]): number {
|
|
41
|
-
const c = countTodos(items
|
|
34
|
+
const c = countTodos(items);
|
|
42
35
|
return c.pending + c.inProgress + c.blocked;
|
|
43
36
|
}
|
|
44
37
|
|
|
45
38
|
/**
|
|
46
|
-
*
|
|
47
|
-
*
|
|
39
|
+
* Clock (archived) takes precedence over turns (stale). Empty list → fresh (a separate guard owns
|
|
40
|
+
* empty-list handling); null lastWriteAt → never stale.
|
|
48
41
|
*/
|
|
49
42
|
export function evaluateStaleness(opts: {
|
|
50
43
|
items: readonly TodoItem[];
|
|
@@ -61,7 +54,7 @@ export function evaluateStaleness(opts: {
|
|
|
61
54
|
return st;
|
|
62
55
|
}
|
|
63
56
|
|
|
64
|
-
/**
|
|
57
|
+
/** Title/mirror paths have no turn information: only the wall-clock archive axis. */
|
|
65
58
|
export function isArchived(
|
|
66
59
|
items: readonly TodoItem[],
|
|
67
60
|
lastWriteAt: number | null,
|
|
@@ -70,7 +63,7 @@ export function isArchived(
|
|
|
70
63
|
return evaluateStaleness({ items, lastWriteAt, turnsSinceWrite: null, now }).kind === 'archived';
|
|
71
64
|
}
|
|
72
65
|
|
|
73
|
-
/**
|
|
66
|
+
/** <60m → `Nm`; <48h → `Nh` (floor); otherwise `Nd`. */
|
|
74
67
|
export function formatAge(ms: number): string {
|
|
75
68
|
if (ms < 60 * 60_000) return `${Math.max(1, Math.floor(ms / 60_000))}m`;
|
|
76
69
|
if (ms < 48 * 3_600_000) return `${Math.floor(ms / 3_600_000)}h`;
|
package/src/storage-layout.ts
CHANGED
|
@@ -1,32 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Storage path layout (history, session dirs, role dirs)
|
|
2
|
+
* Storage path layout (history, session dirs, role dirs) — the single owner of the session-dir
|
|
3
|
+
* encoding, so history-store / session-tail / role-loader cannot drift apart.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* so path conventions cannot drift.
|
|
7
|
-
*
|
|
8
|
-
* Writes use a collision-resistant encoding (`a/b` ≠ `a-b`). Reads prefer the
|
|
9
|
-
* new name, then the legacy flattened name, so existing history/session dirs
|
|
10
|
-
* stay reachable. If only the legacy dir exists, writes keep appending there
|
|
11
|
-
* instead of splitting the ledger.
|
|
5
|
+
* Writes use a collision-resistant encoding (`a/b` ≠ `a-b`); reads try the new name, then the legacy
|
|
6
|
+
* flattened name, and when only the legacy dir exists writes keep appending there (no split ledger).
|
|
12
7
|
*/
|
|
13
8
|
import { existsSync } from 'node:fs';
|
|
14
9
|
import { homedir } from 'node:os';
|
|
15
10
|
import { join } from 'node:path';
|
|
16
11
|
|
|
17
|
-
/**
|
|
18
|
-
* Legacy flattening (`--F--herdr-pi--`).
|
|
19
|
-
* Why kept: `a/b` and `a-b` collide, but on-disk dirs already use this form.
|
|
20
|
-
*/
|
|
12
|
+
/** Legacy flattening (`--F--herdr-pi--`): `a/b` and `a-b` collide, but on-disk dirs use this form. */
|
|
21
13
|
export function sessionDirNameLegacy(cwd: string): string {
|
|
22
|
-
|
|
23
|
-
return `--${flat}--`;
|
|
14
|
+
return `--${cwd.replace(/[\\/]/g, '-').replace(/:/g, '-')}--`;
|
|
24
15
|
}
|
|
25
16
|
|
|
26
|
-
/**
|
|
27
|
-
* Collision-resistant session directory name.
|
|
28
|
-
* `%` is escaped first so `/` `\` `:` round-trip: `a/b` → `--a%2Fb--`, `a-b` → `--a-b--`.
|
|
29
|
-
*/
|
|
17
|
+
/** Collision-resistant name: `%` is escaped first so `/` `\` `:` round-trip (`a/b` → `--a%2Fb--`). */
|
|
30
18
|
export function sessionDirName(cwd: string): string {
|
|
31
19
|
const encoded = cwd
|
|
32
20
|
.replace(/%/g, '%25')
|
|
@@ -36,7 +24,6 @@ export function sessionDirName(cwd: string): string {
|
|
|
36
24
|
return `--${encoded}--`;
|
|
37
25
|
}
|
|
38
26
|
|
|
39
|
-
/** New encoding first, then legacy when they differ. */
|
|
40
27
|
export function sessionDirCandidates(cwd: string): readonly string[] {
|
|
41
28
|
const next = sessionDirName(cwd);
|
|
42
29
|
const prev = sessionDirNameLegacy(cwd);
|
|
@@ -44,39 +31,26 @@ export function sessionDirCandidates(cwd: string): readonly string[] {
|
|
|
44
31
|
}
|
|
45
32
|
|
|
46
33
|
/**
|
|
47
|
-
* pi core's own session-directory encoding, byte-for-byte
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
* `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--` (strip ONE leading separator,
|
|
51
|
-
* then flatten `/` `\` `:` — note it does NOT escape `%` like `sessionDirName` does).
|
|
52
|
-
* Verified on this machine: `/Users/yehaoyu/Documents/pier` →
|
|
53
|
-
* `--Users-yehaoyu-Documents-pier--` (pier's own encodings produce `---Users-…--` and
|
|
54
|
-
* `--%2FUsers…--`, neither of which exists on disk).
|
|
34
|
+
* pi core's own session-directory encoding, byte-for-byte: strip ONE leading separator, then flatten
|
|
35
|
+
* `/` `\` `:` — it does NOT escape `%` like `sessionDirName`. `<agentDir>/sessions/` is pi core's
|
|
36
|
+
* directory, so reading pi sessions must match it exactly.
|
|
55
37
|
*/
|
|
56
38
|
export function piCoreSessionDirName(cwd: string): string {
|
|
57
39
|
return `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
|
|
58
40
|
}
|
|
59
41
|
|
|
60
42
|
/**
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
* Read-only on purpose: writes to `<agentDir>/sessions/` are pi core's business.
|
|
43
|
+
* pi core's exact name first, then pier's own encodings (they may hold stray dirs from earlier
|
|
44
|
+
* versions). Read-only: writes to `<agentDir>/sessions/` are pi core's business.
|
|
64
45
|
*/
|
|
65
46
|
export function piSessionDirCandidates(cwd: string): readonly string[] {
|
|
66
47
|
return [...new Set([piCoreSessionDirName(cwd), ...sessionDirCandidates(cwd)])];
|
|
67
48
|
}
|
|
68
49
|
|
|
69
|
-
/**
|
|
70
|
-
* Directory to read/write under `parent`.
|
|
71
|
-
* Existing legacy dirs win over a missing new dir so history is not split.
|
|
72
|
-
*/
|
|
50
|
+
/** Directory to read/write under `parent`: an existing legacy dir wins so history is not split. */
|
|
73
51
|
export function preferredSessionDir(parent: string, cwd: string): string {
|
|
74
52
|
const names = sessionDirCandidates(cwd);
|
|
75
|
-
|
|
76
|
-
const dir = join(parent, name);
|
|
77
|
-
if (existsSync(dir)) return dir;
|
|
78
|
-
}
|
|
79
|
-
return join(parent, names[0]);
|
|
53
|
+
return names.map((name) => join(parent, name)).find(existsSync) ?? join(parent, names[0]!);
|
|
80
54
|
}
|
|
81
55
|
|
|
82
56
|
export function historyFilePath(agentRoot: string, cwd: string): string {
|
|
@@ -87,17 +61,14 @@ export function historyFilePathLegacy(agentRoot: string, cwd: string): string {
|
|
|
87
61
|
return join(agentRoot, 'herdr-pi', 'history', sessionDirNameLegacy(cwd), 'history.jsonl');
|
|
88
62
|
}
|
|
89
63
|
|
|
90
|
-
/** Dual-read history path: existing ledger (legacy or new) else canonical new file. */
|
|
91
64
|
export function preferredHistoryFile(agentRoot: string, cwd: string): string {
|
|
92
65
|
return join(preferredSessionDir(join(agentRoot, 'herdr-pi', 'history'), cwd), 'history.jsonl');
|
|
93
66
|
}
|
|
94
67
|
|
|
95
|
-
/** User-global roles directory (`~/.pi/agent/herdr-pi/roles/`). */
|
|
96
68
|
export function userRolesDir(): string {
|
|
97
69
|
return join(homedir(), '.pi', 'agent', 'herdr-pi', 'roles');
|
|
98
70
|
}
|
|
99
71
|
|
|
100
|
-
/** Workspace-level roles directory (`<base>/.pi-herdr/roles/`). */
|
|
101
72
|
export function workspaceRolesDir(baseDir: string): string {
|
|
102
73
|
return join(baseDir, '.pi-herdr', 'roles');
|
|
103
74
|
}
|