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.
- package/README.md +16 -12
- package/package.json +3 -3
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +10 -13
- package/src/compact-coordinator.ts +279 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/cordis-services.ts +64 -0
- package/src/dashboard-command.ts +77 -33
- package/src/delegation-core.ts +115 -0
- package/src/efficiency-config-core.ts +286 -486
- package/src/efficiency-store.ts +16 -29
- package/src/gc-core.ts +10 -36
- package/src/guards.ts +6 -0
- package/src/herdr-client.ts +202 -233
- package/src/history-store.ts +38 -23
- package/src/index-gates.ts +118 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +31 -29
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +31 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +16 -6
- package/src/index-worker.ts +8 -18
- package/src/index.ts +561 -570
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +138 -90
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/panel-open.ts +47 -0
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +53 -50
- package/src/pipe-channel.ts +44 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +766 -0
- package/src/{core → plugins}/terminal.ts +198 -139
- package/src/{core → plugins}/todo.ts +73 -134
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +26 -64
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +108 -84
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +25 -31
- package/src/role-state.ts +119 -0
- package/src/roles/worker-default.json +3 -3
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +61 -22
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +423 -120
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +383 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +577 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +187 -134
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +46 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/tree-view.ts +162 -0
- package/src/vocab.ts +52 -25
- package/src/worker-marker.ts +76 -0
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/focus-poller.ts +0 -244
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- 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
|
-
}
|
package/src/focus-poller.ts
DELETED
|
@@ -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
|
-
}
|
package/src/git-adapter.ts
DELETED
|
@@ -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()
|
package/src/notice-buffer.ts
DELETED
|
@@ -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
|
-
}
|