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
@@ -0,0 +1,71 @@
1
+ /** Common-segment pipe request handler (prompt / interrupt / reply) over the subagent port and notice buffer. */
2
+ import { formatSettlementNotice } from './vocab.ts';
3
+ import type { SettlementCard } from './settlement-card.ts';
4
+ import type { PipeRequest, PipeResponse } from './pipe-channel.ts';
5
+ import type { SubagentPortBox } from './subagent-core.ts';
6
+
7
+ export interface MachineRequest {
8
+ id: string;
9
+ from: string | null;
10
+ sinceTs: number;
11
+ }
12
+
13
+ export interface PipeHandlerSession {
14
+ paneId: string;
15
+ port: SubagentPortBox;
16
+ claimSettleNotice: (key: string) => boolean;
17
+ deliverNotice: (content: string, paneId?: string, card?: SettlementCard | null) => Promise<void>;
18
+ sendUserMessageIn: (content: string) => Promise<void>;
19
+ sendUserMessageAs: (content: string, mode: 'steer' | 'followUp') => Promise<void>;
20
+ abort: () => void;
21
+ setPendingMachineRequest: (req: MachineRequest | null) => void;
22
+ }
23
+
24
+ export async function handlePipeRequest(
25
+ req: PipeRequest,
26
+ s: PipeHandlerSession,
27
+ ): Promise<PipeResponse> {
28
+ switch (req.type) {
29
+ case 'ping':
30
+ return { type: 'ok', id: req.id, detail: s.paneId };
31
+ case 'prompt':
32
+ case 'follow_up': {
33
+ s.setPendingMachineRequest({
34
+ id: req.id,
35
+ from: req.from ?? null,
36
+ sinceTs: Date.now(),
37
+ });
38
+ // follow_up + steer → deliver between tool calls (B3). Initial prompt
39
+ // stays followUp so an idle worker starts a new run (D96).
40
+ if (req.steer === true) await s.sendUserMessageAs(req.text, 'steer');
41
+ else await s.sendUserMessageIn(req.text);
42
+ return { type: 'ok', id: req.id };
43
+ }
44
+ case 'interrupt':
45
+ s.abort();
46
+ s.setPendingMachineRequest(null);
47
+ return { type: 'ok', id: req.id };
48
+ case 'reply': {
49
+ s.port.current?.applyReplySession(req.paneId, req.sessionFile);
50
+ // Consume before the finish notice: otherwise listRunningSubs still reports the pane and the
51
+ // next master settle injects "still running" for an agent just announced finished.
52
+ s.port.current?.consumeReply(req.paneId, req.text);
53
+ // B8: the claim key `${paneId}:${requestId}` must match the poll loop's (subagent-poller.ts):
54
+ // a child echoes the id of the pipe request it answered (`id: req.id` in index.ts's settle
55
+ // push) and the parent hands that same id to startPoller (`prompt-<taskId>` / `fu-<ts>`).
56
+ // A mismatch surfaces as two "finished" notices for one settlement.
57
+ const claimed = s.claimSettleNotice(`${req.paneId}:${req.id}`);
58
+ if (claimed) {
59
+ const head = formatSettlementNotice(`${req.paneId}`, req.text);
60
+ const body = req.sessionFile ? `${head}\nSession: ${req.sessionFile}` : head;
61
+ await s.deliverNotice(body, req.paneId, s.port.current?.settlementCard(req.paneId, 'settled') ?? null);
62
+ }
63
+ return { type: 'ok', id: req.id };
64
+ }
65
+ default: {
66
+ // PipeRequest is closed; parsePipeLine may still forward an unknown type.
67
+ const unknownReq: { type: string; id: string } = req;
68
+ return { type: 'error', id: unknownReq.id, message: `unknown type ${unknownReq.type}` };
69
+ }
70
+ }
71
+ }
@@ -0,0 +1,265 @@
1
+ /**
2
+ * Role runtime for the composition root: manifest state, mid-session switching,
3
+ * the mandatory tool gate, `/piher-role`, and branch replay.
4
+ */
5
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
6
+ import { composeForRole, toRuntimeManifest } from './manifest-compose.ts';
7
+ import { planActiveTools, planToolGate, type RuntimeRoleManifest } from './tool-gate.ts';
8
+ import {
9
+ initialRoleState,
10
+ latestRoleManifestRecord,
11
+ manifestFromRecord,
12
+ planRoleSwitch,
13
+ planSwitchActiveTools,
14
+ roleRecordDiffers,
15
+ type RoleState,
16
+ } from './role-state.ts';
17
+ import { RESERVED_ROLE_NAMES, listRoleNames } from './role-loader.ts';
18
+ import { catalogEntries, catalogSearchDirs, renderRoleCatalog } from './role-catalog.ts';
19
+ import { ROLE_MANIFEST_CUSTOM_TYPE } from './renderers.ts';
20
+
21
+ /**
22
+ * WS-D7: the master pane applies its own manifest through the same mandatory chain as
23
+ * subagents. The builtin `master` role is read directly so a workspace master.json decoy
24
+ * cannot affect self-application; a malformed manifest fails open to no-role state.
25
+ */
26
+ export function composeMasterRuntime(): RuntimeRoleManifest | null {
27
+ try {
28
+ return toRuntimeManifest(composeForRole('master', [], { loadRoleOpts: { builtinDirect: true } }));
29
+ } catch (err) {
30
+ console.error(`[piher] master manifest invalid (fail-open): ${err instanceof Error ? err.message : String(err)}`);
31
+ return null;
32
+ }
33
+ }
34
+
35
+ export interface RoleRuntimeDeps {
36
+ pi: ExtensionAPI;
37
+ /** Role-resolution base dir carried by the master at spawn (a worker's own checkout may resolve a different one). */
38
+ roleBase: string;
39
+ initialManifest: RuntimeRoleManifest | null;
40
+ isSubagent: boolean;
41
+ }
42
+
43
+ export interface RoleRuntime {
44
+ readonly state: RoleState;
45
+ /** Replay the branch-point role record and re-anchor side effects (D77 visible layer). */
46
+ syncFromBranch(ctx: unknown): void;
47
+ /** Mid-session switch from `/piher-role`: the human typing in the pane is the final authority (Q2/S22). */
48
+ applyRoleSwitch(roleName: string): Promise<{ ok: boolean; message: string }>;
49
+ }
50
+
51
+ export function createRoleRuntime(d: RoleRuntimeDeps): RoleRuntime {
52
+ const { pi, roleBase } = d;
53
+ const roleState = initialRoleState(d.initialManifest);
54
+ /**
55
+ * S20 role catalog: computed once per session, dropped on a role switch so a role file written
56
+ * mid-session reaches the next prompt. Only a session that can actually spawn gets the menu.
57
+ */
58
+ let catalog: string | null = null;
59
+ function roleCatalog(): string | null {
60
+ if (d.isSubagent || !pi.getAllTools().some((t) => t.name === 'subagent')) return null;
61
+ if (catalog === null) {
62
+ catalog = renderRoleCatalog(catalogEntries({ baseDir: roleBase }), {
63
+ searchDirs: catalogSearchDirs({ baseDir: roleBase }),
64
+ });
65
+ }
66
+ return catalog;
67
+ }
68
+ /**
69
+ * Same-origin sync (RFC docs/rfc-pi-0.86-dynamic-tools.md §4.3): reconcile roleState against the CURRENT
70
+ * branch point on session_start (resume) and session_tree — pi's tool deltas revert the loadout at the
71
+ * branch point, so the gate manifest must follow or it would keep the post-switch tools. Replay only
72
+ * when the last record names a DIFFERENT role (name equality tolerates on-disk drift and keeps the env
73
+ * value authoritative on resume); the anchor entry is written only on change so resume replay still works. */
74
+ function syncFromBranch(ctx: unknown): void {
75
+ if (!roleState.manifest) return;
76
+ try {
77
+ const branchEntries =
78
+ (ctx as { sessionManager?: { getBranch?: () => readonly unknown[] } }).sessionManager?.getBranch?.() ?? [];
79
+ const record = latestRoleManifestRecord(branchEntries);
80
+ if (record && record.role !== roleState.manifest.role) {
81
+ roleState.manifest = manifestFromRecord(record);
82
+ roleState.origin = record.origin === 'switch' ? 'switch' : 'env';
83
+ roleState.switchedBy = record.switchedBy ?? null;
84
+ roleState.switchedAt = record.ts ?? null;
85
+ console.error(`[piher] role replay: ${record.role} (origin ${record.origin ?? 'env'}, switchedBy ${record.switchedBy ?? '?'})`);
86
+ }
87
+ if (roleRecordDiffers(record, roleState)) {
88
+ const m = roleState.manifest;
89
+ pi.appendEntry(ROLE_MANIFEST_CUSTOM_TYPE, {
90
+ version: 1,
91
+ role: m.role,
92
+ manifestVersion: m.version,
93
+ tools: m.tools,
94
+ permissions: m.permissions,
95
+ unknownTools: m.unknownTools ?? 'deny',
96
+ ...(m.guidelines?.length ? { guidelines: m.guidelines } : {}),
97
+ ...(roleState.origin === 'switch'
98
+ ? { origin: 'switch' as const, switchedBy: roleState.switchedBy ?? undefined }
99
+ : {}),
100
+ ts: Date.now(),
101
+ });
102
+ }
103
+ } catch {
104
+ /* Best effort recording; the mandatory layer does not depend on the session log. */
105
+ }
106
+ // D77 visible layer: hide tools outside the manifest AFTER all plugins load.
107
+ // Intersection semantics prevent clearing everything; master without a
108
+ // manifest stays full. setActiveTools persists as a tool delta (pi 0.86).
109
+ try {
110
+ const active = pi.getActiveTools();
111
+ const vis = planActiveTools(roleState.manifest.tools, active, {
112
+ unknownTools: roleState.manifest.unknownTools,
113
+ permissions: roleState.manifest.permissions,
114
+ });
115
+ if (vis && vis.changed) {
116
+ pi.setActiveTools(vis.next);
117
+ console.error(`[piher] D77 visible-layer: role ${roleState.manifest.role} tools ${active.length} → ${vis.next.length}`);
118
+ }
119
+ } catch {
120
+ /* Best effort visible layer; the mandatory layer remains active. */
121
+ }
122
+ }
123
+
124
+ async function applyRoleSwitch(roleName: string): Promise<{ ok: boolean; message: string }> {
125
+ const current = roleState.manifest;
126
+ if (!current) {
127
+ return { ok: false, message: 'Error: role system not armed (no manifest; bare pi sessions cannot switch roles)' };
128
+ }
129
+ const name = roleName.trim();
130
+ if (name === '') return { ok: false, message: 'Error: role name is required (see /piher-role with no argument)' };
131
+ if (name === current.role) return { ok: true, message: `role is already ${name}` };
132
+ let next: RuntimeRoleManifest;
133
+ try {
134
+ // Resolve against the spawn-inherited base, matching the initial manifest composition.
135
+ next = toRuntimeManifest(composeForRole(name, [], { loadRoleOpts: { baseDir: roleBase } }));
136
+ } catch (err) {
137
+ return { ok: false, message: `Error: role "${name}" unavailable: ${(err as Error).message}` };
138
+ }
139
+ const plan = planRoleSwitch(current.tools, next.tools);
140
+ // Universe = ALL registered tools: a switch may re-admit tools an earlier
141
+ // prune removed, which intersection semantics would lose.
142
+ const registered = pi.getAllTools().map((t) => t.name);
143
+ const nextActive = planSwitchActiveTools(next.tools, registered, {
144
+ unknownTools: next.unknownTools,
145
+ permissions: next.permissions,
146
+ });
147
+ pi.setActiveTools(nextActive);
148
+ roleState.manifest = next;
149
+ catalog = null; // A switch is the recompute point for the role menu (S20).
150
+ roleState.origin = 'switch';
151
+ roleState.switchedBy = 'human';
152
+ roleState.switchedAt = Date.now();
153
+ try {
154
+ pi.appendEntry(ROLE_MANIFEST_CUSTOM_TYPE, {
155
+ version: 1,
156
+ role: next.role,
157
+ manifestVersion: next.version,
158
+ tools: next.tools,
159
+ permissions: next.permissions,
160
+ unknownTools: next.unknownTools ?? 'deny',
161
+ ...(next.guidelines?.length ? { guidelines: next.guidelines } : {}),
162
+ origin: 'switch' as const,
163
+ switchedBy: 'human',
164
+ ts: Date.now(),
165
+ });
166
+ } catch {
167
+ }
168
+ const diff = [
169
+ plan.added.length ? `+${plan.added.join(', ')}` : null,
170
+ plan.removed.length ? `-${plan.removed.join(', ')}` : null,
171
+ ]
172
+ .filter(Boolean)
173
+ .join(' ');
174
+ return {
175
+ ok: true,
176
+ message: `role ${current.role} → ${next.role}${diff ? ` (${diff})` : ''}; active tools: ${nextActive.length}`,
177
+ };
178
+ }
179
+
180
+ /* Mandatory gate: a tool the manifest does not permit blocks and terminates the batch. */
181
+ pi.on('tool_call', async (event: { toolName?: string }) => {
182
+ const manifest = roleState.manifest;
183
+ if (!manifest) return;
184
+ const tool = typeof event?.toolName === 'string' ? event.toolName : '';
185
+ const gate = planToolGate(tool, manifest);
186
+ if (gate.kind === 'deny') {
187
+ return { block: true, reason: gate.reason, terminate: true };
188
+ }
189
+ return undefined;
190
+ });
191
+
192
+ pi.registerCommand('piher-role', {
193
+ description:
194
+ 'Show the current role + available role names, or switch: /piher-role <name> (widening beyond the current toolset asks for confirmation)',
195
+ handler: async (args: unknown, ctx: unknown) => {
196
+ const ui = (ctx as { ui?: { notify?: (t: string, l?: string) => void; confirm?: (t: string, m: string) => Promise<boolean> } }).ui;
197
+ const notify = (text: string, level: 'info' | 'error') => ui?.notify?.(text, level) ?? console.error(text);
198
+ const current = roleState.manifest;
199
+ if (!current) {
200
+ notify('Role system not armed (no manifest).', 'error');
201
+ return;
202
+ }
203
+ const name = typeof args === 'string' ? args.trim() : '';
204
+ if (name === '') {
205
+ const names = new Set<string>(RESERVED_ROLE_NAMES);
206
+ for (const n of listRoleNames(roleBase)) names.add(n);
207
+ notify(
208
+ `Current role: ${current.role} (origin: ${roleState.origin}). Available: ${[...names].sort().join(', ')}`,
209
+ 'info',
210
+ );
211
+ return;
212
+ }
213
+ try {
214
+ const preview = toRuntimeManifest(composeForRole(name, [], { loadRoleOpts: { baseDir: roleBase } }));
215
+ const plan = planRoleSwitch(current.tools, preview.tools);
216
+ // Q2: humans may widen after confirm — the human is the final authority in the pane.
217
+ if (plan.widening && ui?.confirm) {
218
+ const okToWiden = await ui.confirm(
219
+ 'Widen role toolset',
220
+ `Role "${name}" adds tools beyond the current set (${plan.added.join(', ')}). Switch anyway?`,
221
+ );
222
+ if (!okToWiden) {
223
+ notify('Role switch cancelled.', 'info');
224
+ return;
225
+ }
226
+ }
227
+ } catch (err) {
228
+ notify(`Error: role "${name}" unavailable: ${(err as Error).message}`, 'error');
229
+ return;
230
+ }
231
+ const res = await applyRoleSwitch(name);
232
+ notify(res.message, res.ok ? 'info' : 'error');
233
+ },
234
+ });
235
+
236
+ /* Per-role guidelines and instructions ride the before_agent_start prompt-section diff; writing
237
+ * the same string each turn is a no-op (pi diffs sections), removal cleans up. */
238
+ pi.on('before_agent_start', async (event) => {
239
+ const sections = event.systemPromptOptions?.sections;
240
+ if (!sections) return;
241
+ const m = roleState.manifest;
242
+ const blocks: string[] = [];
243
+ if (m?.guidelines?.length) blocks.push(m.guidelines.map((g) => `- ${g}`).join('\n'));
244
+ if (m?.instructions) blocks.push(m.instructions.trim());
245
+ if (m && blocks.length > 0) {
246
+ const text = `Role "${m.role}" operating constraints:\n${blocks.join('\n\n')}`;
247
+ if (sections['piher-role'] !== text) sections['piher-role'] = text;
248
+ } else {
249
+ delete sections['piher-role'];
250
+ }
251
+ // S20: the menu is a separate section from my own constraints, so neither reads as the other.
252
+ const menu = roleCatalog();
253
+ if (menu === null) {
254
+ delete sections['piher-roles'];
255
+ } else if (sections['piher-roles'] !== menu) {
256
+ sections['piher-roles'] = menu;
257
+ }
258
+ });
259
+
260
+ return {
261
+ state: roleState,
262
+ syncFromBranch,
263
+ applyRoleSwitch,
264
+ };
265
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Index process-mode planner (master vs worker, master-manifest gate): a pure function of
3
+ * process env that must stay testable without booting the composition root. */
4
+ import { detectCoreEnv } from './core-client.ts';
5
+
6
+ export interface IndexMode {
7
+ /** PIHER_SUBAGENT=1 → worker terminal (no subagent tools, no cordis loader). */
8
+ readonly isSubagent: boolean;
9
+ /** HC_TERMINAL_SOCKET + HC_TERMINAL_ID are present. */
10
+ readonly hasCore: boolean;
11
+ /** core terminal that is not a worker → apply builtin master manifest and mount master plugins. */
12
+ readonly composeMaster: boolean;
13
+ }
14
+
15
+ export function planIndexMode(env: NodeJS.ProcessEnv = process.env): IndexMode {
16
+ const isSubagent = env.PIHER_SUBAGENT === '1';
17
+ const hasCore = Boolean(detectCoreEnv(env));
18
+ return {
19
+ isSubagent,
20
+ hasCore,
21
+ composeMaster: hasCore && !isSubagent,
22
+ };
23
+ }