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,73 @@
1
+ /**
2
+ * Settled-wake decision (prevents wake storms): a settle wake that injects messages creates a
3
+ * self-triggering loop (settle → notice → new run → settle → …) that even ESC cannot break, so an
4
+ * aborted previous turn injects nothing and a running set is announced once, re-announced only after
5
+ * D96_REPEAT_NOTICE_MS or when the set changes.
6
+ */
7
+
8
+ export const ABORT_STOP_REASON = 'aborted';
9
+
10
+ /** pi/provider failure stop. pi auto-retries these, so the run has not settled for the user. */
11
+ export const ERROR_STOP_REASON = 'error';
12
+
13
+ /** D96: cooldown before repeating a notice for the same running set. */
14
+ export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
15
+
16
+ export interface SettleWakeInput {
17
+ /** stopReason of the last assistant turn (null = treated as natural completion). */
18
+ lastStopReason: string | null;
19
+ /**
20
+ * pi ≥ 1.1 carries `aborted` on agent_settled itself: authoritative for runs cancelled without a
21
+ * final assistant message (ESC mid-tool), where stopReason tracking sees a stale natural reason.
22
+ */
23
+ aborted?: boolean;
24
+ running: ReadonlyArray<{ paneId: string }>;
25
+ lastNoticeKey: string | null;
26
+ lastNoticeAt: number;
27
+ now: number;
28
+ }
29
+
30
+ export interface SettleWakePlan {
31
+ /** false means this settlement stays silent and emits no wake-up message. */
32
+ wake: boolean;
33
+ notice: boolean;
34
+ /** New set key; empty running set → null. */
35
+ noticeKey: string | null;
36
+ noticeAt: number;
37
+ }
38
+
39
+ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
40
+ if (input.aborted === true || input.lastStopReason === ABORT_STOP_REASON) {
41
+ return {
42
+ wake: false,
43
+ notice: false,
44
+ noticeKey: input.lastNoticeKey,
45
+ noticeAt: input.lastNoticeAt,
46
+ };
47
+ }
48
+ const key = input.running.length === 0 ? null : input.running.map((s) => s.paneId).sort().join(',');
49
+ if (key === null) {
50
+ return { wake: true, notice: false, noticeKey: null, noticeAt: input.lastNoticeAt };
51
+ }
52
+ const newSet = key !== input.lastNoticeKey;
53
+ const cooled = input.now - input.lastNoticeAt >= D96_REPEAT_NOTICE_MS;
54
+ const notice = newSet || cooled;
55
+ return { wake: true, notice, noticeKey: key, noticeAt: notice ? input.now : input.lastNoticeAt };
56
+ }
57
+
58
+ /**
59
+ * Whether a just-settled run may answer its pending machine request (the pipe settle fast-path).
60
+ * A user abort ('aborted') settles a run that may re-run, and an 'error' stop is an upstream
61
+ * failure pi retries — answering then reports "finished, no closing message" for live work and
62
+ * consumes the one-shot request plus the parent's claim latch, so the real closing message can
63
+ * never be delivered.
64
+ */
65
+ export function shouldPushSettleReply(input: {
66
+ /** pi ≥ 1.1 agent_settled.aborted: the run was cancelled — the closing message may still come after a re-run. */
67
+ aborted?: boolean;
68
+ lastStopReason: string | null;
69
+ }): boolean {
70
+ return input.aborted !== true
71
+ && input.lastStopReason !== ABORT_STOP_REASON
72
+ && input.lastStopReason !== ERROR_STOP_REASON;
73
+ }
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A settlement travels as a custom `piher.settlement` message: the transcript draws a card
3
+ * (name, role, outcome, elapsed) while the text the model reads stays exactly the settlement
4
+ * notice. A host without custom-message support gets the same text as a plain user message, so
5
+ * delivery never depends on the rendering.
6
+ */
7
+
8
+ export const SETTLEMENT_CUSTOM_TYPE = 'piher.settlement';
9
+
10
+ /** Why the worker left: normal settlement, a dead pane / settle timeout, or a reclaimed row. */
11
+ export type SettlementOutcome = 'settled' | 'failed' | 'closed';
12
+
13
+ /** Card fields only — the notice text itself is the message content. */
14
+ export interface SettlementCard {
15
+ name: string;
16
+ /** Role name from the ledger row's `kind`; `null` for a plain task. */
17
+ role: string | null;
18
+ outcome: SettlementOutcome;
19
+ /** Worker lifetime, spawn → settlement; `null` when the row is gone. */
20
+ elapsedMs: number | null;
21
+ }
22
+
23
+ /** `12s` / `3m04s` / `2h11m`: short enough for a card header, unambiguous at a glance. */
24
+ export function formatElapsed(ms: number): string {
25
+ const seconds = Math.max(0, Math.round(ms / 1000));
26
+ if (seconds < 60) return `${seconds}s`;
27
+ const minutes = Math.floor(seconds / 60);
28
+ if (minutes < 60) return `${minutes}m${String(seconds % 60).padStart(2, '0')}s`;
29
+ return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
30
+ }
31
+
32
+ /** `kind` is the ledger row's role label; `'task'` (the default) means the worker ran no role. */
33
+ export function settlementRole(kind: string | null | undefined): string | null {
34
+ return typeof kind === 'string' && kind !== '' && kind !== 'task' ? kind : null;
35
+ }
36
+
37
+ export function settlementCard(input: {
38
+ name: string;
39
+ kind?: string | null;
40
+ outcome: SettlementOutcome;
41
+ startedAt?: number | null;
42
+ now?: number;
43
+ }): SettlementCard {
44
+ const now = input.now ?? Date.now();
45
+ const startedAt = typeof input.startedAt === 'number' && Number.isFinite(input.startedAt)
46
+ ? input.startedAt
47
+ : null;
48
+ return {
49
+ name: input.name,
50
+ role: settlementRole(input.kind),
51
+ outcome: input.outcome,
52
+ elapsedMs: startedAt === null ? null : Math.max(0, now - startedAt),
53
+ };
54
+ }
55
+
56
+ export interface SettlementMessageApi {
57
+ sendMessage?(
58
+ message: { customType: string; content: string; display: boolean; details?: unknown },
59
+ opts?: { deliverAs?: string; triggerTurn?: boolean },
60
+ ): void;
61
+ sendUserMessage?(content: string, opts?: { deliverAs?: string; triggerTurn?: boolean }): void;
62
+ }
63
+
64
+ /**
65
+ * Deliver one settlement notice. Custom message first (card rendered, content untouched), then the
66
+ * plain notice when the host has no custom messages or the renderer could not be registered; a
67
+ * synchronous throw from either path propagates so the caller can retry instead of losing the
68
+ * settlement.
69
+ */
70
+ export async function sendSettlementNotice(
71
+ api: SettlementMessageApi,
72
+ input: {
73
+ content: string;
74
+ mode: 'steer' | 'followUp';
75
+ /** Absent for a collapsed batch: the card describes one settlement, the batch text covers many. */
76
+ card?: SettlementCard | null;
77
+ /** False when no `registerMessageRenderer` exists, so a card would render as raw JSON. */
78
+ cardsRendered: boolean;
79
+ },
80
+ ): Promise<void> {
81
+ const opts = { deliverAs: input.mode, triggerTurn: true };
82
+ if (input.cardsRendered && typeof api.sendMessage === 'function') {
83
+ try {
84
+ api.sendMessage({
85
+ customType: SETTLEMENT_CUSTOM_TYPE,
86
+ content: input.content,
87
+ display: true,
88
+ ...(input.card ? { details: input.card } : {}),
89
+ }, opts);
90
+ return;
91
+ } catch {
92
+ /* Older host or a rejected custom message: the plain notice below is the delivery. */
93
+ }
94
+ }
95
+ api.sendUserMessage?.(input.content, opts);
96
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Storage path layout (session dirs, role dirs) — the single owner of the
3
+ * session-dir encoding, so session-tail / role-loader cannot drift apart.
4
+ *
5
+ * Reads try pi core's own directory name first, then piher's collision-resistant encoding
6
+ * (`a/b` ≠ `a-b`), because both spellings can exist on disk.
7
+ */
8
+ import { readFileSync, statSync } from 'node:fs';
9
+ import { homedir } from 'node:os';
10
+ import { dirname, join, resolve } from 'node:path';
11
+
12
+ /** One shape for every per-session id carved out of a transcript path (worker marker, session dir). */
13
+ const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]*$/i;
14
+
15
+ export function isValidSessionId(id: string | null | undefined): boolean {
16
+ return typeof id === 'string' && id.length > 0 && SAFE_SESSION_ID_RE.test(id);
17
+ }
18
+
19
+ /** Collision-resistant name: `%` is escaped first so `/` `\` `:` round-trip (`a/b` → `--a%2Fb--`). */
20
+ export function sessionDirName(cwd: string): string {
21
+ const encoded = cwd
22
+ .replace(/%/g, '%25')
23
+ .replace(/\\/g, '%5C')
24
+ .replace(/\//g, '%2F')
25
+ .replace(/:/g, '%3A');
26
+ return `--${encoded}--`;
27
+ }
28
+
29
+ /**
30
+ * pi core's own session-directory encoding, byte-for-byte: strip ONE leading separator, then flatten
31
+ * `/` `\` `:` — it does NOT escape `%` like `sessionDirName`. `<agentDir>/sessions/` is pi core's
32
+ * directory, so reading pi sessions must match it exactly.
33
+ */
34
+ export function piCoreSessionDirName(cwd: string): string {
35
+ return `--${cwd.replace(/^[/\\]/, '').replace(/[/\\:]/g, '-')}--`;
36
+ }
37
+
38
+ /**
39
+ * pi core's exact name first, then piher's own encoding. Read-only: writes to
40
+ * `<agentDir>/sessions/` are pi core's business.
41
+ */
42
+ export function piSessionDirCandidates(cwd: string): readonly string[] {
43
+ return [...new Set([piCoreSessionDirName(cwd), sessionDirName(cwd)])];
44
+ }
45
+
46
+ /**
47
+ * Main worktree root of the repository containing `cwd`, or null when `cwd` is not in a working
48
+ * tree. Mirrors the first entry of `git worktree list --porcelain` without spawning git, because the
49
+ * ledger is written on a synchronous path: a main checkout has a `.git` directory, a linked
50
+ * worktree has a `.git` file whose `commondir` points back at the main checkout's `.git`.
51
+ */
52
+ export function mainWorktreeRoot(cwd: string): string | null {
53
+ let dir = resolve(cwd);
54
+ try {
55
+ if (!statSync(dir).isDirectory()) return null;
56
+ } catch {
57
+ return null; // a collected checkout (deleted worktree) has no repository to write into
58
+ }
59
+ for (;;) {
60
+ const dotGit = join(dir, '.git');
61
+ let isDir: boolean | null = null;
62
+ try {
63
+ isDir = statSync(dotGit).isDirectory();
64
+ } catch {
65
+ isDir = null;
66
+ }
67
+ if (isDir === true) return dir;
68
+ if (isDir === false) {
69
+ try {
70
+ const m = /^gitdir:\s*(.+)$/m.exec(readFileSync(dotGit, 'utf8'));
71
+ if (!m) return null;
72
+ const gitdir = resolve(dir, m[1]!.trim());
73
+ // A linked worktree records the shared git dir in `commondir` (`../..` from its own gitdir).
74
+ // A submodule or a bare redirect has no commondir and owns no working tree here.
75
+ return dirname(resolve(gitdir, readFileSync(join(gitdir, 'commondir'), 'utf8').trim()));
76
+ } catch {
77
+ return null;
78
+ }
79
+ }
80
+ const parent = dirname(dir);
81
+ if (parent === dir) return null;
82
+ dir = parent;
83
+ }
84
+ }
85
+
86
+ export function userRolesDir(): string {
87
+ return join(homedir(), '.pi', 'agent', 'piher', 'roles');
88
+ }
89
+
90
+ export function workspaceRolesDir(baseDir: string): string {
91
+ return join(baseDir, '.piher', 'roles');
92
+ }