piher 0.0.0-stage → 0.2.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 (60) hide show
  1. package/README.md +60 -2
  2. package/package.json +58 -4
  3. package/schemas/role-manifest.schema.json +62 -0
  4. package/skills/piher-tools/SKILL.md +212 -0
  5. package/src/ansi-text.ts +143 -0
  6. package/src/bg-job-core.ts +412 -0
  7. package/src/bootstrap.ts +102 -0
  8. package/src/config-catalog-core.ts +200 -0
  9. package/src/config-command.ts +324 -0
  10. package/src/cordis-services.ts +38 -0
  11. package/src/core-client.ts +715 -0
  12. package/src/delegation-core.ts +94 -0
  13. package/src/gc-core.ts +150 -0
  14. package/src/guards.ts +6 -0
  15. package/src/hc-templates.ts +134 -0
  16. package/src/index-locks.ts +118 -0
  17. package/src/index-master.ts +92 -0
  18. package/src/index-notices.ts +79 -0
  19. package/src/index-pipe.ts +71 -0
  20. package/src/index-roles.ts +265 -0
  21. package/src/index-runtime.ts +23 -0
  22. package/src/index.ts +460 -0
  23. package/src/ledger.ts +61 -0
  24. package/src/lock-core.ts +200 -0
  25. package/src/manifest-compose.ts +103 -0
  26. package/src/options.ts +78 -0
  27. package/src/pi-surface.ts +140 -0
  28. package/src/pipe-channel.ts +291 -0
  29. package/src/platform-paths.ts +37 -0
  30. package/src/plugins/bg-job.ts +560 -0
  31. package/src/plugins/subagent.ts +1244 -0
  32. package/src/prompt-store.ts +50 -0
  33. package/src/renderers.ts +195 -0
  34. package/src/role-catalog.ts +99 -0
  35. package/src/role-loader.ts +196 -0
  36. package/src/role-manifest.ts +163 -0
  37. package/src/role-state.ts +119 -0
  38. package/src/roles/master.json +23 -0
  39. package/src/roles/worker-default.json +23 -0
  40. package/src/runtime-policy.ts +45 -0
  41. package/src/session-state.ts +19 -0
  42. package/src/session-tail.ts +276 -0
  43. package/src/settle-wake-core.ts +73 -0
  44. package/src/settlement-card.ts +96 -0
  45. package/src/storage-layout.ts +92 -0
  46. package/src/subagent-core.ts +543 -0
  47. package/src/subagent-gc.ts +312 -0
  48. package/src/subagent-poller.ts +484 -0
  49. package/src/subagent-session.ts +548 -0
  50. package/src/subagent-spawn.ts +695 -0
  51. package/src/swallow.ts +59 -0
  52. package/src/task-row.ts +257 -0
  53. package/src/task-store.ts +173 -0
  54. package/src/tool-error.ts +19 -0
  55. package/src/tool-gate.ts +99 -0
  56. package/src/tree-view.ts +58 -0
  57. package/src/vocab.ts +41 -0
  58. package/src/worker-marker.ts +65 -0
  59. package/src/worktree-io.ts +177 -0
  60. package/src/worktree.ts +280 -0
