pi-pier 0.3.0 → 0.4.1

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 (103) hide show
  1. package/README.md +16 -12
  2. package/package.json +3 -3
  3. package/schemas/role-manifest.schema.json +7 -1
  4. package/skills/pi-herdr-workflow/SKILL.md +4 -5
  5. package/src/ansi-text.ts +62 -11
  6. package/src/ask-dialog.ts +308 -0
  7. package/src/ask-user.ts +113 -112
  8. package/src/bootstrap.ts +10 -13
  9. package/src/compact-coordinator.ts +279 -261
  10. package/src/compact-economics-core.ts +77 -145
  11. package/src/config-catalog-core.ts +77 -197
  12. package/src/config-command.ts +271 -64
  13. package/src/cordis-services.ts +64 -0
  14. package/src/dashboard-command.ts +77 -33
  15. package/src/delegation-core.ts +115 -0
  16. package/src/efficiency-config-core.ts +286 -486
  17. package/src/efficiency-store.ts +16 -29
  18. package/src/gc-core.ts +10 -36
  19. package/src/guards.ts +6 -0
  20. package/src/herdr-client.ts +202 -233
  21. package/src/history-store.ts +38 -23
  22. package/src/index-gates.ts +118 -0
  23. package/src/index-locks.ts +2 -7
  24. package/src/index-master.ts +31 -29
  25. package/src/index-notices.ts +28 -15
  26. package/src/index-pipe.ts +31 -12
  27. package/src/index-roles.ts +306 -0
  28. package/src/index-runtime.ts +16 -6
  29. package/src/index-worker.ts +8 -18
  30. package/src/index.ts +561 -570
  31. package/src/jev-client.ts +12 -17
  32. package/src/jev-core.ts +138 -90
  33. package/src/ledger.ts +13 -21
  34. package/src/lock-core.ts +28 -56
  35. package/src/manifest-compose.ts +35 -20
  36. package/src/observation-core.ts +52 -100
  37. package/src/pane-title.ts +19 -38
  38. package/src/panel-open.ts +47 -0
  39. package/src/pi-surface.ts +34 -22
  40. package/src/pier-options.ts +53 -50
  41. package/src/pipe-channel.ts +44 -59
  42. package/src/platform-paths.ts +19 -52
  43. package/src/{core → plugins}/grid-shape.ts +8 -10
  44. package/src/{core → plugins}/heat-plan.ts +20 -22
  45. package/src/plugins/observation.ts +475 -0
  46. package/src/plugins/subagent.ts +766 -0
  47. package/src/{core → plugins}/terminal.ts +198 -139
  48. package/src/{core → plugins}/todo.ts +73 -134
  49. package/src/progress-core.ts +16 -53
  50. package/src/reconcile-core.ts +24 -33
  51. package/src/reducer-core.ts +26 -64
  52. package/src/reducer-invoker.ts +266 -239
  53. package/src/renderers.ts +108 -84
  54. package/src/role-loader.ts +33 -28
  55. package/src/role-manifest.ts +25 -31
  56. package/src/role-state.ts +119 -0
  57. package/src/roles/worker-default.json +3 -3
  58. package/src/routing-telemetry.ts +136 -0
  59. package/src/runtime-policy.ts +25 -40
  60. package/src/session-tail.ts +65 -41
  61. package/src/settle-wake-core.ts +61 -22
  62. package/src/slim-frame.ts +25 -122
  63. package/src/stale-core.ts +17 -24
  64. package/src/storage-layout.ts +14 -43
  65. package/src/subagent-core.ts +423 -120
  66. package/src/subagent-gc.ts +109 -67
  67. package/src/subagent-poller.ts +383 -24
  68. package/src/subagent-session.ts +516 -0
  69. package/src/subagent-spawn.ts +577 -52
  70. package/src/swallow.ts +7 -17
  71. package/src/terminal-core.ts +187 -134
  72. package/src/todo-core.ts +57 -109
  73. package/src/todo-read-hook.ts +46 -39
  74. package/src/todo-reminder-core.ts +26 -51
  75. package/src/todo-window.ts +11 -12
  76. package/src/todos-service.ts +15 -35
  77. package/src/tool-error.ts +6 -13
  78. package/src/tool-gate.ts +38 -21
  79. package/src/tree-view.ts +162 -0
  80. package/src/vocab.ts +52 -25
  81. package/src/worker-marker.ts +76 -0
  82. package/src/ask-multi.ts +0 -260
  83. package/src/config-guide.ts +0 -361
  84. package/src/core/observation.ts +0 -555
  85. package/src/core/subagent.ts +0 -633
  86. package/src/efficiency-config-io.ts +0 -53
  87. package/src/focus-poller.ts +0 -244
  88. package/src/git-adapter.ts +0 -113
  89. package/src/notice-buffer.ts +0 -23
  90. package/src/subagent-execute.ts +0 -328
  91. package/src/subagent-git-io.ts +0 -80
  92. package/src/subagent-launch.ts +0 -114
  93. package/src/subagent-list-action.ts +0 -69
  94. package/src/subagent-output-action.ts +0 -104
  95. package/src/subagent-output-adapter.ts +0 -27
  96. package/src/subagent-output-core.ts +0 -263
  97. package/src/subagent-poll-loop.ts +0 -305
  98. package/src/subagent-port.ts +0 -27
  99. package/src/subagent-ready.ts +0 -89
  100. package/src/subagent-registry.ts +0 -70
  101. package/src/subagent-resolution.ts +0 -41
  102. package/src/subagent-scope.ts +0 -41
  103. package/src/subagent-session-io.ts +0 -230
