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,291 @@
1
+ /**
2
+ * M11 separates the human/machine channels (D45–D47) by having the pi extension create its own
3
+ * Windows named pipe.
4
+ *
5
+ * - Controller (client): one connection per request, matching the herdr client;
6
+ * - Child pane (server): when PIHER_SUBAGENT=1, the extension starts a server at session_start
7
+ * and injects prompt/follow_up through pi.sendUserMessage (visible in TUI);
8
+ * - Completion data is not sent back; authoritative state lives in herdr, session JSONL, and
9
+ * history (D47).
10
+ *
11
+ * Only pure transport-layer logic here; index.ts performs pi injection in the server callback.
12
+ */
13
+ import * as net from 'node:net';
14
+ import { createHash } from 'node:crypto';
15
+ import * as fs from 'node:fs';
16
+
17
+ function encodePaneId(paneId: string): string {
18
+ return String(paneId).replace(/[^A-Za-z0-9_-]/g, '-');
19
+ }
20
+
21
+ /** POSIX sun_path caps at ~104-108 bytes and libuv silently truncates longer paths, which would
22
+ * collide every pane of a workspace onto one socket under long TMPDIRs (macOS /var/folders).
23
+ * The workspace therefore rides as a 16-hex digest instead of its path. */
24
+ function workspaceDigest(cwd: string): string {
25
+ return createHash('sha256').update(cwd).digest('hex').slice(0, 16);
26
+ }
27
+
28
+ /** Pipe name: workspace-scoped + paneId, short enough for every socket namespace. */
29
+ export function pipeNameFor(cwd: string, paneId: string): string {
30
+ return `piher-${workspaceDigest(cwd)}-${encodePaneId(paneId)}`;
31
+ }
32
+
33
+ /** The names a client tries in order; one today, and the list keeps that a transport detail. */
34
+ export function pipeNameCandidates(cwd: string, paneId: string): string[] {
35
+ return [pipeNameFor(cwd, paneId)];
36
+ }
37
+
38
+ /** Windows named-pipe address in the kernel namespace, not a filesystem path. */
39
+ export function pipePathFor(name: string): string {
40
+ return process.platform === 'win32'
41
+ ? (name.startsWith('\\\\.\\pipe\\') ? name : `\\\\.\\pipe\\${name}`)
42
+ : `/tmp/${name}.sock`;
43
+ }
44
+
45
+ /** Pipe message in the JSON-lines protocol; one connection carries one request and response. */
46
+ export type PipeRequest =
47
+ | { type: 'ping'; id: string }
48
+ | {
49
+ type: 'prompt' | 'follow_up';
50
+ id: string;
51
+ text: string;
52
+ /** D49: reply follows the sender's pipe name, avoiding hard-coded controller/child roles. */
53
+ from?: string | null;
54
+ /** B3: deliver follow_up as steer so it arrives between tool calls within seconds. Missing/false keeps
55
+ * queue semantics (delivered when the run ends); the initial prompt stays queued while continuations steer. */
56
+ steer?: boolean;
57
+ }
58
+ | { type: 'interrupt'; id: string }
59
+ | {
60
+ /** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
61
+ type: 'reply';
62
+ id: string;
63
+ paneId: string;
64
+ text: string | null;
65
+ sessionFile: string | null;
66
+ };
67
+
68
+ export type PipeResponse =
69
+ | { type: 'ok'; id: string; detail?: string }
70
+ | { type: 'error'; id: string; message: string };
71
+
72
+ /** Response side of the protocol (`ok` / `error`): requests never use these tags, so this is a sound
73
+ * discriminator when narrowing a parsed line at the answering end. */
74
+ function isPipeResponse(value: PipeRequest | PipeResponse): value is PipeResponse {
75
+ return value.type === 'ok' || value.type === 'error';
76
+ }
77
+
78
+ function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
79
+ const t = (line ?? '').trim();
80
+ if (!t) return null;
81
+ try {
82
+ const obj = JSON.parse(t);
83
+ if (obj && typeof obj === 'object' && typeof obj.type === 'string') return obj as PipeRequest | PipeResponse;
84
+ } catch {
85
+ }
86
+ return null;
87
+ }
88
+
89
+ /** One connection, one request: connect → write JSON line → wait for JSON line → close. Timeout /
90
+ * connect failure / bad frame throw (caller retries). */
91
+ export function pipeRequest(
92
+ pipeName: string,
93
+ payload: PipeRequest,
94
+ timeoutMs = 8000,
95
+ ): Promise<PipeResponse> {
96
+ return new Promise((resolve, reject) => {
97
+ const sock = net.createConnection(pipePathFor(pipeName));
98
+ sock.setEncoding('utf8');
99
+ let buf = '';
100
+ let settled = false;
101
+ const timer = setTimeout(() => {
102
+ if (!settled) {
103
+ settled = true;
104
+ sock.destroy();
105
+ reject(new Error(`pipe ${pipeName}: ${payload.type} timeout`));
106
+ }
107
+ }, timeoutMs);
108
+ sock.on('connect', () => {
109
+ sock.write(JSON.stringify(payload) + '\n');
110
+ });
111
+ sock.on('data', (chunk) => {
112
+ buf += chunk;
113
+ const i = buf.indexOf('\n');
114
+ if (i < 0) return;
115
+ clearTimeout(timer);
116
+ if (settled) return;
117
+ settled = true;
118
+ sock.destroy();
119
+ const parsed = parsePipeLine(buf.slice(0, i));
120
+ if (!parsed || !isPipeResponse(parsed)) {
121
+ reject(new Error(`pipe ${pipeName}: bad response frame`));
122
+ return;
123
+ }
124
+ resolve(parsed);
125
+ });
126
+ sock.on('error', (err) => {
127
+ if (!settled) {
128
+ settled = true;
129
+ clearTimeout(timer);
130
+ reject(err);
131
+ }
132
+ });
133
+ sock.on('close', () => {
134
+ if (!settled) {
135
+ settled = true;
136
+ clearTimeout(timer);
137
+ reject(new Error(`pipe ${pipeName}: connection closed`));
138
+ }
139
+ });
140
+ });
141
+ }
142
+
143
+ export async function pipeRequestTo(
144
+ cwd: string,
145
+ paneId: string,
146
+ payload: PipeRequest,
147
+ timeoutMs = 8000,
148
+ ): Promise<PipeResponse> {
149
+ const names = pipeNameCandidates(cwd, paneId);
150
+ let lastErr: unknown;
151
+ for (const name of names) {
152
+ try {
153
+ return await pipeRequest(name, payload, timeoutMs);
154
+ } catch (err) {
155
+ lastErr = err;
156
+ }
157
+ }
158
+ throw lastErr instanceof Error ? lastErr : new Error(`pipe ${names[0] ?? paneId}: unreachable`);
159
+ }
160
+
161
+ type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
162
+
163
+ /** One pipe server per pane, across module copies. A pane can load piher twice (an installed package
164
+ * plus the `-e` the spawner passes), and two servers on one socket path steal it from each other: the
165
+ * loser's requests reach whichever instance bound last — and after a reload that is the instance whose
166
+ * ctx is stale, so every follow-up comes back as "ctx is stale after session replacement or reload".
167
+ * The registry lives on globalThis because two copies of this module share no module state. */
168
+ const PIPE_OWNER_KEY = Symbol.for('piher.pipeServerOwner');
169
+ const PIPE_GENERATION_KEY = Symbol.for('piher.pipeServerGeneration');
170
+
171
+ interface PipeOwner {
172
+ readonly generation: number;
173
+ /** Shuts the previous owner's server down; awaited before the new one binds. */
174
+ readonly close: () => Promise<void>;
175
+ }
176
+
177
+ function ownerRegistry(): Map<string, PipeOwner> {
178
+ const store = globalThis as unknown as Record<symbol, unknown>;
179
+ const existing = store[PIPE_OWNER_KEY];
180
+ // globalThis outlives a reload, so the value may belong to an older build with another shape (the
181
+ // first version kept a Set of pane ids). Interpreting it would throw on the first rebind after an
182
+ // upgrade, so an unknown shape is discarded rather than trusted.
183
+ if (existing instanceof Map) return existing as Map<string, PipeOwner>;
184
+ const fresh = new Map<string, PipeOwner>();
185
+ store[PIPE_OWNER_KEY] = fresh;
186
+ return fresh;
187
+ }
188
+
189
+ /** Become the pane's channel owner, closing whoever held it first. Last writer wins on purpose: after
190
+ * a reload the newest instance is the one with a live ctx, and the previous one must stop answering.
191
+ * Closing before binding also keeps the socket entry alive (libuv unlinks on close). */
192
+ export async function takeOverPipe(paneId: string, close: () => Promise<void>): Promise<number> {
193
+ const store = globalThis as unknown as Record<symbol, number | undefined>;
194
+ store[PIPE_GENERATION_KEY] = (store[PIPE_GENERATION_KEY] ?? 0) + 1;
195
+ const generation = store[PIPE_GENERATION_KEY];
196
+ const previous = ownerRegistry().get(paneId);
197
+ if (previous) {
198
+ try {
199
+ await previous.close();
200
+ } catch {
201
+ /* the previous owner is already gone: the bind below is the only thing left to do */
202
+ }
203
+ }
204
+ ownerRegistry().set(paneId, { generation, close });
205
+ return generation;
206
+ }
207
+
208
+ /** Still the owner? A watchdog must stop healing a channel someone else took over. */
209
+ export function ownsPipe(paneId: string, generation: number): boolean {
210
+ return ownerRegistry().get(paneId)?.generation === generation;
211
+ }
212
+
213
+ export function releasePipe(paneId: string, generation: number): void {
214
+ if (ownsPipe(paneId, generation)) ownerRegistry().delete(paneId);
215
+ }
216
+
217
+ /** Close a pipe server and wait for the close to complete, so its entry is gone before the next bind.
218
+ * Binding first and closing second is the trap: libuv unlinks the path on close, and it would remove
219
+ * the entry the new server just created — a listener nobody can connect to. */
220
+ export function closePipeServer(server: net.Server): Promise<void> {
221
+ return new Promise((resolve) => {
222
+ try {
223
+ server.close(() => resolve());
224
+ } catch {
225
+ resolve();
226
+ }
227
+ });
228
+ }
229
+
230
+ /** Server: one line in → handler → one line out → close. Returns the server (caller closes it). */
231
+ export function startPipeServer(
232
+ pipeName: string,
233
+ handler: PipeMessageHandler,
234
+ onError?: (err: Error) => void,
235
+ ): net.Server {
236
+ const socketPath = pipePathFor(pipeName);
237
+ // POSIX leaves the UNIX domain socket behind after a crash, and the next listen fails with
238
+ // EADDRINUSE unless it is unlinked before bind (Windows named pipes need no unlink). Best-effort:
239
+ // if unlink fails, listen reports the real error through onError.
240
+ if (process.platform !== 'win32') {
241
+ try {
242
+ fs.unlinkSync(socketPath);
243
+ } catch {
244
+ /* see above: listen surfaces the actionable error */
245
+ }
246
+ }
247
+ const server = net.createServer((sock) => {
248
+ sock.setEncoding('utf8');
249
+ let buf = '';
250
+ sock.on('data', (chunk) => {
251
+ buf += chunk;
252
+ const i = buf.indexOf('\n');
253
+ if (i < 0) return;
254
+ const line = buf.slice(0, i);
255
+ buf = buf.slice(i + 1);
256
+ const parsedLine = parsePipeLine(line);
257
+ if (!parsedLine || isPipeResponse(parsedLine)) {
258
+ sock.end(JSON.stringify({ type: 'error', id: '', message: 'bad frame' }) + '\n');
259
+ return;
260
+ }
261
+ const req: PipeRequest = parsedLine;
262
+ void handler(req)
263
+ .then((res) => {
264
+ sock.end(JSON.stringify(res) + '\n');
265
+ })
266
+ .catch((err: unknown) => {
267
+ sock.end(JSON.stringify({
268
+ type: 'error',
269
+ id: req.id,
270
+ message: String((err as Error)?.message ?? err),
271
+ }) + '\n');
272
+ });
273
+ });
274
+ sock.on('error', () => {
275
+ /* client disconnect: silent */
276
+ });
277
+ });
278
+ if (onError) {
279
+ server.on('error', onError);
280
+ } else {
281
+ // Always attach a listener: an EventEmitter 'error' with no listener throws and would take the
282
+ // whole extension host down on a transient listen failure; pass onError to observe it (F04).
283
+ server.on('error', () => { /* swallowed by design; pass onError to observe */ });
284
+ }
285
+ // No 'close' handler removes the entry: libuv already unlinks a unix socket when its server closes
286
+ // (verified: listen + close leaves no file). That is also why a rebind MUST close the old server to
287
+ // completion first — closing it after the new one bound deletes the NEW entry, leaving a live
288
+ // listener that no connect() can reach. See closePipeServer.
289
+ server.listen(socketPath);
290
+ return server;
291
+ }
@@ -0,0 +1,37 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ /**
5
+ * Windows uses LOCALAPPDATA, Linux XDG, macOS keeps `~/.pi` (with XDG override). Injectable for tests.
6
+ */
7
+ export interface PlatformPaths {
8
+ readonly agentDataDir: string
9
+ readonly sessionsDir: string
10
+ }
11
+
12
+ const PLATFORM_DEFAULTS: Record<string, {
13
+ /** A data dir from the environment, if this platform has one pi should follow. */
14
+ readonly envVar: string | null
15
+ readonly homeFallback: readonly string[]
16
+ }> = {
17
+ win32: { envVar: 'LOCALAPPDATA', homeFallback: ['AppData', 'Local'] },
18
+ // pi keeps its agent dir at `~/.pi/agent` and does not follow XDG. Deriving it from XDG_DATA_HOME
19
+ // pointed at ~/.local/share/agent/sessions, which never exists, so every resolution by session id
20
+ // silently found nothing (PI_CODING_AGENT_DIR, when set, is handled by the callers).
21
+ linux: { envVar: null, homeFallback: ['.pi'] },
22
+ darwin: { envVar: 'XDG_DATA_HOME', homeFallback: ['.pi'] },
23
+ }
24
+
25
+ export function createPlatformPaths(overrides?: Partial<PlatformPaths>): PlatformPaths {
26
+ const layout = PLATFORM_DEFAULTS[process.platform] ?? PLATFORM_DEFAULTS.darwin!
27
+ const dataDir = (layout.envVar ? process.env[layout.envVar] : undefined) || join(homedir(), ...layout.homeFallback)
28
+ const agentDataDir = overrides?.agentDataDir || join(dataDir, 'agent')
29
+
30
+ return {
31
+ agentDataDir,
32
+ sessionsDir: overrides?.sessionsDir || join(agentDataDir, 'sessions'),
33
+ }
34
+ }
35
+
36
+ /** Singleton for production; tests inject via createPlatformPaths(). */
37
+ export const platformPaths = createPlatformPaths()