pi-pier 0.2.7 → 0.3.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 +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
package/src/pi-surface.ts
CHANGED
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Proxy the pi ExtensionAPI registration surface (D79).
|
|
3
3
|
*
|
|
4
|
-
* pi
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* older generations immediately. The ledger then collects only generations registered
|
|
10
|
-
* through the reload boundary; explicit disposal retires every generation.
|
|
4
|
+
* pi 0.86+ `pi.on()` returns an unsubscribe, so event handlers are truly removed when their generation
|
|
5
|
+
* retires (RFC docs/rfc-pi-0.86-dynamic-tools.md §5). Tools and commands have no unregister API: tools
|
|
6
|
+
* overwrite by name, so retired generations keep tombstoned wrappers there. HMR emits reload after
|
|
7
|
+
* mounting the replacement, so mounting a key retires older generations immediately; the ledger then
|
|
8
|
+
* collects only generations registered through the reload boundary.
|
|
11
9
|
*/
|
|
12
10
|
import type { DisposeLedger } from './ledger.ts';
|
|
13
11
|
|
|
@@ -28,6 +26,19 @@ interface Group {
|
|
|
28
26
|
alive: boolean;
|
|
29
27
|
/** Monotonic mount generation used by the ledger disposer to determine its collection boundary. */
|
|
30
28
|
epoch: number;
|
|
29
|
+
unsubscribes: Array<() => void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Flip the tombstone and drain any native unsubscribes; retirement must never throw. */
|
|
33
|
+
function retireGroup(group: Group): void {
|
|
34
|
+
group.alive = false;
|
|
35
|
+
for (const off of group.unsubscribes) {
|
|
36
|
+
try {
|
|
37
|
+
off();
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
group.unsubscribes.length = 0;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
/** Proxy for pi's registration surface (D79); non-registration methods (append/exec/setActiveTools, etc.) pass through raw. */
|
|
@@ -51,16 +62,13 @@ export class PiSurface<P extends object> {
|
|
|
51
62
|
return this.pi;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
/**
|
|
55
|
-
*
|
|
56
|
-
* generations for the same key so HMR replacement and manual remounting share one lifecycle.
|
|
57
|
-
* The ledger registers each key once and registers it again only after its entry is consumed.
|
|
58
|
-
*/
|
|
65
|
+
/** Get a module-scoped registration surface; each call creates a new generation, retiring older ones
|
|
66
|
+
* for the same key. The ledger registers each key once and again only after its entry is consumed. */
|
|
59
67
|
forModule(key: string): ScopedSurface {
|
|
60
68
|
this.epochCounter += 1;
|
|
61
69
|
const epoch = this.epochCounter;
|
|
62
|
-
for (const g of this.generations.get(key) ?? []) g
|
|
63
|
-
const group: Group = { alive: true, epoch };
|
|
70
|
+
for (const g of this.generations.get(key) ?? []) retireGroup(g);
|
|
71
|
+
const group: Group = { alive: true, epoch, unsubscribes: [] };
|
|
64
72
|
this.generations.set(key, [group]);
|
|
65
73
|
this.groups.set(key, group);
|
|
66
74
|
if (this.ledger && !this.ledgerEntryEpoch.has(key)) {
|
|
@@ -72,7 +80,7 @@ export class PiSurface<P extends object> {
|
|
|
72
80
|
key,
|
|
73
81
|
(this.generations.get(key) ?? []).filter((g) => {
|
|
74
82
|
if (g.epoch <= bound) {
|
|
75
|
-
g
|
|
83
|
+
retireGroup(g);
|
|
76
84
|
return false;
|
|
77
85
|
}
|
|
78
86
|
return true;
|
|
@@ -94,9 +102,8 @@ export class PiSurface<P extends object> {
|
|
|
94
102
|
);
|
|
95
103
|
},
|
|
96
104
|
registerCommand: (name, options) => {
|
|
97
|
-
// pi stores commands as Map<name
|
|
98
|
-
//
|
|
99
|
-
// replacement means the old handler will no longer be called.
|
|
105
|
+
// pi stores commands as Map<name>, so same-name registration replaces safely; the handler
|
|
106
|
+
// tombstone is a symmetry safeguard even though the old handler will no longer be called.
|
|
100
107
|
const handler = options.handler as ((...a: unknown[]) => unknown) | undefined;
|
|
101
108
|
const wrapped = handler
|
|
102
109
|
? (...a: unknown[]) => (group!.alive ? handler(...a) : undefined)
|
|
@@ -105,23 +112,28 @@ export class PiSurface<P extends object> {
|
|
|
105
112
|
.registerCommand?.(name, wrapped ? { ...options, handler: wrapped } : options);
|
|
106
113
|
},
|
|
107
114
|
on: (event, handler) => {
|
|
115
|
+
// Pass-through while alive, tombstone after retirement; when pi (0.86+) returns an unsubscribe,
|
|
116
|
+
// retirement also removes the registration so HMR churn cannot grow the dispatch list.
|
|
108
117
|
const wrapped = (...a: unknown[]) => (group!.alive ? handler(...a) : undefined);
|
|
109
|
-
|
|
118
|
+
const unsubscribe: unknown = (
|
|
119
|
+
this.pi as { on?: (e: string, h: (...a: unknown[]) => unknown) => unknown }
|
|
120
|
+
).on?.(event, wrapped);
|
|
121
|
+
if (typeof unsubscribe === 'function') {
|
|
122
|
+
group.unsubscribes.push(unsubscribe as () => void);
|
|
123
|
+
}
|
|
110
124
|
},
|
|
111
125
|
};
|
|
112
126
|
}
|
|
113
127
|
|
|
114
|
-
/** Retire all generations for a key during explicit disposal; return false when the key is absent. */
|
|
115
128
|
disposeModule(key: string): boolean {
|
|
116
129
|
const had = this.groups.has(key);
|
|
117
|
-
for (const g of this.generations.get(key) ?? []) g
|
|
130
|
+
for (const g of this.generations.get(key) ?? []) retireGroup(g);
|
|
118
131
|
this.generations.delete(key);
|
|
119
132
|
this.groups.delete(key);
|
|
120
133
|
this.ledgerEntryEpoch.delete(key);
|
|
121
134
|
return had;
|
|
122
135
|
}
|
|
123
136
|
|
|
124
|
-
/** Number of live module groups, for tests and diagnostics. */
|
|
125
137
|
get moduleCount(): number {
|
|
126
138
|
return this.groups.size;
|
|
127
139
|
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* B10 environment naming. `PIER_*` is the canonical namespace for *pier options* (timeouts, windows,
|
|
3
|
+
* toggles); the historical `PI_HERDR_*` spelling stays accepted as a fallback alias.
|
|
4
|
+
*
|
|
5
|
+
* `PI_HERDR_SUBAGENT`, `PI_HERDR_ROLE_MANIFEST`, `PI_HERDR_TUI`, `PI_HERDR_META_KEY` stay as they are:
|
|
6
|
+
* those are *contracts* handed to the child process by pier itself (or read by pi/herdr), and renaming
|
|
7
|
+
* them would split running workers from their parent.
|
|
8
|
+
*
|
|
9
|
+
* `pierOption()` is the single reader; this registry is also the single source of the numeric
|
|
10
|
+
* bounds/defaults behind `runtimePolicy` (runtime-policy.ts) and the `/pier-config` catalog
|
|
11
|
+
* (config-catalog-core.ts), so a knob cannot mean three different things.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** RuntimePolicy field fed by an integer option (see runtime-policy.ts). */
|
|
15
|
+
export type PolicyField =
|
|
16
|
+
| 'subagentTimeoutMs'
|
|
17
|
+
| 'gcTickMs'
|
|
18
|
+
| 'pollIntervalMs'
|
|
19
|
+
| 'settlementWindowMs'
|
|
20
|
+
| 'observationWindowMs'
|
|
21
|
+
| 'foregroundPatienceMs'
|
|
22
|
+
| 'sessionTtlSeconds'
|
|
23
|
+
| 'gitTimeoutMs'
|
|
24
|
+
| 'readinessTimeoutMs';
|
|
25
|
+
|
|
26
|
+
export interface OptionSpec {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
/** Historical `PI_HERDR_*` spelling still accepted. */
|
|
29
|
+
readonly legacy?: string;
|
|
30
|
+
/** Default when neither name is set. */
|
|
31
|
+
readonly fallback: string;
|
|
32
|
+
readonly description: string;
|
|
33
|
+
/** Integer option: smallest value the readers accept (omitted for string/enum options). */
|
|
34
|
+
readonly min?: number;
|
|
35
|
+
/** Integer option read into `RuntimePolicy`. */
|
|
36
|
+
readonly policy?: PolicyField;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Every pier option, with its alias, default and bounds. */
|
|
40
|
+
export const PIER_OPTIONS: readonly OptionSpec[] = [
|
|
41
|
+
{ name: 'PIER_GIT_TIMEOUT_MS', legacy: 'PI_HERDR_GIT_TIMEOUT_MS', fallback: '10000', min: 1, policy: 'gitTimeoutMs', description: 'git command timeout (worktree/status batches)' },
|
|
42
|
+
{ name: 'PIER_SUBAGENT_TIMEOUT_MS', legacy: 'PI_HERDR_SUBAGENT_TIMEOUT_MS', fallback: '600000', min: 1000, policy: 'subagentTimeoutMs', description: 'subagent inactivity budget before forced termination' },
|
|
43
|
+
{ name: 'PIER_READY_TIMEOUT_MS', legacy: 'PI_HERDR_READY_TIMEOUT_MS', fallback: '90000', min: 1000, policy: 'readinessTimeoutMs', description: 'how long to wait for a spawned pane to open its pipe (a dead pane fails fast)' },
|
|
44
|
+
{ name: 'PIER_OBSERVATION_WINDOW_MS', legacy: 'PI_HERDR_OBSERVATION_WINDOW_MS', fallback: '30000', min: 0, policy: 'observationWindowMs', description: 'observation window before a subagent is called settled' },
|
|
45
|
+
{ name: 'PIER_POLL_INTERVAL_MS', legacy: 'PI_HERDR_POLL_INTERVAL_MS', fallback: '30000', min: 1000, policy: 'pollIntervalMs', description: 'subagent poll cadence' },
|
|
46
|
+
{ name: 'PIER_GC_TICK_MS', legacy: 'PI_HERDR_GC_TICK_MS', fallback: '30000', min: 1000, policy: 'gcTickMs', description: 'idle GC sweep cadence (panes/worktrees)' },
|
|
47
|
+
{ name: 'PIER_SETTLEMENT_WINDOW_MS', legacy: 'PI_HERDR_SETTLEMENT_WINDOW_MS', fallback: '60000', min: 0, policy: 'settlementWindowMs', description: 'settlement notice window / machine-inject grace / takeover idle' },
|
|
48
|
+
{ name: 'PIER_FOREGROUND_PATIENCE_MS', legacy: 'PI_HERDR_FOREGROUND_PATIENCE_MS', fallback: '300000', min: 0, policy: 'foregroundPatienceMs', description: 'foreground patience before demoting a subagent to background' },
|
|
49
|
+
{ name: 'PIER_SESSION_TTL_SECONDS', legacy: 'PI_HERDR_SESSION_TTL_SECONDS', fallback: '600', min: 0, policy: 'sessionTtlSeconds', description: 'session retention after a subagent exits, before GC' },
|
|
50
|
+
{ name: 'PIER_FOCUS_POLL_MS', fallback: '1500', min: 0, description: 'pane-focus sampling cadence for the heat layout (0 disables; event-first on herdr 0.9.1+)' },
|
|
51
|
+
{ name: 'PIER_TERMINAL_PROMPT', legacy: 'PI_HERDR_TERMINAL_PROMPT', fallback: '(auto from $SHELL)', description: 'prompt strategy for terminal readiness: bash|zsh|powershell|pwsh' },
|
|
52
|
+
{ name: 'PIER_SLIM_FRAME', legacy: 'PI_HERDR_SLIM_FRAME', fallback: '1', description: 'mutation frame around todo-tool cards (0 disables)' },
|
|
53
|
+
{ name: 'PIER_TRACE', legacy: 'PI_HERDR_TRACE', fallback: '', description: 'write diagnostics (renderers, swallowed errors) to stderr (or to this file when it is a path)' },
|
|
54
|
+
{ name: 'PIER_TERM_IDLE_MS', legacy: 'PI_HERDR_TERM_IDLE_MS', fallback: '1800000', min: 1, description: 'idle time before pier nudges about an open terminal (0/NaN falls back to the default)' },
|
|
55
|
+
{ name: 'PIER_TERM_GRACE_MS', legacy: 'PI_HERDR_TERM_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the terminal idle nudge (0/NaN falls back to the default)' },
|
|
56
|
+
{ name: 'PIER_TERM_READ_MAX', legacy: 'PI_HERDR_TERM_READ_MAX', fallback: '8000', min: 1, description: 'characters returned per terminal read (0/NaN falls back to the default)' },
|
|
57
|
+
{ name: 'PIER_TODO_GRACE_MS', legacy: 'PI_HERDR_TODO_GRACE_MS', fallback: '30000', min: 1, description: 'settle grace before the unfinished-todo reminder (0/NaN falls back to the default)' },
|
|
58
|
+
{ name: 'PIER_HMR', legacy: 'PI_HERDR_HMR', fallback: '', description: 'dev: enable the cordis HMR boundary (requires --expose-internals)' },
|
|
59
|
+
{ name: 'PIER_ISOLATE_SWEEP_ORPHANS', legacy: 'PI_HERDR_ISOLATE_SWEEP_ORPHANS', fallback: '', description: 'opt-in sweeping of isolate worktrees this session never registered' },
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
/** Canonical name → legacy alias → fallback; an empty string counts as unset for both. */
|
|
63
|
+
export function pierOption(
|
|
64
|
+
name: string,
|
|
65
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
66
|
+
): string | undefined {
|
|
67
|
+
const spec = PIER_OPTIONS.find((o) => o.name === name);
|
|
68
|
+
for (const key of [name, spec?.legacy]) {
|
|
69
|
+
if (!key) continue;
|
|
70
|
+
const raw = env[key];
|
|
71
|
+
if (typeof raw === 'string' && raw.trim() !== '') return raw;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function pierOptionRows(env: NodeJS.ProcessEnv = process.env): Array<{
|
|
77
|
+
name: string;
|
|
78
|
+
value: string;
|
|
79
|
+
source: 'env' | 'legacy-env' | 'default';
|
|
80
|
+
description: string;
|
|
81
|
+
}> {
|
|
82
|
+
return PIER_OPTIONS.map((spec) => {
|
|
83
|
+
const own = typeof env[spec.name] === 'string' && env[spec.name]!.trim() !== '';
|
|
84
|
+
const legacyRaw = spec.legacy ? env[spec.legacy] : undefined;
|
|
85
|
+
const viaLegacy = typeof legacyRaw === 'string' && legacyRaw.trim() !== '';
|
|
86
|
+
if (own || viaLegacy) {
|
|
87
|
+
return { name: spec.name, value: String(own ? env[spec.name] : legacyRaw), source: own ? 'env' : 'legacy-env', description: spec.description };
|
|
88
|
+
}
|
|
89
|
+
return { name: spec.name, value: spec.fallback, source: 'default' as const, description: spec.description };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `/pier-config doctor` table, one line per option: `NAME = value (source) — description`; legacy env
|
|
94
|
+
* names are called out so a stale export is visible. */
|
|
95
|
+
export function formatOptionRows(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
96
|
+
return pierOptionRows(env).map((row) => {
|
|
97
|
+
const source = row.source === 'default' ? 'default' : row.source === 'env' ? 'env' : 'env (legacy name)';
|
|
98
|
+
return ` ${row.name} = ${row.value} (${source}) — ${row.description}`;
|
|
99
|
+
});
|
|
100
|
+
}
|
package/src/pipe-channel.ts
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* M11 separates the human/machine channels (D45–D47) by having the pi extension
|
|
3
|
-
*
|
|
2
|
+
* M11 separates the human/machine channels (D45–D47) by having the pi extension create its own
|
|
3
|
+
* Windows named pipe.
|
|
4
4
|
*
|
|
5
5
|
* - Controller (client): one connection per request, matching the herdr client;
|
|
6
|
-
* - Child pane (server): when PI_HERDR_SUBAGENT=1, the extension starts a server at
|
|
7
|
-
*
|
|
8
|
-
* - Completion data is not sent back; authoritative state lives in herdr, session JSONL,
|
|
9
|
-
*
|
|
6
|
+
* - Child pane (server): when PI_HERDR_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
10
|
*
|
|
11
|
-
*
|
|
12
|
-
* performs pi injection in the server callback.
|
|
11
|
+
* Only pure transport-layer logic here; index.ts performs pi injection in the server callback.
|
|
13
12
|
*/
|
|
14
13
|
import * as net from 'node:net';
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
15
|
import { sessionDirCandidates, sessionDirName } from './storage-layout.ts';
|
|
16
16
|
|
|
17
17
|
function encodePaneId(paneId: string): string {
|
|
@@ -51,6 +51,13 @@ export type PipeRequest =
|
|
|
51
51
|
steer?: boolean;
|
|
52
52
|
}
|
|
53
53
|
| { type: 'interrupt'; id: string }
|
|
54
|
+
| {
|
|
55
|
+
/** P0 (RFC §4.4): master switches a worker's role; the worker applies the manifest swap and
|
|
56
|
+
* replies ok/error. Mixed-version peers answer `unknown type role`, which the master surfaces. */
|
|
57
|
+
type: 'role';
|
|
58
|
+
id: string;
|
|
59
|
+
role: string;
|
|
60
|
+
}
|
|
54
61
|
| {
|
|
55
62
|
/** D50: fast-path settlement reply from child to requester, with summary and session path for progressive disclosure. */
|
|
56
63
|
type: 'reply';
|
|
@@ -64,23 +71,25 @@ export type PipeResponse =
|
|
|
64
71
|
| { type: 'ok'; id: string; detail?: string }
|
|
65
72
|
| { type: 'error'; id: string; message: string };
|
|
66
73
|
|
|
67
|
-
/**
|
|
68
|
-
|
|
74
|
+
/** Response side of the protocol (`ok` / `error`): requests never use these tags, so this is a sound
|
|
75
|
+
* discriminator when narrowing a parsed line at the answering end. */
|
|
76
|
+
function isPipeResponse(value: PipeRequest | PipeResponse): value is PipeResponse {
|
|
77
|
+
return value.type === 'ok' || value.type === 'error';
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePipeLine(line: string): PipeRequest | PipeResponse | null {
|
|
69
81
|
const t = (line ?? '').trim();
|
|
70
82
|
if (!t) return null;
|
|
71
83
|
try {
|
|
72
84
|
const obj = JSON.parse(t);
|
|
73
85
|
if (obj && typeof obj === 'object' && typeof obj.type === 'string') return obj as PipeRequest | PipeResponse;
|
|
74
86
|
} catch {
|
|
75
|
-
/* Malformed line. */
|
|
76
87
|
}
|
|
77
88
|
return null;
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
/**
|
|
81
|
-
*
|
|
82
|
-
* Timeout / connect failure / bad frame throw (caller retries).
|
|
83
|
-
*/
|
|
91
|
+
/** One connection, one request: connect → write JSON line → wait for JSON line → close. Timeout /
|
|
92
|
+
* connect failure / bad frame throw (caller retries). */
|
|
84
93
|
export function pipeRequest(
|
|
85
94
|
pipeName: string,
|
|
86
95
|
payload: PipeRequest,
|
|
@@ -110,7 +119,7 @@ export function pipeRequest(
|
|
|
110
119
|
settled = true;
|
|
111
120
|
sock.destroy();
|
|
112
121
|
const parsed = parsePipeLine(buf.slice(0, i));
|
|
113
|
-
if (!parsed) {
|
|
122
|
+
if (!parsed || !isPipeResponse(parsed)) {
|
|
114
123
|
reject(new Error(`pipe ${pipeName}: bad response frame`));
|
|
115
124
|
return;
|
|
116
125
|
}
|
|
@@ -123,12 +132,16 @@ export function pipeRequest(
|
|
|
123
132
|
reject(err);
|
|
124
133
|
}
|
|
125
134
|
});
|
|
135
|
+
sock.on('close', () => {
|
|
136
|
+
if (!settled) {
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(timer);
|
|
139
|
+
reject(new Error(`pipe ${pipeName}: connection closed`));
|
|
140
|
+
}
|
|
141
|
+
});
|
|
126
142
|
});
|
|
127
143
|
}
|
|
128
144
|
|
|
129
|
-
/**
|
|
130
|
-
* Try new then legacy pipe names so a new client can still reach an old server.
|
|
131
|
-
*/
|
|
132
145
|
export async function pipeRequestTo(
|
|
133
146
|
cwd: string,
|
|
134
147
|
paneId: string,
|
|
@@ -147,38 +160,25 @@ export async function pipeRequestTo(
|
|
|
147
160
|
throw lastErr instanceof Error ? lastErr : new Error(`pipe ${names[0] ?? paneId}: unreachable`);
|
|
148
161
|
}
|
|
149
162
|
|
|
150
|
-
|
|
151
|
-
export async function pingUntilReady(
|
|
152
|
-
pipeName: string | readonly string[],
|
|
153
|
-
timeoutMs: number,
|
|
154
|
-
intervalMs = 1000,
|
|
155
|
-
): Promise<boolean> {
|
|
156
|
-
const names = typeof pipeName === 'string' ? [pipeName] : pipeName;
|
|
157
|
-
const deadline = Date.now() + timeoutMs;
|
|
158
|
-
while (Date.now() < deadline) {
|
|
159
|
-
for (const name of names) {
|
|
160
|
-
try {
|
|
161
|
-
const res = await pipeRequest(name, { type: 'ping', id: `ping-${Date.now()}` }, 3000);
|
|
162
|
-
if (res.type === 'ok') return true;
|
|
163
|
-
} catch {
|
|
164
|
-
/* not ready on this name */
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
await new Promise((r) => setTimeout(r, intervalMs));
|
|
168
|
-
}
|
|
169
|
-
return false;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
export type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
|
|
163
|
+
type PipeMessageHandler = (req: PipeRequest) => Promise<PipeResponse>;
|
|
173
164
|
|
|
174
|
-
/**
|
|
175
|
-
* Server: one line in → handler → one line out → close.
|
|
176
|
-
* Returns the server (caller closes it).
|
|
177
|
-
*/
|
|
165
|
+
/** Server: one line in → handler → one line out → close. Returns the server (caller closes it). */
|
|
178
166
|
export function startPipeServer(
|
|
179
167
|
pipeName: string,
|
|
180
168
|
handler: PipeMessageHandler,
|
|
169
|
+
onError?: (err: Error) => void,
|
|
181
170
|
): net.Server {
|
|
171
|
+
const socketPath = pipePathFor(pipeName);
|
|
172
|
+
// POSIX leaves the UNIX domain socket behind after a crash, and the next listen fails with
|
|
173
|
+
// EADDRINUSE unless it is unlinked before bind (Windows named pipes need no unlink). Best-effort:
|
|
174
|
+
// if unlink fails, listen reports the real error through onError.
|
|
175
|
+
if (process.platform !== 'win32') {
|
|
176
|
+
try {
|
|
177
|
+
fs.unlinkSync(socketPath);
|
|
178
|
+
} catch {
|
|
179
|
+
/* see above: listen surfaces the actionable error */
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
182
|
const server = net.createServer((sock) => {
|
|
183
183
|
sock.setEncoding('utf8');
|
|
184
184
|
let buf = '';
|
|
@@ -188,11 +188,12 @@ export function startPipeServer(
|
|
|
188
188
|
if (i < 0) return;
|
|
189
189
|
const line = buf.slice(0, i);
|
|
190
190
|
buf = buf.slice(i + 1);
|
|
191
|
-
const
|
|
192
|
-
if (!
|
|
191
|
+
const parsedLine = parsePipeLine(line);
|
|
192
|
+
if (!parsedLine || isPipeResponse(parsedLine)) {
|
|
193
193
|
sock.end(JSON.stringify({ type: 'error', id: '', message: 'bad frame' }) + '\n');
|
|
194
194
|
return;
|
|
195
195
|
}
|
|
196
|
+
const req: PipeRequest = parsedLine;
|
|
196
197
|
void handler(req)
|
|
197
198
|
.then((res) => {
|
|
198
199
|
sock.end(JSON.stringify(res) + '\n');
|
|
@@ -209,9 +210,21 @@ export function startPipeServer(
|
|
|
209
210
|
/* client disconnect: silent */
|
|
210
211
|
});
|
|
211
212
|
});
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
}
|
|
215
|
-
|
|
213
|
+
if (onError) {
|
|
214
|
+
server.on('error', onError);
|
|
215
|
+
} else {
|
|
216
|
+
// Always attach a listener: an EventEmitter 'error' with no listener throws and would take the
|
|
217
|
+
// whole extension host down on a transient listen failure; pass onError to observe it (F04).
|
|
218
|
+
server.on('error', () => { /* swallowed by design; pass onError to observe */ });
|
|
219
|
+
}
|
|
220
|
+
if (process.platform !== 'win32') {
|
|
221
|
+
server.on('close', () => {
|
|
222
|
+
try {
|
|
223
|
+
fs.unlinkSync(socketPath);
|
|
224
|
+
} catch {
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
server.listen(socketPath);
|
|
216
229
|
return server;
|
|
217
230
|
}
|
package/src/platform-paths.ts
CHANGED
|
@@ -2,71 +2,38 @@ import { homedir } from 'node:os'
|
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* Why: Centralizes platform-specific path logic to support Windows (LOCALAPPDATA),
|
|
8
|
-
* Unix XDG conventions, and testability through injection.
|
|
5
|
+
* Windows uses LOCALAPPDATA, Linux XDG, macOS keeps `~/.pi` (with XDG override); herdr keeps its
|
|
6
|
+
* worktrees next to the data dir on Windows/Linux but under `~/.herdr` on macOS. Injectable for tests.
|
|
9
7
|
*/
|
|
10
|
-
|
|
11
8
|
export interface PlatformPaths {
|
|
12
|
-
/** Base directory for agent sessions and state */
|
|
13
9
|
readonly agentDataDir: string
|
|
14
|
-
/** Base directory for worktrees */
|
|
15
10
|
readonly worktreeBaseDir: string
|
|
16
|
-
/** Sessions subdirectory under agent data */
|
|
17
11
|
readonly sessionsDir: string
|
|
18
12
|
}
|
|
19
13
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (platform === 'linux') {
|
|
30
|
-
return process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// macOS: use ~/.local/share for consistency, but .pi is acceptable legacy
|
|
34
|
-
return process.env.XDG_DATA_HOME || join(homedir(), '.pi')
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function getDefaultWorktreeDir(): string {
|
|
38
|
-
const platform = process.platform
|
|
39
|
-
|
|
40
|
-
if (platform === 'win32') {
|
|
41
|
-
const localAppData = process.env.LOCALAPPDATA || join(homedir(), 'AppData', 'Local')
|
|
42
|
-
return join(localAppData, 'herdr', 'worktrees')
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Unix: use XDG_DATA_HOME or ~/.local/share
|
|
46
|
-
if (platform === 'linux') {
|
|
47
|
-
const dataHome = process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share')
|
|
48
|
-
return join(dataHome, 'herdr', 'worktrees')
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// macOS legacy: ~/.herdr/worktrees
|
|
52
|
-
return join(homedir(), '.herdr', 'worktrees')
|
|
14
|
+
const PLATFORM_DEFAULTS: Record<string, {
|
|
15
|
+
readonly envVar: string
|
|
16
|
+
readonly homeFallback: readonly string[]
|
|
17
|
+
/** macOS keeps worktrees in ~/.herdr instead of under the data dir. */
|
|
18
|
+
readonly worktreesUnderData: boolean
|
|
19
|
+
}> = {
|
|
20
|
+
win32: { envVar: 'LOCALAPPDATA', homeFallback: ['AppData', 'Local'], worktreesUnderData: true },
|
|
21
|
+
linux: { envVar: 'XDG_DATA_HOME', homeFallback: ['.local', 'share'], worktreesUnderData: true },
|
|
22
|
+
darwin: { envVar: 'XDG_DATA_HOME', homeFallback: ['.pi'], worktreesUnderData: false },
|
|
53
23
|
}
|
|
54
24
|
|
|
55
25
|
export function createPlatformPaths(overrides?: Partial<PlatformPaths>): PlatformPaths {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
26
|
+
const layout = PLATFORM_DEFAULTS[process.platform] ?? PLATFORM_DEFAULTS.darwin!
|
|
27
|
+
const dataDir = process.env[layout.envVar] || join(homedir(), ...layout.homeFallback)
|
|
28
|
+
const agentDataDir = overrides?.agentDataDir || join(dataDir, 'agent')
|
|
29
|
+
|
|
61
30
|
return {
|
|
62
31
|
agentDataDir,
|
|
63
|
-
sessionsDir,
|
|
64
|
-
worktreeBaseDir
|
|
32
|
+
sessionsDir: overrides?.sessionsDir || join(agentDataDir, 'sessions'),
|
|
33
|
+
worktreeBaseDir: overrides?.worktreeBaseDir
|
|
34
|
+
|| (layout.worktreesUnderData ? join(dataDir, 'herdr', 'worktrees') : join(homedir(), '.herdr', 'worktrees')),
|
|
65
35
|
}
|
|
66
36
|
}
|
|
67
37
|
|
|
68
|
-
/**
|
|
69
|
-
* Singleton platform paths for production use.
|
|
70
|
-
* Tests should inject via createPlatformPaths().
|
|
71
|
-
*/
|
|
38
|
+
/** Singleton for production; tests inject via createPlatformPaths(). */
|
|
72
39
|
export const platformPaths = createPlatformPaths()
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* D97: Always split the largest cell downward so spawned panes remain full-width strips.
|
|
3
3
|
*
|
|
4
|
-
* Heat reflow narrows unfocused cells, but full-width strips keep the slim-frame title
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* new strips cross the row limit, while existing columns cross the column limit.
|
|
4
|
+
* Heat reflow narrows unfocused cells, but full-width strips keep the slim-frame title readable on
|
|
5
|
+
* one line. The overlay threshold complements both layouts: new strips cross the row limit, while
|
|
6
|
+
* existing columns cross the column limit.
|
|
8
7
|
*
|
|
9
|
-
* herdr 0.8.2 defines ratio as the first child's share. Because layout.apply creates a
|
|
10
|
-
*
|
|
11
|
-
*
|
|
8
|
+
* herdr 0.8.2 defines ratio as the first child's share. Because layout.apply creates a new shell
|
|
9
|
+
* rather than attaching an existing pane, spawn-time incremental splits must establish this
|
|
10
|
+
* topology; heat reflow owns only later sizing.
|
|
12
11
|
*/
|
|
13
12
|
|
|
14
13
|
export type ShapeNode =
|
|
@@ -71,15 +70,16 @@ export function paneCells(root: ShapeNode, width = 200, height = 50): PaneCell[]
|
|
|
71
70
|
}
|
|
72
71
|
|
|
73
72
|
/**
|
|
74
|
-
* Split the largest eligible cell downward; preorder tie-breaking keeps the choice stable
|
|
75
|
-
*
|
|
76
|
-
* Exclusions protect resident non-agent panes such as the board from worker splits.
|
|
73
|
+
* Split the largest eligible cell downward; preorder tie-breaking keeps the choice stable, and exclusions
|
|
74
|
+
* protect resident non-agent panes such as the board from worker splits.
|
|
77
75
|
*/
|
|
78
76
|
export function pickGridSplit(
|
|
79
77
|
root: ShapeNode,
|
|
80
|
-
opts: { exclude?: ReadonlySet<string>; width?: number; height?: number } = {},
|
|
78
|
+
opts: { exclude?: ReadonlySet<string>; width?: number; height?: number; cells?: PaneCell[] } = {},
|
|
81
79
|
): { targetPaneId: string; direction: 'right' | 'down' } | null {
|
|
82
|
-
const cells =
|
|
80
|
+
const cells = (opts.cells && opts.cells.length > 0
|
|
81
|
+
? opts.cells
|
|
82
|
+
: paneCells(root, opts.width ?? 200, opts.height ?? 50))
|
|
83
83
|
.filter((c) => !opts.exclude?.has(c.id));
|
|
84
84
|
if (cells.length === 0) return null;
|
|
85
85
|
let best = cells[0];
|