@@ -1,53 +0,0 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import { homedir } from 'node:os';
3
- import { join } from 'node:path';
4
-
5
- export interface PiCompactionSettings {
6
- enabled?: boolean;
7
- keepRecentTokens?: number;
8
- }
9
-
10
- function readCompactionSettings(filePath: string): PiCompactionSettings {
11
- if (!existsSync(filePath)) return {};
12
- try {
13
- const parsed: unknown = JSON.parse(readFileSync(filePath, 'utf8'));
14
- if (!parsed || typeof parsed !== 'object' || !('compaction' in parsed)) return {};
15
- const compaction = parsed.compaction;
16
- if (!compaction || typeof compaction !== 'object') return {};
17
- const result: PiCompactionSettings = {};
18
- if ('enabled' in compaction && typeof compaction.enabled === 'boolean') result.enabled = compaction.enabled;
19
- if ('keepRecentTokens' in compaction && typeof compaction.keepRecentTokens === 'number') {
20
- result.keepRecentTokens = compaction.keepRecentTokens;
21
- }
22
- return result;
23
- } catch {
24
- return {};
25
- }
26
- }
27
-
28
- function defaultAgentDir(): string {
29
- return process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
30
- }
31
-
32
- /** Read pi's own compaction settings (global, plus the project file when trusted). */
33
- export function loadPiNativeCompactionSettings(opts: {
34
- cwd?: string;
35
- isProjectTrusted?: boolean;
36
- agentDir?: string;
37
- } = {}): PiCompactionSettings {
38
- const cwd = opts.cwd ?? process.cwd();
39
- const globalSettings = readCompactionSettings(join(opts.agentDir ?? defaultAgentDir(), 'settings.json'));
40
- if (!opts.isProjectTrusted) return globalSettings;
41
- const projectSettings = readCompactionSettings(join(cwd, '.pi', 'settings.json'));
42
- return { ...globalSettings, ...projectSettings };
43
- }
44
-
45
- export function readJsonConfig(filePath: string, onError?: (error: unknown) => void): unknown | undefined {
46
- if (!existsSync(filePath)) return undefined;
47
- try {
48
- return JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
49
- } catch (error) {
50
- onError?.(error);
51
- return undefined;
52
- }
53
- }
@@ -1,244 +0,0 @@
1
- /**
2
- * D-4: herdr 0.9.0 resolved mouse focus client-side, so `pane.focused` never reached plugins.
3
- * Each pier pane sampled `layout.export` and replayed `pane.focused` into heat-reflow.mjs.
4
- *
5
- * Herdr 0.9.1 delivers `pane.focused` to the workbench hook. Default polling is therefore off
6
- * (0 ms) on ≥0.9.1 so a click is not followed by a second reflow ~8s later. Herdr <0.9.1 keeps
7
- * the 1500ms sampler. `PIER_FOCUS_POLL_MS` overrides either default; 0 disables.
8
- */
9
- import { spawn } from 'node:child_process';
10
- import { dirname, join } from 'node:path';
11
- import { fileURLToPath } from 'node:url';
12
-
13
- /** Poll cadence for older Herdr (< 0.9.1). */
14
- export const FOCUS_POLL_DEFAULT_MS = 1500;
15
- /** Herdr 0.9.1+: native pane.focused is authoritative; default poller off to avoid a delayed second reflow. */
16
- export const FOCUS_POLL_HERDR_091_MS = 0;
17
-
18
- /** Check if Herdr server version is 0.9.1 or later. */
19
- export function isHerdr091OrLater(version?: string | null): boolean {
20
- if (!version) return false;
21
- const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version.trim());
22
- if (!m) return false;
23
- const major = Number(m[1]);
24
- const minor = Number(m[2]);
25
- const patch = Number(m[3]);
26
- if (major > 0) return true;
27
- if (minor > 9) return true;
28
- if (minor === 9 && patch >= 1) return true;
29
- return false;
30
- }
31
-
32
- /** Determine default poll cadence based on Herdr server version. */
33
- export function resolveDefaultFocusPollMs(herdrVersion?: string | null): number {
34
- return isHerdr091OrLater(herdrVersion) ? FOCUS_POLL_HERDR_091_MS : FOCUS_POLL_DEFAULT_MS;
35
- }
36
- /**
37
- * Minimum gap between two reflow triggers. Re-clicking the same pane quickly must not spawn a
38
- * process per click; the workbench debounces per tab (150 ms) on top of this.
39
- */
40
- export const FOCUS_FIRE_MIN_INTERVAL_MS = 700;
41
-
42
- export interface FocusSample {
43
- /** Pane focused in the sampled tab (null when the server reported no focus). */
44
- readonly focusedPaneId: string | null;
45
- /** Panes currently in that tab, used to tell a click apart from a spawn auto-focus. */
46
- readonly paneIds: readonly string[];
47
- }
48
-
49
- export interface FocusPollerState {
50
- readonly lastFocusedPaneId: string | null;
51
- readonly lastPaneIds: readonly string[];
52
- readonly lastFireAt: number;
53
- }
54
-
55
- /** Minimal flatten of a herdr layout tree (`{type:'pane',pane_id}` / `{type:'split',first,second}`). */
56
- export function collectPaneIds(root: unknown): string[] {
57
- const out: string[] = [];
58
- const walk = (node: unknown): void => {
59
- if (node === null || typeof node !== 'object') return;
60
- const n = node as { pane_id?: unknown; first?: unknown; second?: unknown };
61
- if (typeof n.pane_id === 'string') {
62
- out.push(n.pane_id);
63
- return;
64
- }
65
- walk(n.first);
66
- walk(n.second);
67
- };
68
- walk(root);
69
- return out;
70
- }
71
-
72
- /** `layout.export` payload → sample. `focusedPaneId` is optional for older clients. */
73
- export function parseFocusSample(
74
- layout: { root: unknown; focusedPaneId?: string | null } | null | undefined,
75
- ): FocusSample | null {
76
- if (!layout || !('root' in layout)) return null;
77
- const paneIds = collectPaneIds(layout.root);
78
- const focused = typeof layout.focusedPaneId === 'string' ? layout.focusedPaneId : null;
79
- return { focusedPaneId: focused, paneIds };
80
- }
81
-
82
- export interface FocusTick {
83
- readonly fire: boolean;
84
- readonly cause: 'user' | null;
85
- readonly state: FocusPollerState;
86
- }
87
-
88
- /**
89
- * Decide whether one sample should trigger a reflow.
90
- *
91
- * Fires only on a *transition* into "I am focused" (so a steady focus never re-triggers) and at most
92
- * once per `minIntervalMs`. `cause` is 'user' when the pane set did not change in the same sample:
93
- * a click moves focus and nothing else, whereas a spawn auto-focus always comes with a new pane.
94
- * The workbench uses that distinction for its 3 s pane-age whitelist (F1: a freshly created pane
95
- * must not grab the layout through an automatic focus).
96
- */
97
- export function planFocusTick(opts: {
98
- myPaneId: string;
99
- sample: FocusSample;
100
- prev: FocusPollerState | null;
101
- now: number;
102
- minIntervalMs?: number;
103
- }): FocusTick {
104
- const minInterval = opts.minIntervalMs ?? FOCUS_FIRE_MIN_INTERVAL_MS;
105
- const prev = opts.prev;
106
- const next: FocusPollerState = {
107
- lastFocusedPaneId: opts.sample.focusedPaneId,
108
- lastPaneIds: opts.sample.paneIds,
109
- lastFireAt: prev?.lastFireAt ?? 0,
110
- };
111
- const noFire = (): FocusTick => ({ fire: false, cause: null, state: next });
112
-
113
- if (opts.myPaneId === '') return noFire();
114
- // First sample only records the baseline: a pi that starts in the focused pane must not reflow on
115
- // startup (that is not a click, and it would fight whatever layout the user had).
116
- if (prev === null) return noFire();
117
- if (opts.sample.focusedPaneId !== opts.myPaneId) return noFire();
118
- if (prev.lastFocusedPaneId === opts.myPaneId) return noFire();
119
- if (opts.now - prev.lastFireAt < minInterval) return noFire();
120
-
121
- const setUnchanged =
122
- prev.lastPaneIds.length === opts.sample.paneIds.length &&
123
- prev.lastPaneIds.every((id, i) => id === opts.sample.paneIds[i]);
124
- return {
125
- fire: true,
126
- cause: setUnchanged ? 'user' : null,
127
- state: { ...next, lastFireAt: opts.now },
128
- };
129
- }
130
-
131
- /* ─────────────────────────── runner ─────────────────────────── */
132
-
133
- export interface FocusPollerDeps {
134
- /** Layout of the caller's own tab (the client already exposes `layout.export`). */
135
- sample(): Promise<FocusSample | null>;
136
- /** Replay a pane.focused reflow for `paneId`. Must never throw. */
137
- fire(paneId: string, cause: 'user' | null): void;
138
- myPaneId: string;
139
- intervalMs?: number;
140
- minIntervalMs?: number;
141
- now?: () => number;
142
- setIntervalFn?: (fn: () => void, ms: number) => NodeJS.Timeout;
143
- clearIntervalFn?: (handle: NodeJS.Timeout) => void;
144
- /** Diagnostics only: polling failures are expected while herdr restarts. */
145
- onError?: (err: unknown) => void;
146
- }
147
-
148
- export interface FocusPoller {
149
- /** One sampling pass (exposed for tests; the interval calls the same function). */
150
- tick(): Promise<void>;
151
- stop(): void;
152
- }
153
-
154
- export function startFocusPoller(deps: FocusPollerDeps): FocusPoller {
155
- const intervalMs = deps.intervalMs ?? FOCUS_POLL_DEFAULT_MS;
156
- const now = deps.now ?? (() => Date.now());
157
- const setIntervalFn = deps.setIntervalFn ?? ((fn, ms) => setInterval(fn, ms));
158
- const clearIntervalFn =
159
- deps.clearIntervalFn ?? ((handle: NodeJS.Timeout) => { clearInterval(handle); });
160
- let state: FocusPollerState | null = null;
161
- let inFlight = false;
162
- let stopped = false;
163
- let handle: NodeJS.Timeout | null = null;
164
-
165
- async function tick(): Promise<void> {
166
- if (stopped || inFlight) return;
167
- inFlight = true;
168
- try {
169
- const sample = await deps.sample();
170
- if (sample === null) return;
171
- const decision = planFocusTick({
172
- myPaneId: deps.myPaneId,
173
- sample,
174
- prev: state,
175
- now: now(),
176
- minIntervalMs: deps.minIntervalMs,
177
- });
178
- state = decision.state;
179
- if (decision.fire) deps.fire(deps.myPaneId, decision.cause);
180
- } catch (err) {
181
- // A failed sample must not advance the baseline: the next tick re-reads the layout.
182
- deps.onError?.(err);
183
- } finally {
184
- inFlight = false;
185
- }
186
- }
187
-
188
- if (intervalMs > 0) {
189
- handle = setIntervalFn(() => { void tick(); }, intervalMs);
190
- handle.unref?.(); // never keep pi's event loop alive on our account
191
- }
192
-
193
- return {
194
- tick,
195
- stop(): void {
196
- stopped = true;
197
- if (handle !== null) {
198
- clearIntervalFn(handle);
199
- handle = null;
200
- }
201
- },
202
- };
203
- }
204
-
205
- /** The workbench's reflow entry point; overridable for relocated checkouts. */
206
- export function reflowScriptPath(env: NodeJS.ProcessEnv = process.env): string {
207
- const root = env.PIER_WORKBENCH_ROOT?.trim();
208
- if (root) return join(root, 'scripts', 'heat-reflow.mjs');
209
- // packages/pier-ext/src/focus-poller.ts -> packages/pier-workbench/scripts/heat-reflow.mjs
210
- return join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'pier-workbench', 'scripts', 'heat-reflow.mjs');
211
- }
212
-
213
- export interface SpawnReflowOpts {
214
- paneId: string;
215
- cause: 'user' | null;
216
- env?: NodeJS.ProcessEnv;
217
- /** Injection seam for tests (defaults to child_process.spawn). */
218
- spawnFn?: typeof spawn;
219
- }
220
-
221
- /**
222
- * Fire-and-forget replay of the `pane.focused` hook.
223
- * Why a child process: it is exactly how herdr runs the hook, so the workbench script keeps its own
224
- * event parsing, debounce and state file — pier never duplicates layout logic.
225
- */
226
- export function spawnReflow(opts: SpawnReflowOpts): void {
227
- const env = opts.env ?? process.env;
228
- const spawnFn = opts.spawnFn ?? spawn;
229
- const payload = JSON.stringify({
230
- event: 'pane_focused',
231
- type: 'pane_focused',
232
- data: { type: 'pane_focused', pane_id: opts.paneId, cause: opts.cause },
233
- });
234
- try {
235
- const child = spawnFn(process.execPath, [reflowScriptPath(env)], {
236
- env: { ...env, HERDR_PLUGIN_EVENT: 'pane.focused', HERDR_PLUGIN_EVENT_JSON: payload },
237
- stdio: 'ignore',
238
- });
239
- child.on?.('error', () => { /* Missing script / dead exec: polling must never break pi. */ });
240
- child.unref?.();
241
- } catch {
242
- /* Best-effort: focus heat is a comfort feature; the click still focuses the pane. */
243
- }
244
- }
@@ -1,113 +0,0 @@
1
- import { execFile as nodeExecFile } from 'node:child_process'
2
- import { promisify } from 'node:util'
3
- import { runtimePolicy } from './runtime-policy.ts'
4
-
5
- const execFile = promisify(nodeExecFile)
6
-
7
- export interface GitResult {
8
- readonly stdout: string
9
- readonly stderr: string
10
- }
11
-
12
- export interface GitError extends Error {
13
- readonly code?: string | number
14
- readonly stdout?: string
15
- readonly stderr?: string
16
- }
17
-
18
- /**
19
- * Injectable execFile used by NodeGitAdapter.
20
- * Why: tests drive git without a real process.
21
- */
22
- export type GitExecFile = (
23
- file: string,
24
- args: readonly string[],
25
- options: { timeout: number; encoding: 'utf8'; maxBuffer: number },
26
- ) => Promise<{ stdout: string; stderr: string }>;
27
-
28
- /**
29
- * Output ceiling for one git call. `execFile` defaults to 1MB, which a large checkout exceeds
30
- * (`status --porcelain` on a repo with tens of thousands of untracked files, or `diff --stat`
31
- * across a wide change): the call then fails with ENOBUFS, callers normalize it to null, and the
32
- * user sees a silently missing stat line — or "failed to create worktree" for a worktree that was
33
- * in fact created. 64MB bounds memory without hitting realistic repositories.
34
- */
35
- const GIT_MAX_BUFFER = 64 * 1024 * 1024;
36
-
37
- /**
38
- * Minimal Git adapter for worktree and status operations.
39
- *
40
- * Why: Centralizes platform-specific git executable resolution, timeout policy,
41
- * and error normalization; makes tests independent of real git processes.
42
- */
43
- export interface GitAdapter {
44
- /**
45
- * List worktrees in porcelain format.
46
- * Returns stdout containing worktree entries.
47
- */
48
- listWorktrees(cwd: string): Promise<GitResult>
49
-
50
- /**
51
- * Get status of worktree in short format.
52
- * Returns stdout containing status entries.
53
- */
54
- status(cwd: string): Promise<GitResult>
55
-
56
- /**
57
- * Run an arbitrary git command in `cwd` (`git -C <cwd> ...args`).
58
- * Throws a GitError on failure/timeout — callers that want "null on error"
59
- * (isolate sweep, placement) catch at the call site.
60
- */
61
- run(cwd: string, args: readonly string[]): Promise<GitResult>
62
- }
63
- export class NodeGitAdapter implements GitAdapter {
64
- private readonly gitExecutable: string
65
- private readonly timeoutMs: number
66
- private readonly exec: GitExecFile
67
-
68
- constructor(
69
- gitExecutable: string = 'git',
70
- timeoutMs: number = runtimePolicy.gitTimeoutMs,
71
- exec: GitExecFile = execFile as unknown as GitExecFile,
72
- ) {
73
- this.gitExecutable = gitExecutable
74
- this.timeoutMs = timeoutMs
75
- this.exec = exec
76
- }
77
-
78
- listWorktrees(cwd: string): Promise<GitResult> {
79
- return this.run(cwd, ['worktree', 'list', '--porcelain'])
80
- }
81
-
82
- status(cwd: string): Promise<GitResult> {
83
- return this.run(cwd, ['status', '--short'])
84
- }
85
-
86
- async run(cwd: string, args: readonly string[]): Promise<GitResult> {
87
- try {
88
- const { stdout, stderr } = await this.exec(
89
- this.gitExecutable,
90
- ['-C', cwd, ...args],
91
- { timeout: this.timeoutMs, encoding: 'utf8', maxBuffer: GIT_MAX_BUFFER },
92
- )
93
- return { stdout, stderr }
94
- } catch (err) {
95
- throw this.normalizeError(err, args[0] ?? 'command')
96
- }
97
- }
98
-
99
- private normalizeError(err: unknown, operation: string): GitError {
100
- if (err instanceof Error) {
101
- const gitError = err as GitError
102
- gitError.message = `Git ${operation} failed: ${gitError.message}`
103
- return gitError
104
- }
105
- return new Error(`Git ${operation} failed: ${String(err)}`) as GitError
106
- }
107
- }
108
-
109
- /**
110
- * Default production git adapter.
111
- * Tests should inject fake implementations or a fake exec.
112
- */
113
- export const defaultGitAdapter = new NodeGitAdapter()
@@ -1,23 +0,0 @@
1
- /**
2
- * D92 settlement-notice collapser (pure half of the followup-flood fix).
3
- *
4
- * Why: `sendUserMessage(followUp)` only delivers when the agent has no more
5
- * tool calls, so a long master run queues every settlement and floods at the
6
- * end. Buffer while busy; collapse at turn_end. The other half is the index
7
- * notice buffer.
8
- */
9
- export const NOTICE_MAX_SHOWN = 3;
10
-
11
- /**
12
- * Collapse a batch into one message body.
13
- * - empty → null (do not inject)
14
- * - ≤max → originals joined with `\n\n` (single-item byte-identical to the old format)
15
- * - >max → first max originals + a tail pointing at history / subagent list
16
- */
17
- export function collapseNotices(contents: readonly string[], max = NOTICE_MAX_SHOWN): string | null {
18
- if (contents.length === 0) return null;
19
- if (contents.length <= max) return contents.join('\n\n');
20
- const hidden = contents.length - max;
21
- const tail = `…另有 ${hidden} 条结算未逐条展示。全量结果看 history 台账(路径公式见 subagent resume 工具描述);在跑代理用 subagent list 查看。`;
22
- return [...contents.slice(0, max), tail].join('\n\n');
23
- }