package/src/swallow.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `swallow(tag, err)` keeps catch-site cleanup behaviour, but records the event in a bounded ring
3
+ * buffer, so `/piher-config` can show the recent ones and a bug report can quote them. Stays
4
+ * silent: cleanup paths must never add noise to the transcript.
5
+ */
6
+
7
+ export interface SwallowedError {
8
+ /** Stable site label, e.g. 'role.persist-switch'. */
9
+ readonly tag: string;
10
+ readonly message: string;
11
+ /** Epoch ms. */
12
+ readonly at: number;
13
+ }
14
+
15
+ export const SWALLOW_BUFFER_MAX = 50;
16
+
17
+ const buffer: SwallowedError[] = [];
18
+
19
+ function describe(err: unknown): string {
20
+ if (err instanceof Error) {
21
+ const code = (err as { code?: unknown }).code;
22
+ return code !== undefined ? `${err.name}: ${err.message} (code ${String(code)})` : `${err.name}: ${err.message}`;
23
+ }
24
+ if (typeof err === 'string') return err;
25
+ try {
26
+ return JSON.stringify(err);
27
+ } catch {
28
+ return String(err);
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Record a caught error that is being intentionally ignored; call sites keep their cleanup and stay
34
+ * `} catch (err) { swallow('tag', err); }`.
35
+ */
36
+ export function swallow(tag: string, err: unknown): void {
37
+ const entry: SwallowedError = { tag, message: describe(err), at: Date.now() };
38
+ buffer.push(entry);
39
+ if (buffer.length > SWALLOW_BUFFER_MAX) buffer.splice(0, buffer.length - SWALLOW_BUFFER_MAX);
40
+ }
41
+
42
+ /** Recent swallowed errors, oldest first (diagnostics only; never persisted). */
43
+ export function swallowedErrors(): readonly SwallowedError[] {
44
+ return [...buffer];
45
+ }
46
+
47
+ export function resetSwallowedErrors(): void {
48
+ buffer.length = 0;
49
+ }
50
+
51
+ /** Render for `/piher-config`; the section is never ambiguous even with no entries. */
52
+ export function formatSwallowedErrors(entries: readonly SwallowedError[] = swallowedErrors()): string {
53
+ if (entries.length === 0) return 'swallowed errors: none this session';
54
+ const lines = entries.slice(-10).map((e) => {
55
+ const hhmmss = new Date(e.at).toISOString().slice(11, 19);
56
+ return `${hhmmss} ${e.tag}: ${e.message}`;
57
+ });
58
+ return [`swallowed errors: ${entries.length} this session (last 10)`, ...lines].join('\n');
59
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Task rows: the one row type behind delegation state (spec S1-S12, `docs/spec/domain.md`).
3
+ *
4
+ * A task is one delegation unit — a spawned subagent, never a tool call (S1). Every later
5
+ * action sends, closes, retries or resumes the same task; only the generation (pane, tab,
6
+ * transcript) is replaced on revive (S3). The durable facts are exactly the row below (S10);
7
+ * liveness is an observation from core and lives in memory only (S11).
8
+ */
9
+ import { foldWorktree, type WorktreeRecord } from './worktree.ts';
10
+ import type { Lifecycle } from './role-manifest.ts';
11
+
12
+ /** One observation of a task's liveness, read from core when a surface needs it (S11). It feeds
13
+ * only the state text a surface prints; an action never reads it to decide (S26). A stale
14
+ * observation can mislabel a row — never revive, close or steer the wrong one. */
15
+ export interface TaskObservation {
16
+ paneExists: boolean;
17
+ agentStatus: string | null;
18
+ lastActivityMs: number | null;
19
+ at: number;
20
+ }
21
+
22
+ /** The state text `list` and the widget print (contracts): the three predicates plus what core
23
+ * last said. Without an observation the durable facts alone decide: a delivered report reads
24
+ * `idle`, anything else `running` — the zero-observation base a fresh spawn shares. */
25
+ export type TaskStateText = 'running' | 'idle' | 'no report' | 'no pane';
26
+
27
+ export function describeTaskState(
28
+ row: Pick<TaskRow, 'gen'>,
29
+ obs?: Pick<TaskObservation, 'paneExists' | 'agentStatus'> | null,
30
+ ): TaskStateText {
31
+ if (obs) {
32
+ if (!obs.paneExists) return 'no pane';
33
+ if (obs.agentStatus === 'working' || obs.agentStatus === 'blocked') return 'running';
34
+ }
35
+ return row.gen.consumedAt !== null ? 'idle' : obs ? 'no report' : 'running';
36
+ }
37
+
38
+ /** One generation: the pane, tab and transcript a revive replaces wholesale (S3). Earlier
39
+ * generations leave no durable trace. */
40
+ export interface TaskGeneration {
41
+ paneId: string;
42
+ slug: string;
43
+ tabId: string;
44
+ tabName: string;
45
+ /** The worker's transcript: resume needs it to exist. */
46
+ sessionFile: string | null;
47
+ launchCommand: string[];
48
+ startedAt: number;
49
+ /** The report reached the master (S10). */
50
+ consumedAt: number | null;
51
+ reportDelivered: { fingerprint: string; at: number } | null;
52
+ }
53
+
54
+ export interface TaskRow {
55
+ /** Minted at spawn; the task's identity across revives (S1). */
56
+ taskId: string;
57
+ description: string;
58
+ /** Role profile the worker was launched with. */
59
+ kind: string;
60
+ lifecycle: 'one-shot' | 'persistent';
61
+ cwd: string;
62
+ /** The checkout the spawn created, if any. */
63
+ worktree?: WorktreeRecord;
64
+ createdAt: number;
65
+ /** The master's final word (S9). */
66
+ closedAt: number | null;
67
+ gen: TaskGeneration;
68
+ }
69
+
70
+ /** What id resolution answers: a resident memory row or a transient record copy, flagged when
71
+ * the row belongs to another session (S17). Transient rows resolve for action, never adopted. */
72
+ export type ResolvedTaskEntry = { entry: TaskEntry; foreign: boolean } | { error: string };
73
+
74
+ /** A task resident in memory: the durable row plus transient control state. The transient
75
+ * fields are stripped at the document seam (`toTaskRow`) and never reach disk. Liveness is
76
+ * not among them: it is observed from core per surface, never stored (S11, S12). */
77
+ export interface TaskEntry extends TaskRow {
78
+ /** Settle window start (poller state): settlement waits until the transcript settles. */
79
+ settleWindowStartedAt?: number | null;
80
+ /** The closing text already announced for this pane (fingerprint + time). Two pollers can
81
+ * settle the same transcript — a recovery re-arm and a live request carry different ids —
82
+ * and the claim is per request, so this is what stops the master from reading one
83
+ * settlement twice. */
84
+ noticeAnnounced?: { fingerprint: string; at: number } | null;
85
+ }
86
+
87
+ /** The durable row alone: what a record file holds. */
88
+ export function toTaskRow(entry: TaskEntry): TaskRow {
89
+ const { settleWindowStartedAt: _w, noticeAnnounced: _n, ...row } = entry;
90
+ void _w;
91
+ void _n;
92
+ const fingerprint = row.gen.reportDelivered ? { ...row.gen.reportDelivered } : null;
93
+ const delivered = fingerprint ? { reportDelivered: fingerprint } : {};
94
+ return {
95
+ ...row,
96
+ gen: { ...row.gen, ...delivered },
97
+ ...(row.worktree ? { worktree: { ...row.worktree } } : {}),
98
+ };
99
+ }
100
+
101
+ /* ── predicates (S12) ─────────────────────────────────────────── */
102
+
103
+ export function isClosed(row: Pick<TaskRow, 'closedAt'>): boolean {
104
+ return row.closedAt !== null;
105
+ }
106
+
107
+ export function isSpent(row: Pick<TaskRow, 'lifecycle' | 'gen'>): boolean {
108
+ return row.lifecycle === 'one-shot' && row.gen.consumedAt !== null;
109
+ }
110
+
111
+ export function isActionable(row: Pick<TaskRow, 'closedAt' | 'lifecycle' | 'gen'>): boolean {
112
+ return !isClosed(row) && !isSpent(row);
113
+ }
114
+
115
+ /* ── tolerant parsing ─────────────────────────────────────────── */
116
+
117
+ /** Legacy short/resident/missing kinds normalize to task; role names pass through. */
118
+ export function normalizeEntryKind(kind: string | null | undefined): string {
119
+ if (!kind || kind === 'short' || kind === 'resident') return 'task';
120
+ return kind;
121
+ }
122
+
123
+ /** Only a .jsonl path is accepted, so an invalid report cannot overwrite a valid one. */
124
+ export function applyReportedSessionFile(
125
+ current: string | null,
126
+ reported: string | null | undefined,
127
+ ): string | null {
128
+ if (typeof reported === 'string' && /\.jsonl$/i.test(reported)) return reported;
129
+ return current;
130
+ }
131
+
132
+ function asString(value: unknown, fallback = ''): string {
133
+ return typeof value === 'string' ? value : fallback;
134
+ }
135
+
136
+ function asTime(value: unknown): number | null {
137
+ return typeof value === 'number' && Number.isFinite(value) ? value : null;
138
+ }
139
+
140
+ /** One row out of an unknown document. Unknown fields are kept off; a row without a taskId
141
+ * is not a row. Never throws. */
142
+ export function parseTaskRow(value: unknown): TaskRow | null {
143
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return null;
144
+ const rec = value as Record<string, unknown>;
145
+ if (typeof rec.taskId !== 'string' || rec.taskId.trim() === '') return null;
146
+ const gen = (rec.gen !== null && typeof rec.gen === 'object' && !Array.isArray(rec.gen)
147
+ ? rec.gen as Record<string, unknown>
148
+ : {});
149
+ const delivered = gen.reportDelivered !== null && typeof gen.reportDelivered === 'object'
150
+ && !Array.isArray(gen.reportDelivered)
151
+ && typeof (gen.reportDelivered as Record<string, unknown>).fingerprint === 'string'
152
+ && typeof (gen.reportDelivered as Record<string, unknown>).at === 'number'
153
+ ? {
154
+ fingerprint: (gen.reportDelivered as Record<string, unknown>).fingerprint as string,
155
+ at: (gen.reportDelivered as Record<string, unknown>).at as number,
156
+ }
157
+ : null;
158
+ const worktree = foldWorktree(rec.worktree);
159
+ const lifecycle: Lifecycle = rec.lifecycle === 'one-shot' ? 'one-shot' : 'persistent';
160
+ return {
161
+ taskId: rec.taskId,
162
+ description: asString(rec.description),
163
+ kind: normalizeEntryKind(typeof rec.kind === 'string' ? rec.kind : undefined),
164
+ lifecycle,
165
+ cwd: asString(rec.cwd),
166
+ ...(worktree ? { worktree } : {}),
167
+ createdAt: asTime(rec.createdAt) ?? 0,
168
+ closedAt: asTime(rec.closedAt),
169
+ gen: {
170
+ paneId: asString(gen.paneId),
171
+ slug: asString(gen.slug),
172
+ tabId: asString(gen.tabId),
173
+ tabName: asString(gen.tabName),
174
+ sessionFile: typeof gen.sessionFile === 'string' ? gen.sessionFile : null,
175
+ launchCommand: Array.isArray(gen.launchCommand)
176
+ ? gen.launchCommand.filter((part): part is string => typeof part === 'string')
177
+ : [],
178
+ startedAt: asTime(gen.startedAt) ?? 0,
179
+ consumedAt: asTime(gen.consumedAt),
180
+ reportDelivered: delivered,
181
+ },
182
+ };
183
+ }
184
+
185
+ export interface TaskDocument {
186
+ rows: TaskRow[];
187
+ taskIds: string[];
188
+ }
189
+
190
+ /**
191
+ * The `{version:1, rows:[TaskRow]}` envelope both documents share, read tolerantly: a
192
+ * missing, torn or foreign document parses to empty lists and never raises (S6). The record
193
+ * file holds its row under `rows`, the session view its ids under `taskIds`.
194
+ */
195
+ export function parseTaskDocument(value: unknown): TaskDocument {
196
+ const empty: TaskDocument = { rows: [], taskIds: [] };
197
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return empty;
198
+ const rec = value as Record<string, unknown>;
199
+ if (rec.version !== 1) return empty;
200
+ const rows = Array.isArray(rec.rows)
201
+ ? rec.rows.map(parseTaskRow).filter((row): row is TaskRow => row !== null)
202
+ : [];
203
+ const isId = (id: unknown): id is string => typeof id === 'string' && id.trim() !== '';
204
+ const taskIds = Array.isArray(rec.taskIds) ? [...new Set(rec.taskIds.filter(isId))] : [];
205
+ return { rows, taskIds };
206
+ }
207
+
208
+ /* ── task slugs ───────────────────────────────────────────────── */
209
+
210
+ /** Slug cap: short enough for tool args and list rows, long enough to stay distinctive. */
211
+ const SLUG_MAX = 24;
212
+
213
+ /** Kebab-case a task description; non-ASCII (CJK descriptions) sanitizes to '' — the allocator
214
+ * then falls back to a taskId-derived ascii slug, because tool args must stay typeable. */
215
+ export function slugifyTask(description: string): string {
216
+ return description
217
+ .toLowerCase()
218
+ .replace(/[''']/g, '')
219
+ .replace(/[^a-z0-9]+/g, '-')
220
+ .replace(/^-+|-+$/g, '')
221
+ .slice(0, SLUG_MAX)
222
+ .replace(/-+$/g, '');
223
+ }
224
+
225
+ /** Allocate a unique slug among live rows: base, then base-2, base-3, …; a description that
226
+ * sanitizes to nothing (CJK, emoji-only) falls back to `task-<taskId prefix>`. */
227
+ export function allocateTaskSlug(
228
+ description: string,
229
+ taskId: string,
230
+ taken: ReadonlySet<string>,
231
+ ): string {
232
+ const base = slugifyTask(description) || `task-${taskId.slice(0, 6)}`;
233
+ if (!taken.has(base)) return base;
234
+ for (let n = 2; ; n++) {
235
+ const next = `${base}-${n}`;
236
+ if (!taken.has(next)) return next;
237
+ }
238
+ }
239
+
240
+ /** Stable display/argument form for a task row: `slug@paneId` when a slug exists. */
241
+ export function taskRef(task: { paneId: string; slug?: string }): string {
242
+ return task.slug ? `${task.slug}@${task.paneId}` : task.paneId;
243
+ }
244
+
245
+ /* ── memory index ─────────────────────────────────────────────── */
246
+
247
+ /** Resolve a pane id to its task's entry through the pane→task index. A task outlives its
248
+ * pane; the pane id is part of the generation, so lookups by pane go through the task. */
249
+ export function taskForPane(
250
+ tasks: ReadonlyMap<string, TaskEntry>,
251
+ panes: ReadonlyMap<string, string>,
252
+ paneId: string,
253
+ ): TaskEntry | undefined {
254
+ const taskId = panes.get(paneId);
255
+ if (!taskId) return undefined;
256
+ return tasks.get(taskId);
257
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The task document store: one atomic writer and reader for both durable documents (S5, S6).
3
+ *
4
+ * - task record `<repo>/.piher/tasks/<taskId>.json` — `{version:1, rows:[TaskRow]}`, one row;
5
+ * - session view `<sessionDir>/piher/<sessionId>/tasks.json` — `{version:1, taskIds:[...]}`.
6
+ *
7
+ * Every write is a temporary file + rename, so a crash mid-write leaves the previous document
8
+ * intact (S6). A missing, torn or foreign document reads as "no record" and never raises (S6).
9
+ * Nothing durable is pruned by age or by count (S8).
10
+ */
11
+ import {
12
+ copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync,
13
+ } from 'node:fs';
14
+ import { dirname, join } from 'node:path';
15
+ import { sessionStatePath } from './session-state.ts';
16
+ import { mainWorktreeRoot } from './storage-layout.ts';
17
+ import { parseTaskDocument, toTaskRow, type TaskEntry, type TaskRow } from './task-row.ts';
18
+ import { swallow } from './swallow.ts';
19
+
20
+ export const TASKS_DIR_NAME = 'tasks';
21
+ export const TASK_VIEW_NAME = 'tasks.json';
22
+
23
+ /**
24
+ * The task directory of the repository containing `cwd`: one directory per repository inside
25
+ * the main checkout, so every worktree of a repository shares it. Outside a git repository
26
+ * there is no directory and no fallback. `.piher/tasks/` must be git-ignored (never the
27
+ * parent `.piher/`, whose `roles/` are meant to be committed).
28
+ */
29
+ export function repoTasksDir(cwd: string): string | null {
30
+ const root = mainWorktreeRoot(cwd);
31
+ return root === null ? null : join(root, '.piher', TASKS_DIR_NAME);
32
+ }
33
+
34
+ export function taskRecordPath(dir: string, taskId: string): string {
35
+ return join(dir, `${taskId}.json`);
36
+ }
37
+
38
+ /** Replace the document atomically (tmp + rename): a crash mid-write must not leave half of one. */
39
+ function writeDocument(file: string, doc: unknown): boolean {
40
+ try {
41
+ mkdirSync(dirname(file), { recursive: true });
42
+ const tmp = `${file}.tmp`;
43
+ writeFileSync(tmp, `${JSON.stringify(doc)}\n`);
44
+ renameSync(tmp, file);
45
+ return true;
46
+ } catch (err) {
47
+ console.error(`[piher] task document write failed: ${file}: ${(err as Error).message}`);
48
+ return false;
49
+ }
50
+ }
51
+
52
+ /** One log line per checkout with no repository, so a long session outside git stays readable. */
53
+ const noRepoLogged = new Set<string>();
54
+
55
+ function logNoRepo(cwd: string): void {
56
+ // A checkout the spawn itself removed is not a misconfiguration: the rows it wrote while it
57
+ // existed are its history, and a line about a path that no longer exists only reads as an error.
58
+ if (!existsSync(cwd)) return;
59
+ if (noRepoLogged.has(cwd)) return;
60
+ noRepoLogged.add(cwd);
61
+ console.error(
62
+ `[piher] no delegation ledger: ${cwd} is not inside a git repository, ` +
63
+ 'so delegation history is not recorded and resume needs one',
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Write one task record: the task, its current generation and its decision. Best-effort —
69
+ * bookkeeping must never break delegation. Only the durable row is written; the transient
70
+ * memory state stays out of the document (S11, S12).
71
+ */
72
+ export function writeTaskRecord(cwd: string, entry: TaskEntry): boolean {
73
+ const dir = repoTasksDir(cwd);
74
+ if (dir === null) {
75
+ logNoRepo(cwd);
76
+ return false;
77
+ }
78
+ const row: TaskRow = toTaskRow(entry);
79
+ return writeDocument(taskRecordPath(dir, row.taskId), { version: 1, rows: [row] });
80
+ }
81
+
82
+ /**
83
+ * Delete one task record (S8: besides `close`, deleting its record file is the only way a task
84
+ * leaves use). What never started leaves nothing behind; anything else goes through `close`.
85
+ */
86
+ export function deleteTaskRecord(cwd: string, taskId: string): void {
87
+ const dir = repoTasksDir(cwd);
88
+ if (dir === null || taskId.trim() === '') return;
89
+ try {
90
+ rmSync(taskRecordPath(dir, taskId), { force: true });
91
+ } catch (err) {
92
+ swallow('task-store.delete-record', err);
93
+ }
94
+ }
95
+
96
+ /** Read one task record by id. A missing, torn or foreign document is "no record", never raised. */
97
+ export function readTaskRecord(cwd: string, taskId: string): TaskRow | null {
98
+ const dir = repoTasksDir(cwd);
99
+ if (dir === null || taskId.trim() === '') return null;
100
+ try {
101
+ const parsed: unknown = JSON.parse(readFileSync(taskRecordPath(dir, taskId), 'utf8'));
102
+ const row = parseTaskDocument(parsed).rows[0] ?? null;
103
+ // The file name is the address: a row for another task inside is a foreign document.
104
+ if (!row || row.taskId !== taskId) return null;
105
+ return row;
106
+ } catch (err) {
107
+ swallow('task-store.read-record', err);
108
+ return null;
109
+ }
110
+ }
111
+
112
+ /** Every readable record of the repository containing `cwd`: the candidate set `resume`
113
+ * resolves against. Unreadable files are skipped, not fatal. */
114
+ export function listTaskRecords(cwd: string): TaskRow[] {
115
+ const dir = repoTasksDir(cwd);
116
+ if (dir === null) return [];
117
+ let names: string[];
118
+ try {
119
+ names = readdirSync(dir);
120
+ } catch (err) {
121
+ swallow('task-store.list-records', err);
122
+ return [];
123
+ }
124
+ const rows: TaskRow[] = [];
125
+ for (const name of names) {
126
+ if (!name.endsWith('.json')) continue;
127
+ const row = readTaskRecord(cwd, name.slice(0, -'.json'.length));
128
+ if (row) rows.push(row);
129
+ }
130
+ return rows;
131
+ }
132
+
133
+ /**
134
+ * The session's view: the ordered task ids it delegated. Null when the session has nowhere to
135
+ * sit (no transcript path — the view then stays in memory only, S25) or when the document is
136
+ * missing, torn or foreign.
137
+ */
138
+ export function readTaskView(sessionFile: string | null | undefined): string[] | null {
139
+ const file = sessionStatePath(sessionFile ?? null, TASK_VIEW_NAME);
140
+ if (!file) return null;
141
+ try {
142
+ const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'));
143
+ return parseTaskDocument(parsed).taskIds;
144
+ } catch (err) {
145
+ swallow('task-store.read-view', err);
146
+ return null;
147
+ }
148
+ }
149
+
150
+ /** Replace the session view. A session with no transcript path keeps it in memory only (S25). */
151
+ export function writeTaskView(
152
+ sessionFile: string | null | undefined,
153
+ taskIds: readonly string[],
154
+ ): boolean {
155
+ const file = sessionStatePath(sessionFile ?? null, TASK_VIEW_NAME);
156
+ if (!file) return false;
157
+ return writeDocument(file, { version: 1, taskIds: [...taskIds] });
158
+ }
159
+
160
+ /** A fork gets the view it branched from: the parent's document is copied once, before the
161
+ * restore reads it. Records are shared, not copied: the fork continues the same tasks. */
162
+ export function seedTaskView(to: string | null, from: string | null): boolean {
163
+ if (!to || !from || to === from) return false;
164
+ try {
165
+ if (existsSync(to) || !existsSync(from)) return false;
166
+ mkdirSync(dirname(to), { recursive: true });
167
+ copyFileSync(from, to);
168
+ return true;
169
+ } catch (err) {
170
+ swallow('task-store.seed-view', err);
171
+ return false;
172
+ }
173
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * pi only marks a tool result as failed when `execute()` throws; returning a value never sets the
3
+ * error flag (pi docs/extensions.md, "Signaling errors"). Every hard failure goes through
4
+ * `toolError()`, while deliberate non-failures (empty result set, "no match within Xms", a pane that
5
+ * has not produced output yet) stay normal results.
6
+ *
7
+ * The leading "Error: " text is stripped: the provider's tool-result error channel and pi's red
8
+ * rendering already mark it, and keeping it would double the prefix once pi formats a thrown error.
9
+ */
10
+ export class ToolError extends Error {
11
+ constructor(message: string) {
12
+ super(String(message).replace(/^Error:\s*/, ''));
13
+ this.name = 'ToolError';
14
+ }
15
+ }
16
+
17
+ export function toolError(message: string): never {
18
+ throw new ToolError(message);
19
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Worker execution enforcement (C7, D76/D77/D82) — pure. RuntimeRoleManifest is THE runtime manifest
3
+ * shape; the file format lives in role-manifest.ts and the composed/persisted shapes derive from it
4
+ * (manifest-compose.ts, role-state.ts).
5
+ */
6
+ import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
7
+
8
+ export interface RuntimeRoleManifest {
9
+ role: string;
10
+ version?: string;
11
+ tools: string[];
12
+ permissions: Record<string, PermissionAction>;
13
+ /** Stance for tools outside `tools`; 'allow' keeps user-installed extensions visible (D82). */
14
+ unknownTools?: UnknownToolStance;
15
+ /** P0 per-role guidelines (RFC §4.6): injected as a `piher-role` prompt section each turn. */
16
+ guidelines?: string[];
17
+ /** Prose injected after the guidelines, in the same `piher-role` section. */
18
+ instructions?: string;
19
+ }
20
+
21
+ /** Parse the env-carried manifest; malformed input is null (fail-open: no gate) rather than a crash. */
22
+ export function parseRuntimeManifest(envValue: string | undefined): RuntimeRoleManifest | null {
23
+ if (!envValue) return null;
24
+ try {
25
+ const m = JSON.parse(envValue) as RuntimeRoleManifest;
26
+ if (!m || typeof m.role !== 'string' || !Array.isArray(m.tools)) return null;
27
+ if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
28
+ m.unknownTools = 'deny';
29
+ }
30
+ if (m.guidelines !== undefined) {
31
+ m.guidelines = Array.isArray(m.guidelines)
32
+ ? m.guidelines.filter((g) => typeof g === 'string' && g.trim() !== '')
33
+ : undefined;
34
+ }
35
+ if (m.instructions !== undefined && (typeof m.instructions !== 'string' || m.instructions.trim() === '')) {
36
+ m.instructions = undefined;
37
+ }
38
+ return m;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ export type ToolGatePlan =
45
+ | { kind: 'open' }
46
+ | { kind: 'allow' }
47
+ | { kind: 'deny'; reason: string };
48
+
49
+ export function planToolGate(toolName: string, manifest: RuntimeRoleManifest | null): ToolGatePlan {
50
+ if (!manifest) return { kind: 'open' };
51
+ const ruleKey = manifest.permissions[toolName] !== undefined ? toolName : '*';
52
+ const perm = manifest.permissions[ruleKey] ?? 'allow';
53
+ const known = manifest.tools.includes(toolName);
54
+ const stance = manifest.unknownTools ?? 'deny';
55
+ if (perm === 'deny' || (!known && stance === 'deny')) {
56
+ return {
57
+ kind: 'deny',
58
+ reason:
59
+ `role "${manifest.role}" does not permit tool "${toolName}" ` +
60
+ `(manifest tools: ${manifest.tools.join(', ')}). ` +
61
+ `If the task needs it, ask the master to delegate differently or adjust the role profile.`,
62
+ };
63
+ }
64
+ return { kind: 'allow' };
65
+ }
66
+
67
+ /**
68
+ * 'allow' keeps every tool but the explicit deny rules (D82 user-installed-extension axis); 'deny'
69
+ * intersects with the manifest. Shared by planActiveTools (visibility) and role-state's switch
70
+ * planning (all-tools universe).
71
+ */
72
+ export function filterToolsByStance(
73
+ manifestTools: readonly string[],
74
+ universe: readonly string[],
75
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
76
+ ): string[] {
77
+ if ((opts?.unknownTools ?? 'deny') === 'allow') {
78
+ const denied = new Set(
79
+ Object.entries(opts?.permissions ?? {})
80
+ .filter(([, action]) => action === 'deny')
81
+ .map(([name]) => name),
82
+ );
83
+ return universe.filter((name) => !denied.has(name));
84
+ }
85
+ const wanted = new Set(manifestTools);
86
+ return universe.filter((name) => wanted.has(name));
87
+ }
88
+
89
+ /** Null means "keep the current active set": an empty result must never clear every tool (D77). */
90
+ export function planActiveTools(
91
+ manifestTools: readonly string[],
92
+ currentActive: readonly string[],
93
+ opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
94
+ ): { next: string[]; changed: boolean } | null {
95
+ if (manifestTools.length === 0 || currentActive.length === 0) return null;
96
+ const next = filterToolsByStance(manifestTools, currentActive, opts);
97
+ if (next.length === 0) return null;
98
+ return { next, changed: next.length !== currentActive.length };
99
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * The resident widget: one line per running subagent. Pure builders only.
3
+ */
4
+
5
+ /** One running worker row as the widget consumes it. */
6
+ export interface WorkerWidgetRow {
7
+ paneId: string;
8
+ slug?: string;
9
+ /** Role name the worker was launched with; `task` when it was spawned without one. */
10
+ role?: string;
11
+ /** Observed state text. The widget is handed workers still awaiting a report only. */
12
+ state?: string;
13
+ /** Spawn time, for the elapsed field on a widget row. */
14
+ startedAt?: number;
15
+ /** Pane label for `list` and the settle notice. A widget row never shows it. */
16
+ description?: string;
17
+ }
18
+
19
+ /** `<60s → Ns`; `<60m → Nm`; `<48h → Nh` (floor); otherwise `Nd`. */
20
+ function formatAge(ms: number): string {
21
+ const seconds = Math.max(0, Math.floor(ms / 1_000));
22
+ if (seconds < 60) return `${seconds}s`;
23
+ if (seconds < 3_600) return `${Math.floor(seconds / 60)}m`;
24
+ if (seconds < 172_800) return `${Math.floor(seconds / 3_600)}h`;
25
+ return `${Math.floor(seconds / 86_400)}d`;
26
+ }
27
+
28
+ /**
29
+ * One widget row: `↳ <slug> (<paneId>) · <role> · <state> · <elapsed>`. The pane id rides beside the
30
+ * name because it is the address the app and `send`/`output` take; a row without a slug is the pane
31
+ * id alone, since `↳ p9 (p9)` would spend the width on a duplicate. A label or a description is still
32
+ * not a field — those would cost the line the fold budget pays for.
33
+ */
34
+ export function workerLine(row: WorkerWidgetRow, now = Date.now()): string {
35
+ const elapsed = row.startedAt != null ? formatAge(Math.max(now - row.startedAt, 0)) : undefined;
36
+ const handle = row.slug ? `${row.slug} (${row.paneId})` : row.paneId;
37
+ const fields = [handle, row.role, row.state, elapsed]
38
+ .filter((field): field is string => typeof field === 'string' && field.length > 0);
39
+ return `↳ ${fields.join(' · ')}`;
40
+ }
41
+
42
+ /**
43
+ * The whole widget budget, fold tail included: pi wraps a string-array widget in
44
+ * `content.slice(0, 10)` and appends its own `... (widget truncated)` line when given more, so a
45
+ * tenth row plus a tail would cost the tail — the one line that says where the rest went.
46
+ */
47
+ export const WIDGET_MAX_LINES = 10;
48
+
49
+ /** One widget line per running worker; overflow folds into a tail that still fits the budget. */
50
+ export function widgetLines(workers: readonly WorkerWidgetRow[], now = Date.now()): string[] {
51
+ if (workers.length === 0) return [];
52
+ const rows = workers.map((worker) => workerLine(worker, now));
53
+ // Reserve the tail line before slicing, so the rendered widget never exceeds the budget.
54
+ const overflow = rows.length > WIDGET_MAX_LINES;
55
+ const shown = rows.slice(0, overflow ? WIDGET_MAX_LINES - 1 : WIDGET_MAX_LINES);
56
+ const hidden = rows.length - shown.length;
57
+ return [...shown, ...(hidden > 0 ? [`…+${hidden} more subagents · subagent list`] : [])];
58
+ }