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,94 @@
1
+ /**
2
+ * Delegation identity (RFC docs/rfc-delegation-identity.md Phase 1): a worker pane knows who
3
+ * delegated it, and for what task, on every surface it already touches — the system prompt section
4
+ * and the session custom entry (first-frame card).
5
+ * Pure builders only; the composition root owns the side effects.
6
+ */
7
+
8
+ import type { Lifecycle } from './role-manifest.ts';
9
+
10
+ /** Contract env handed to the worker process by the master (PIHER_ prefix per naming rule). */
11
+ export const DELEGATION_ENV = 'PIHER_DELEGATION';
12
+ /** Worker-session custom entry written once at first boot; replayed on resume. */
13
+ export const DELEGATION_CUSTOM_TYPE = 'piher.delegation';
14
+
15
+ export interface DelegationContext {
16
+ /** Master pane that spawned this worker; '' when the master pane is unknown (tests, RPC). */
17
+ readonly parentPaneId: string;
18
+ /** Master pi session id (cross-locate the parent transcript via /resume). */
19
+ readonly parentSessionId: string;
20
+ /** TaskRow.taskId — stable across revives. */
21
+ readonly taskId: string;
22
+ /** Task short label (TaskRow.description). */
23
+ readonly description: string;
24
+ /** Role manifest name active at spawn. */
25
+ readonly role: string;
26
+ /** Role-declared lifetime (S9/S54): `one-shot` settles, reports and then exits; default `persistent`. */
27
+ readonly lifecycle: Lifecycle;
28
+ }
29
+
30
+ function str(v: unknown): string | null {
31
+ return typeof v === 'string' ? v : null;
32
+ }
33
+
34
+ /** Validate a parsed delegation record (env JSON, marker JSON or custom-entry data); null when malformed. */
35
+ export function delegationFromRecord(parsed: unknown): DelegationContext | null {
36
+ if (!parsed || typeof parsed !== 'object') return null;
37
+ const rec = parsed as Record<string, unknown>; // shape-checked field by field below
38
+ const taskId = str(rec.taskId);
39
+ const description = str(rec.description);
40
+ if (!taskId || !description) return null;
41
+ return {
42
+ parentPaneId: str(rec.parentPaneId) ?? '',
43
+ parentSessionId: str(rec.parentSessionId) ?? '',
44
+ taskId,
45
+ description,
46
+ role: str(rec.role) ?? 'worker-default',
47
+ lifecycle: rec.lifecycle === 'one-shot' ? 'one-shot' : 'persistent',
48
+ };
49
+ }
50
+
51
+ /** Parse the spawn env; null when absent or structurally invalid (taskId/description mandatory). */
52
+ export function parseDelegationContext(raw: string | undefined | null): DelegationContext | null {
53
+ if (!raw || typeof raw !== 'string') return null;
54
+ try {
55
+ return delegationFromRecord(JSON.parse(raw));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ /** env map for spawnPaneInTaskTab/createTab; JSON like the role manifest next to it. */
62
+ export function delegationEnv(ctx: DelegationContext): Record<string, string> {
63
+ return { [DELEGATION_ENV]: JSON.stringify(ctx) };
64
+ }
65
+
66
+ /** Fold `piher.delegation` branch entries (last one wins) — the /resume replay source. */
67
+ export function foldDelegation(
68
+ entries: ReadonlyArray<{ type?: string; customType?: string; data?: unknown }>,
69
+ ): DelegationContext | null {
70
+ let found: DelegationContext | null = null;
71
+ for (const entry of entries) {
72
+ if (entry.type !== 'custom' || entry.customType !== DELEGATION_CUSTOM_TYPE) continue;
73
+ const next = delegationFromRecord(entry.data);
74
+ if (next) found = next;
75
+ }
76
+ return found;
77
+ }
78
+
79
+ /**
80
+ * System-prompt section text: identity + delivery contract in one stable wording, so the master
81
+ * model does not have to restate it in every spawn prompt (pi-subagent-manager idea A+F).
82
+ */
83
+ export function buildDelegationSection(ctx: DelegationContext, ownPaneId: string): string {
84
+ const parent = ctx.parentPaneId || 'unknown pane';
85
+ const session = ctx.parentSessionId || 'unknown session';
86
+ const self = ownPaneId ? ` You are pane ${ownPaneId} (agentId "${ownPaneId}") — cite it when asking questions or reporting errors.`
87
+ : '';
88
+ return [
89
+ `You are a delegated subagent of the master pane ${parent} (session ${session}); you were not started by a human.`,
90
+ `Task: ${ctx.description}. Role: ${ctx.role}.${self}`,
91
+ 'Contract: the master collects your answer from this pane\'s settlement, so end the turn in one of two ways — state the answer plainly, or, if you cannot finish, stop and make the first line `BLOCKED: <the decision or resource you need>`. Do not ask who requested the work and do not guess silently.',
92
+ ].join('\n');
93
+ }
94
+
package/src/gc-core.ts ADDED
@@ -0,0 +1,150 @@
1
+ /** Task-tab GC decisions (D29 rules as a pure function; no I/O, no pi/herdr). */
2
+ export type GcEntryKind = string;
3
+
4
+ export interface GcEntryLike {
5
+ /** 'task' or a role name; legacy short/resident is a label only. */
6
+ kind: GcEntryKind;
7
+ /** Report-delivered time (GC grace); null while the turn is still the master's business. */
8
+ consumedAt?: number | null;
9
+ /** Close decision time; a closed task is finished whatever else it says. */
10
+ closedAt?: number | null;
11
+ }
12
+
13
+ /** A row is finished for its tab when the master ended it, or its report was delivered long
14
+ * enough ago that the grace TTL elapsed. An undelivered report is still the master's business. */
15
+ function isTabFinished(e: GcEntryLike, now: number, ttlMs: number): boolean {
16
+ if (e.closedAt != null) return true;
17
+ const consumedAt = e.consumedAt ?? 0;
18
+ return consumedAt > 0 && consumedAt < now - ttlMs;
19
+ }
20
+
21
+ /** Close a task tab only when every work pane finished, the grace TTL elapsed (`ttlMs=0` means never
22
+ * auto-close), no terminal is blocked and the rest are idle/done/error (unknown = never reported). */
23
+ export function shouldCloseTaskTab(opts: {
24
+ entries: readonly GcEntryLike[];
25
+ paneStatuses: readonly string[];
26
+ ttlMs: number;
27
+ now: number;
28
+ }): boolean {
29
+ if (opts.entries.length === 0) return false;
30
+ if (!opts.entries.every((e) => isTabFinished(e, opts.now, opts.ttlMs))) return false;
31
+ if (opts.paneStatuses.some((s) => s === 'blocked')) return false;
32
+ if (!opts.paneStatuses.every((s) => s === 'idle' || s === 'done' || s === 'error' || s === 'unknown')) return false;
33
+ return true;
34
+ }
35
+
36
+ /** Pane-level collection (orphan / compat path): consumed before the previous turn (grace so the
37
+ * settlement notice is still visible); a missing terminal is skipped by the caller, never closed.
38
+ */
39
+ export function shouldClosePane(opts: {
40
+ consumedAt: number | null;
41
+ status: string | undefined;
42
+ prevTurnStart: number;
43
+ /** Only a one-shot spends itself; a persistent worker is the master's to close. */
44
+ lifecycle?: 'one-shot' | 'persistent';
45
+ }): boolean {
46
+ // A terminal the snapshot does not list is not this planner's to close: there is no pane to
47
+ // reclaim, and blessing the close would retire a terminal that may simply be unlisted.
48
+ if (opts.status === undefined) return false;
49
+ // Killing a persistent worker's process buys nothing: it has no pane to reclaim, and the next
50
+ // `send` would pay a cold start and revive a conversation that was never over.
51
+ if (opts.lifecycle !== 'one-shot') return false;
52
+ if (opts.status !== 'idle' && opts.status !== 'done' && opts.status !== 'error') return false;
53
+ return (opts.consumedAt ?? 0) > 0 && opts.consumedAt! < opts.prevTurnStart;
54
+ }
55
+
56
+ /** A subagent row as the checkout sweep sees it: which checkout it works in, and whether it is done. */
57
+ export interface CheckoutRowLike extends GcEntryLike {
58
+ worktreePath?: string | null;
59
+ }
60
+
61
+ export interface CheckoutSweep {
62
+ /** Checkouts whose space closes: every row there is `closed`, so nothing is alive inside. */
63
+ close: string[];
64
+ /** Checkouts to remind the master about once, with the consume time the reminder is about. */
65
+ remind: Array<{ path: string; since: number }>;
66
+ }
67
+
68
+ /**
69
+ * A checkout's app space outlives the workers that used it, and nothing else sweeps it: a one-shot's
70
+ * reclaim closes the space only for one-shots, so a persistent worker leaves its window behind. Two
71
+ * decisions, and neither ends a task:
72
+ * - close the space when every row of that checkout is `closed` — an empty window is cleanup;
73
+ * - remind the master, once per settlement, when every row there finished but one is not closed.
74
+ * That window stays: `space.close` ends the terminals inside it, and an idle worker is still
75
+ * the master's to send more work to.
76
+ */
77
+ export function decideCheckoutSpaces(opts: {
78
+ rows: readonly CheckoutRowLike[];
79
+ ttlMs: number;
80
+ now: number;
81
+ /** Path → the `since` a reminder was already delivered for; nothing new settled since. */
82
+ remindedAt?: ReadonlyMap<string, number>;
83
+ }): CheckoutSweep {
84
+ const byPath = new Map<string, CheckoutRowLike[]>();
85
+ for (const row of opts.rows) {
86
+ const path = row.worktreePath;
87
+ if (!path) continue;
88
+ const arr = byPath.get(path) ?? [];
89
+ arr.push(row);
90
+ byPath.set(path, arr);
91
+ }
92
+ const close: string[] = [];
93
+ const remind: Array<{ path: string; since: number }> = [];
94
+ for (const [path, rows] of byPath) {
95
+ if (rows.every((r) => r.closedAt != null)) {
96
+ close.push(path);
97
+ continue;
98
+ }
99
+ if (opts.ttlMs <= 0) continue;
100
+ if (!rows.every((r) => r.closedAt != null || r.consumedAt != null)) continue;
101
+ const since = Math.max(...rows.map((r) => r.consumedAt ?? 0));
102
+ if (since <= 0 || since >= opts.now - opts.ttlMs) continue;
103
+ if (since <= (opts.remindedAt?.get(path) ?? 0)) continue;
104
+ remind.push({ path, since });
105
+ }
106
+ return { close, remind };
107
+ }
108
+
109
+ /** One line the master can act on. It names the decision, never a countdown: nothing closes by
110
+ * itself, and housekeeping never ends a window that still holds an idle worker. */
111
+ /** Whether the channel heartbeat should ping this row at all. Only a worker with a turn in flight
112
+ * has a channel to lose: a delivered report means the turn is over, and `unknown` means pi has
113
+ * not reported a state yet — the process is still booting and its pipe is seconds away. Announcing
114
+ * a loss in either case is noise the master has to read and act on. */
115
+ export function planChannelCheck(input: {
116
+ /** The master's decision stands: a closed task has no channel to lose. */
117
+ closed: boolean;
118
+ /** A delivered report means no turn is in flight, so there is no channel to lose either. */
119
+ consumed: boolean;
120
+ /** What core reports for the pane; `undefined` when it does not list the terminal at all. */
121
+ terminalStatus: string | undefined;
122
+ }): 'skip' | 'ping' {
123
+ if (input.closed || input.consumed) return 'skip';
124
+ if (input.terminalStatus === undefined) return 'skip';
125
+ if (input.terminalStatus === 'unknown') return 'skip';
126
+ return 'ping';
127
+ }
128
+
129
+ /** A worker whose pane is alive but whose channel is gone can never receive a follow-up: say it once,
130
+ * with the two ways out, so the master does not discover it on the next `send`. */
131
+ export function formatDeadChannelNotice(opts: {
132
+ paneId: string;
133
+ taskId: string;
134
+ status: string;
135
+ }): string {
136
+ return `Note: subagent ${opts.paneId} is alive (${opts.status}) but its message channel is gone — a ` +
137
+ 'follow-up sent to it would not arrive. Nothing was lost: the row is untouched. Restart it with ' +
138
+ `subagent action:"resume" taskId ${opts.taskId}, or retire it with action:"close".`;
139
+ }
140
+
141
+ export function formatIdleCheckoutNotice(opts: {
142
+ name: string;
143
+ minutes: number;
144
+ panes: readonly string[];
145
+ }): string {
146
+ const who = opts.panes.length > 0 ? ` (${opts.panes.join(', ')})` : '';
147
+ return `Note: the checkout ${opts.name} has had no running subagent for ${opts.minutes} min${who}. ` +
148
+ 'Its app space stays open: housekeeping never closes a window with an idle worker in it. ' +
149
+ 'Close what you no longer need with subagent action:"close"; the checkout itself is never removed.';
150
+ }
package/src/guards.ts ADDED
@@ -0,0 +1,6 @@
1
+ /** Shared structural guards used by the *-core validators. */
2
+
3
+ /** Plain JSON object: not null, not an array. */
4
+ export function isPlainObject(v: unknown): v is Record<string, unknown> {
5
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
6
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * hc templates generated from role manifests (S75): one contract, two doors.
3
+ *
4
+ * A role manifest is the source of truth — it carries the model, the thinking level, the tool set and
5
+ * the rules a template cannot express. `hc run <role> "task"` needs the same role as a Markdown
6
+ * template, and generating it removes the drift that let `subagent spawn {role:"reviewer"}` and
7
+ * `hc run reviewer` launch different models from the same name.
8
+ *
9
+ * Placement is hc's, not ours: frontmatter carries `name`, `description` and `command`, and the body
10
+ * is delivered as the first message together with the task (see `hc --skill`). The body therefore
11
+ * holds the standing instructions, while the rules that must be enforced (`rules`, `unknownTools`,
12
+ * `lifecycle`) stay in the manifest, where a tool gate applies them.
13
+ */
14
+ import { join } from 'node:path';
15
+ import { listRoleNames, loadRoleConfig, RESERVED_ROLE_NAMES } from './role-loader.ts';
16
+ import type { RoleManifest } from './role-manifest.ts';
17
+
18
+ /** `~/.config/herdr-core/templates`, where core looks for templates. */
19
+ export function hcTemplatesDir(homeDir: string): string {
20
+ return join(homeDir, '.config', 'herdr-core', 'templates');
21
+ }
22
+
23
+ export interface HcTemplatePlan {
24
+ /** Role name, which is also the template name `hc run <name>` resolves. */
25
+ name: string;
26
+ /** File name inside the templates directory. */
27
+ fileName: string;
28
+ /** Template command line: the launch command with model, thinking and tools. */
29
+ command: string;
30
+ /** The frontmatter description line, kept so a merge can restore a missing one. */
31
+ descriptionLine: string;
32
+ /** Full Markdown file: frontmatter plus standing instructions. */
33
+ markdown: string;
34
+ }
35
+
36
+ /** YAML frontmatter values stay on one line: a newline or a stray quote breaks the file. */
37
+ function oneLine(text: string): string {
38
+ return text.replace(/\s+/g, ' ').trim();
39
+ }
40
+
41
+ /** `pi --model … --thinking … --tools …`: the argv a template launches. */
42
+ export function templateCommand(manifest: RoleManifest): string {
43
+ const parts = ['pi'];
44
+ if (manifest.model) parts.push('--model', manifest.model);
45
+ if (manifest.thinking) parts.push('--thinking', manifest.thinking);
46
+ // `*` is a wildcard, not a tool: the manifest already covers unknown tools through `unknownTools`.
47
+ const tools = manifest.manifest.tools.filter((tool) => tool !== '*');
48
+ if (tools.length > 0) parts.push('--tools', tools.join(','));
49
+ return parts.join(' ');
50
+ }
51
+
52
+ /**
53
+ * One template from one manifest, or null when the role is not meant to be launched by name:
54
+ * `master` and `worker-default` are the harness's own roles, and `modelInvocable: false` marks a
55
+ * role that exists but must not appear as a launchable profile.
56
+ */
57
+ export function planHcTemplate(manifest: RoleManifest): HcTemplatePlan | null {
58
+ if (manifest.modelInvocable === false) return null;
59
+ if (RESERVED_ROLE_NAMES.includes(manifest.role)) return null;
60
+ const command = templateCommand(manifest);
61
+ const description = oneLine(manifest.description ?? `Role ${manifest.role}`);
62
+ const body: string[] = [`# ${manifest.role}`, ''];
63
+ if (manifest.guidelines && manifest.guidelines.length > 0) {
64
+ body.push('Rules for this role:', '');
65
+ for (const line of manifest.guidelines) body.push(`- ${oneLine(line)}`);
66
+ body.push('');
67
+ }
68
+ if (manifest.instructions) body.push(manifest.instructions.trim(), '');
69
+ body.push(
70
+ 'These instructions and the task that follows are one message: do the task under these rules,',
71
+ 'not instead of them. Report the result as the last message of your turn.',
72
+ );
73
+ const markdown = [
74
+ '---',
75
+ `name: ${manifest.role}`,
76
+ `description: ${description}`,
77
+ `command: ${command}`,
78
+ '---',
79
+ '',
80
+ ...body,
81
+ '',
82
+ ].join('\n');
83
+ return {
84
+ name: manifest.role,
85
+ fileName: `${manifest.role}.md`,
86
+ command,
87
+ descriptionLine: `description: ${description}`,
88
+ markdown,
89
+ };
90
+ }
91
+
92
+ const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
93
+
94
+ /**
95
+ * Sync a template that already exists: the frontmatter fields this generator owns (`name`,
96
+ * `description`, `command`) are replaced, every other key and **the whole body** stay untouched. A
97
+ * hand-written brief is worth more than a regenerated one, so syncing never clobbers prose.
98
+ */
99
+ export function mergeHcTemplate(existing: string, plan: HcTemplatePlan): string {
100
+ const match = FRONTMATTER.exec(existing);
101
+ if (match === null) return plan.markdown;
102
+ const kept = match[1]!
103
+ .split(/\r?\n/)
104
+ .filter((line) => line.trim() !== '')
105
+ .filter((line) => !/^(name|description|command)\s*:/.test(line));
106
+ const front = [
107
+ '---',
108
+ `name: ${plan.name}`,
109
+ plan.descriptionLine,
110
+ ...kept,
111
+ `command: ${plan.command}`,
112
+ '---',
113
+ '',
114
+ '',
115
+ ].join('\n');
116
+ return `${front}${existing.slice(match[0].length).replace(/^\n+/, '')}`;
117
+ }
118
+
119
+ /** Every launchable role of the loaded catalog, in name order, as a template plan. */
120
+ export function planHcTemplates(opts: { baseDir?: string; userDir?: string } = {}): HcTemplatePlan[] {
121
+ const plans: HcTemplatePlan[] = [];
122
+ for (const name of listRoleNames(opts.baseDir, opts.userDir)) {
123
+ let manifest: RoleManifest;
124
+ try {
125
+ manifest = loadRoleConfig(name, opts);
126
+ } catch {
127
+ // A role that does not validate is reported by the catalog, not by this generator.
128
+ continue;
129
+ }
130
+ const plan = planHcTemplate(manifest);
131
+ if (plan !== null) plans.push(plan);
132
+ }
133
+ return plans.sort((left, right) => left.name.localeCompare(right.name));
134
+ }
@@ -0,0 +1,118 @@
1
+ /** M18 file write-lock installer (soft veto / hard block): owns the three pi hooks and the
2
+ * held-lock set; fail-open on terminal.list is the collaboration policy. */
3
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
+ import {
5
+ acquireTokensFor,
6
+ isLockTokenKey,
7
+ parseLockTokenValue,
8
+ planWriteGuard,
9
+ releaseTokensFor,
10
+ type LockAgentView,
11
+ } from './lock-core.ts';
12
+ import type { CoreClientLike, CoreEnv } from './core-client.ts';
13
+
14
+ export interface WriteLocksHandle {
15
+ getHeldLocks: () => readonly string[];
16
+ }
17
+
18
+ export function installWriteLocks(
19
+ pi: ExtensionAPI,
20
+ opts: {
21
+ client: CoreClientLike;
22
+ env: CoreEnv | null;
23
+ hard: boolean;
24
+ },
25
+ ): WriteLocksHandle {
26
+ const { client, env, hard } = opts;
27
+ const heldLocks = new Set<string>();
28
+ const lockWarnByToolCall = new Map<string, string>();
29
+
30
+ if (!client.available || !env) {
31
+ return { getHeldLocks: () => [] };
32
+ }
33
+
34
+ async function acquireLocks(paths: readonly string[]): Promise<void> {
35
+ for (const p of paths) heldLocks.add(p);
36
+ await client.setLabels(acquireTokensFor(paths, env?.terminalId ?? ''));
37
+ }
38
+
39
+ pi.on('tool_call', async (event: { toolName?: string; toolCallId?: string; input?: unknown }, ctx: { cwd?: string }) => {
40
+ if (!client.available || !env) return;
41
+ const cwd = ctx?.cwd ?? process.cwd();
42
+ let agents: LockAgentView[];
43
+ let ownForegroundCwd: string | null = null;
44
+ try {
45
+ const live = await client.listTerminals();
46
+ agents = live.map((t) => ({ paneId: t.id, tokens: t.labels }));
47
+ const me = live.find((t) => t.id === env.terminalId);
48
+ if (me?.foreground?.cwd) {
49
+ ownForegroundCwd = me.foreground.cwd;
50
+ }
51
+ } catch {
52
+ return;
53
+ }
54
+ const effectiveCwd = ownForegroundCwd ?? cwd;
55
+ const plan = planWriteGuard({
56
+ toolName: event.toolName ?? '',
57
+ input: event.input,
58
+ agents,
59
+ ownPaneId: env.terminalId,
60
+ cwd: effectiveCwd,
61
+ hard,
62
+ });
63
+ if (plan.kind === 'skip') return;
64
+ if (plan.kind === 'block') {
65
+ return { block: true, reason: plan.reason };
66
+ }
67
+ if (plan.kind === 'warn' && typeof event.toolCallId === 'string') {
68
+ lockWarnByToolCall.set(event.toolCallId, plan.warning);
69
+ }
70
+ await acquireLocks(plan.paths);
71
+ });
72
+
73
+ pi.on('tool_result', async (event: unknown) => {
74
+ const rec = (event ?? {}) as { toolCallId?: unknown; content?: unknown };
75
+ if (typeof rec.toolCallId !== 'string') return;
76
+ const warning = lockWarnByToolCall.get(rec.toolCallId);
77
+ if (!warning) return;
78
+ lockWarnByToolCall.delete(rec.toolCallId);
79
+ const content = Array.isArray(rec.content) ? rec.content as Array<{ type: 'text'; text: string }> : [];
80
+ return { content: [...content, { type: 'text' as const, text: warning }] };
81
+ });
82
+
83
+ pi.on('agent_settled', async () => {
84
+ if (heldLocks.size === 0) return;
85
+ const paths = [...heldLocks];
86
+ heldLocks.clear();
87
+ await client.setLabels(releaseTokensFor(paths));
88
+ });
89
+
90
+ pi.registerCommand('piher-locks', {
91
+ description: 'Show write locks held by this pane and all live panes (M18)',
92
+ handler: async (_args, ctx) => {
93
+ const ui = (ctx as { ui?: { notify?: (text: string, level?: string) => void } }).ui;
94
+ const mine = [...heldLocks];
95
+ const lines = [`held by this pane (${mine.length}):`];
96
+ lines.push(...(mine.length ? mine.map((p) => ` ${p}`) : [' (none)']));
97
+ lines.push('all live locks:');
98
+ let any = false;
99
+ try {
100
+ for (const t of await client.listTerminals()) {
101
+ for (const [k, v] of Object.entries(t.labels)) {
102
+ if (!isLockTokenKey(k) || typeof v !== 'string' || !v) continue;
103
+ const parsed = parseLockTokenValue(v);
104
+ if (!parsed) continue;
105
+ any = true;
106
+ lines.push(` ${parsed.path} → pane ${parsed.holderPaneId}`);
107
+ }
108
+ }
109
+ } catch {
110
+ lines.push(' (terminal.list failed)');
111
+ }
112
+ if (!any && lines[lines.length - 1] !== ' (terminal.list failed)') lines.push(' (none)');
113
+ ui?.notify?.(lines.join('\n'), 'info');
114
+ },
115
+ });
116
+
117
+ return { getHeldLocks: () => [...heldLocks] };
118
+ }
@@ -0,0 +1,92 @@
1
+ /** Master-process plugin mount (cordis loader + terminal/subagent), dynamically imported from
2
+ * the master branch of index.ts only: a worker must never load bootstrap/subagent-poller (C3). */
3
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
4
+ import type { Context } from '@deepseek-ai/cordis';
5
+ import type { Server } from 'node:net';
6
+ import { PiSurface } from './pi-surface.ts';
7
+ import { createCordisApp } from './bootstrap.ts';
8
+ import { cordisProvide } from './cordis-services.ts';
9
+ import { disposeSessionRoot } from './subagent-poller.ts';
10
+ import bgJobPlugin from './plugins/bg-job.ts';
11
+ import subagentPlugin from './plugins/subagent.ts';
12
+ import type { CoreClientLike, CoreEnv } from './core-client.ts';
13
+ import type { SubagentPortBox } from './subagent-core.ts';
14
+ import type { SettlementCard } from './settlement-card.ts';
15
+
16
+ export interface MasterPluginMount {
17
+ pi: ExtensionAPI;
18
+ client: CoreClientLike;
19
+ env: CoreEnv | null;
20
+ extPath: string;
21
+ port: SubagentPortBox;
22
+ pipeServerBox: { current: Server | null };
23
+ deliverNotice: (content: string, paneId?: string, card?: SettlementCard | null) => Promise<void>;
24
+ noticePending: () => ReadonlySet<string>;
25
+ getSessionId: () => string;
26
+ /** This session's transcript path; the subagent registry is stored beside it. */
27
+ getSessionFile: () => string | null;
28
+ claimSettleNotice: (key: string) => boolean;
29
+ }
30
+
31
+ async function loadEntry(
32
+ sessionRoot: Context,
33
+ useLoader: boolean,
34
+ name: string,
35
+ plugin: (ctx: Context) => void,
36
+ ): Promise<void> {
37
+ if (useLoader) {
38
+ const withLoader = sessionRoot as typeof sessionRoot & {
39
+ loader?: { create: (o: { name: string }) => Promise<unknown> };
40
+ };
41
+ await withLoader.loader?.create({ name });
42
+ return;
43
+ }
44
+ await sessionRoot.plugin(plugin);
45
+ }
46
+
47
+ export async function mountMasterPlugins(m: MasterPluginMount): Promise<void> {
48
+ const cordisApp = await createCordisApp();
49
+ const sessionRoot = cordisApp.root;
50
+ // loader.create re-imports .ts via Node ESM — fails under node_modules (npm:piher
51
+ // strip-types) and misses pi's typebox alias. HMR-only; production uses plugin().
52
+ const useLoader = cordisApp.loaderReady && cordisApp.hmrActive;
53
+
54
+ // Pipe server is created in the common segment; closing it from a core plugin would kill it on hmr reload (d87).
55
+ sessionRoot.effect(() => () => {
56
+ if (m.pipeServerBox.current) {
57
+ try { m.pipeServerBox.current.close(); } catch { /* already closed */ }
58
+ m.pipeServerBox.current = null;
59
+ }
60
+ }, 'pipe-server');
61
+
62
+ const surface = new PiSurface(m.pi as unknown as object, cordisApp.ledger);
63
+ cordisProvide(sessionRoot, 'piher.surface', surface);
64
+ const jobDeps = {
65
+ client: m.client,
66
+ env: m.env,
67
+ state: { activePaneIds: (): Set<string> => new Set() },
68
+ };
69
+ cordisProvide(sessionRoot, 'piher.bg-job-deps', jobDeps);
70
+
71
+ await loadEntry(sessionRoot, useLoader, './plugins/bg-job.ts', bgJobPlugin);
72
+
73
+ // Dropped keys fail here: cordisProvide checks the bag against PiherCordisServices (fe99252).
74
+ cordisProvide(sessionRoot, 'piher.subagent-deps', {
75
+ client: m.client,
76
+ env: m.env,
77
+ extPath: m.extPath,
78
+ sessionRoot,
79
+ port: m.port,
80
+ deliverNotice: m.deliverNotice,
81
+ noticePending: m.noticePending,
82
+ getSessionId: m.getSessionId,
83
+ getSessionFile: m.getSessionFile,
84
+ claimSettleNotice: m.claimSettleNotice,
85
+ terminalState: jobDeps.state,
86
+ });
87
+ await loadEntry(sessionRoot, useLoader, './plugins/subagent.ts', subagentPlugin);
88
+
89
+ m.pi.on('session_shutdown', () => {
90
+ void disposeSessionRoot(sessionRoot);
91
+ });
92
+ }
@@ -0,0 +1,79 @@
1
+ /**
2
+ * D92 settlement-notice buffer: `sendUserMessage(followUp)` only delivers when the agent has no
3
+ * more tool calls, so a long master run would queue every settlement and flood at the end. Buffer
4
+ * while busy; flush at turn_end (steer) / agent_settled (followUp), collapsing large batches. */
5
+
6
+ import type { SettlementCard } from './settlement-card.ts';
7
+
8
+ export type NoticeSendMode = 'steer' | 'followUp';
9
+
10
+ export const NOTICE_MAX_SHOWN = 3;
11
+
12
+ /** Collapse a batch into one message body: empty → null; above the cap, the first max originals plus
13
+ * a tail pointing at history / subagent list. A single-item batch stays byte-identical to the
14
+ * direct-send format. */
15
+ export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
16
+ if (contents.length === 0) return null;
17
+ if (contents.length <= max) return contents.join('\n\n');
18
+ const hidden = contents.length - max;
19
+ const tail = `…${hidden} more settlement(s) not shown individually. Read each worker's own transcript `
20
+ + 'for the full result; use subagent list for running agents.';
21
+ return [...contents.slice(0, max), tail].join('\n\n');
22
+ }
23
+
24
+ export interface NoticeBuffer {
25
+ deliverNotice(content: string, paneId?: string, card?: SettlementCard | null): Promise<void>;
26
+ noticePending(): ReadonlySet<string>;
27
+ flush(mode: NoticeSendMode): Promise<void>;
28
+ }
29
+
30
+ export function createNoticeBuffer(opts: {
31
+ /** True while the agent is in a turn, or after an abort that must not wake a new run. */
32
+ isBusy: () => boolean;
33
+ /** One collapsed body; the card describes a single settlement and is omitted for a batch. */
34
+ send: (content: string, mode: NoticeSendMode, card?: SettlementCard) => Promise<void>;
35
+ }): NoticeBuffer {
36
+ const pending: Array<{ content: string; paneId?: string; card?: SettlementCard }> = [];
37
+ const pendingPaneIds = new Set<string>();
38
+ /** A refused send is not a delivered settlement: keep the batch and its panes for the next flush. */
39
+ const requeue = (notices: readonly { content: string; paneId?: string; card?: SettlementCard }[]): void => {
40
+ pending.unshift(...notices);
41
+ };
42
+ return {
43
+ async deliverNotice(content, paneId, card) {
44
+ if (content !== '' && paneId !== undefined) pendingPaneIds.add(paneId);
45
+ if (opts.isBusy()) {
46
+ pending.push({ content, paneId, ...(card ? { card } : {}) });
47
+ return;
48
+ }
49
+ try {
50
+ await opts.send(content, 'followUp', card ?? undefined);
51
+ } catch (err) {
52
+ // pi drops prompts while the agent is mid-turn; holding the notice keeps it (and keeps its
53
+ // pane GC-exempt) until a flush can deliver it, instead of losing the settlement here.
54
+ requeue([{ content, paneId, ...(card ? { card } : {}) }]);
55
+ return;
56
+ }
57
+ if (content !== '' && paneId !== undefined) pendingPaneIds.delete(paneId);
58
+ },
59
+ noticePending: () => pendingPaneIds,
60
+ async flush(mode) {
61
+ if (pending.length === 0) return;
62
+ const flushed = pending.splice(0);
63
+ const collapsed = collapseNotices(flushed.map((n) => n.content));
64
+ if (collapsed) {
65
+ try {
66
+ await opts.send(collapsed, mode, flushed.length === 1 ? flushed[0]!.card : undefined);
67
+ } catch {
68
+ requeue(flushed);
69
+ return;
70
+ }
71
+ }
72
+ // Release only this batch's panes, and only once it is delivered: a notice buffered during the
73
+ // send await, or a batch a refused send put back, is still undelivered and its pane must stay.
74
+ for (const { paneId } of flushed) {
75
+ if (paneId !== undefined && !pending.some((n) => n.paneId === paneId)) pendingPaneIds.delete(paneId);
76
+ }
77
+ },
78
+ };
79
+ }