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.
- package/README.md +60 -2
- package/package.json +58 -4
- package/schemas/role-manifest.schema.json +62 -0
- package/skills/piher-tools/SKILL.md +212 -0
- package/src/ansi-text.ts +143 -0
- package/src/bg-job-core.ts +412 -0
- package/src/bootstrap.ts +102 -0
- package/src/config-catalog-core.ts +200 -0
- package/src/config-command.ts +324 -0
- package/src/cordis-services.ts +38 -0
- package/src/core-client.ts +715 -0
- package/src/delegation-core.ts +94 -0
- package/src/gc-core.ts +150 -0
- package/src/guards.ts +6 -0
- package/src/hc-templates.ts +134 -0
- package/src/index-locks.ts +118 -0
- package/src/index-master.ts +92 -0
- package/src/index-notices.ts +79 -0
- package/src/index-pipe.ts +71 -0
- package/src/index-roles.ts +265 -0
- package/src/index-runtime.ts +23 -0
- package/src/index.ts +460 -0
- package/src/ledger.ts +61 -0
- package/src/lock-core.ts +200 -0
- package/src/manifest-compose.ts +103 -0
- package/src/options.ts +78 -0
- package/src/pi-surface.ts +140 -0
- package/src/pipe-channel.ts +291 -0
- package/src/platform-paths.ts +37 -0
- package/src/plugins/bg-job.ts +560 -0
- package/src/plugins/subagent.ts +1244 -0
- package/src/prompt-store.ts +50 -0
- package/src/renderers.ts +195 -0
- package/src/role-catalog.ts +99 -0
- package/src/role-loader.ts +196 -0
- package/src/role-manifest.ts +163 -0
- package/src/role-state.ts +119 -0
- package/src/roles/master.json +23 -0
- package/src/roles/worker-default.json +23 -0
- package/src/runtime-policy.ts +45 -0
- package/src/session-state.ts +19 -0
- package/src/session-tail.ts +276 -0
- package/src/settle-wake-core.ts +73 -0
- package/src/settlement-card.ts +96 -0
- package/src/storage-layout.ts +92 -0
- package/src/subagent-core.ts +543 -0
- package/src/subagent-gc.ts +312 -0
- package/src/subagent-poller.ts +484 -0
- package/src/subagent-session.ts +548 -0
- package/src/subagent-spawn.ts +695 -0
- package/src/swallow.ts +59 -0
- package/src/task-row.ts +257 -0
- package/src/task-store.ts +173 -0
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +99 -0
- package/src/tree-view.ts +58 -0
- package/src/vocab.ts +41 -0
- package/src/worker-marker.ts +65 -0
- package/src/worktree-io.ts +177 -0
- package/src/worktree.ts +280 -0
package/src/lock-core.ts
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M18 file-level write locks (S2: soft veto by default, `PIHER_WRITE_LOCK=1` makes it hard).
|
|
3
|
+
*
|
|
4
|
+
* A lock is a *beacon*, not a mutex: the terminal editing a file sets a label on itself
|
|
5
|
+
* (`piher.lock.<hash>` → `terminalId|path`) and every other terminal reads the live terminals' labels
|
|
6
|
+
* to warn or block. Each terminal writes only its own labels, so beacons never race; a finished
|
|
7
|
+
* terminal drops out of `terminal.list`, so its beacons expire with it.
|
|
8
|
+
*/
|
|
9
|
+
import { resolve, sep } from 'node:path';
|
|
10
|
+
|
|
11
|
+
export const WRITE_LOCK_ENV = 'PIHER_WRITE_LOCK';
|
|
12
|
+
export const WRITE_TOOLS = ['write', 'edit'] as const;
|
|
13
|
+
export const LOCK_TOKEN_PREFIX = 'piher.lock.';
|
|
14
|
+
|
|
15
|
+
export interface LockAgentView {
|
|
16
|
+
paneId: string;
|
|
17
|
+
tokens: Record<string, string | null>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Case-folded, separator-unified absolute path: the key every lock lookup agrees on. */
|
|
21
|
+
export function normalizeLockPath(p: string, cwd: string): string {
|
|
22
|
+
const abs = resolve(cwd, p);
|
|
23
|
+
const unified = sep === '\\' ? abs.replace(/\\/g, '/') : abs;
|
|
24
|
+
return unified.toLowerCase().replace(/\/+$/, '');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function fnv1a64(s: string): string {
|
|
28
|
+
let h = 0xcbf29ce484222325n;
|
|
29
|
+
for (let i = 0; i < s.length; i++) {
|
|
30
|
+
h ^= BigInt(s.charCodeAt(i));
|
|
31
|
+
h = (h * 0x100000001b3n) & 0xffffffffffffffffn;
|
|
32
|
+
}
|
|
33
|
+
return h.toString(16).padStart(16, '0');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function lockTokenKey(normPath: string): string {
|
|
37
|
+
return `${LOCK_TOKEN_PREFIX}${fnv1a64(normPath)}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function lockTokenValue(normPath: string, paneId: string): string {
|
|
41
|
+
return `${paneId}|${normPath}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function parseLockTokenValue(v: string): { holderPaneId: string; path: string } | null {
|
|
45
|
+
const i = v.indexOf('|');
|
|
46
|
+
if (i <= 0 || i === v.length - 1) return null;
|
|
47
|
+
return { holderPaneId: v.slice(0, i), path: v.slice(i + 1) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function isLockTokenKey(key: string): boolean {
|
|
51
|
+
return key.startsWith(LOCK_TOKEN_PREFIX) && key.length > LOCK_TOKEN_PREFIX.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function writePathsOfTool(toolName: string, input: unknown): string[] {
|
|
55
|
+
if (!(WRITE_TOOLS as readonly string[]).includes(toolName)) return [];
|
|
56
|
+
const p = (input as { path?: unknown } | null | undefined)?.path;
|
|
57
|
+
return typeof p === 'string' && p.trim() ? [p] : [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* B7: write-locks only see the write/edit tools, so a `bash` command that redirects into a file bypasses
|
|
62
|
+
* the beacon entirely (`> file`, `>> file`, `tee file`, `sed -i`, `truncate -s 0`). We do not try to parse
|
|
63
|
+
* shell: this extracts the *obvious* targets so the lock layer can warn (never block) — a false negative is
|
|
64
|
+
* cheap, a false block is not.
|
|
65
|
+
*/
|
|
66
|
+
export function bashWriteTargets(command: unknown): string[] {
|
|
67
|
+
if (typeof command !== 'string' || command.trim() === '') return [];
|
|
68
|
+
const found: string[] = [];
|
|
69
|
+
// Quoted or bare targets; stops at shell metacharacters, ignores process substitution.
|
|
70
|
+
const target = String.raw`(?:'([^']+)'|"([^"]+)"|([^\s;|&<>()]+))`;
|
|
71
|
+
const patterns = [
|
|
72
|
+
new RegExp(String.raw`(?:^|[^0-9>])>>?\s*` + target, 'g'), // > file, >> file
|
|
73
|
+
new RegExp(String.raw`\btee\s+(?:-a\s+)?` + target, 'g'), // tee [-a] file
|
|
74
|
+
new RegExp(String.raw`\btruncate\s+-s\s*0\s+` + target, 'g'), // truncate -s 0 file
|
|
75
|
+
];
|
|
76
|
+
for (const re of patterns) {
|
|
77
|
+
for (const m of command.matchAll(re)) {
|
|
78
|
+
const path = (m[1] ?? m[2] ?? m[3] ?? '').trim();
|
|
79
|
+
const quoted = m[1] !== undefined || m[2] !== undefined;
|
|
80
|
+
if (isObviousPath(path, quoted)) found.push(path);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
// `sed -i '' 's/a/b/' file`: the first argument is the (possibly empty) backup suffix and the
|
|
84
|
+
// second the expression, so the LAST argument is the file.
|
|
85
|
+
for (const m of command.matchAll(/\bsed\s+-i[^\s]*\s+([^|;&\n]+)/g)) {
|
|
86
|
+
const args = splitShellWords(m[1] ?? '');
|
|
87
|
+
for (let i = args.length - 1; i >= 0; i -= 1) {
|
|
88
|
+
const candidate = args[i]!;
|
|
89
|
+
if (candidate.startsWith('-')) continue;
|
|
90
|
+
if (isObviousPath(candidate)) found.push(candidate);
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return [...new Set(found)];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Only unquoted, metacharacter-free paths count as "obvious" (anything else is left to the shell). */
|
|
98
|
+
function isObviousPath(path: string, quoted = false): boolean {
|
|
99
|
+
if (path === '' || path === '/' || path.startsWith('$')) return false;
|
|
100
|
+
if (/['"`$*?{}()[\]]/.test(path)) return false; // Never a literal path (expansion, glob, nested quotes).
|
|
101
|
+
return quoted ? true : !/\s/.test(path); // An explicitly quoted target may contain spaces.
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Whitespace split that keeps quoted segments together and strips their quotes. */
|
|
105
|
+
function splitShellWords(input: string): string[] {
|
|
106
|
+
const out: string[] = [];
|
|
107
|
+
for (const m of input.matchAll(/'([^']*)'|"([^"]*)"|(\S+)/g)) out.push(m[1] ?? m[2] ?? m[3] ?? '');
|
|
108
|
+
return out.filter((s) => s !== '');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Soft hint shown after a bash call that wrote into a path another pane has locked. */
|
|
112
|
+
function formatBashLockHint(normPath: string, holders: readonly string[]): string {
|
|
113
|
+
return `Note: this bash command wrote to ${normPath}, which ${holdersLabel(holders)} is editing. `
|
|
114
|
+
+ `Write-locks only cover the write/edit tools, so this write was not blocked: re-read the file before your next edit.`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Every *other* pane holding a beacon for this path (deduped, in list order — several panes can hold
|
|
118
|
+
* the same path, so callers get the whole list). */
|
|
119
|
+
export function findLockHolders(
|
|
120
|
+
agents: readonly LockAgentView[],
|
|
121
|
+
ownPaneId: string,
|
|
122
|
+
normPath: string,
|
|
123
|
+
): string[] {
|
|
124
|
+
const key = lockTokenKey(normPath);
|
|
125
|
+
const holders: string[] = [];
|
|
126
|
+
for (const a of agents) {
|
|
127
|
+
const v = a.tokens[key];
|
|
128
|
+
if (typeof v !== 'string' || !v) continue;
|
|
129
|
+
const parsed = parseLockTokenValue(v);
|
|
130
|
+
if (parsed && parsed.holderPaneId !== ownPaneId && !holders.includes(parsed.holderPaneId)) {
|
|
131
|
+
holders.push(parsed.holderPaneId);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return holders;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Where to look for the full holder table: humans use the slash command, agents read core labels. */
|
|
138
|
+
const LOCK_HOLDERS_HINT =
|
|
139
|
+
'holders: /piher-locks (human view in this pane) · `hc ls` → labels piher.lock.* (agent-readable)';
|
|
140
|
+
|
|
141
|
+
function holdersLabel(holders: readonly string[]): string {
|
|
142
|
+
return holders.length === 1 ? `pane ${holders[0]}` : `panes ${holders.join(', ')}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function formatConflictWarning(normPath: string, holders: readonly string[]): string {
|
|
146
|
+
return `⚠️ write conflict: ${normPath} is locked by ${holdersLabel(holders)} (edited in another pane that has not settled). This write went through (soft mode) — coordinate to avoid clobbering each other's changes. ${LOCK_HOLDERS_HINT}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type WriteGuardPlan =
|
|
150
|
+
| { kind: 'skip' }
|
|
151
|
+
| { kind: 'pass'; paths: string[] }
|
|
152
|
+
| { kind: 'warn'; paths: string[]; holderPaneIds: string[]; warning: string }
|
|
153
|
+
| { kind: 'block'; paths: string[]; holderPaneIds: string[]; reason: string };
|
|
154
|
+
|
|
155
|
+
export function planWriteGuard(opts: {
|
|
156
|
+
toolName: string;
|
|
157
|
+
input: unknown;
|
|
158
|
+
agents: readonly LockAgentView[];
|
|
159
|
+
ownPaneId: string;
|
|
160
|
+
cwd: string;
|
|
161
|
+
hard: boolean;
|
|
162
|
+
}): WriteGuardPlan {
|
|
163
|
+
const isBash = opts.toolName === 'bash';
|
|
164
|
+
const raw = isBash
|
|
165
|
+
? bashWriteTargets((opts.input as { command?: unknown } | null | undefined)?.command)
|
|
166
|
+
: writePathsOfTool(opts.toolName, opts.input);
|
|
167
|
+
if (raw.length === 0) return { kind: 'skip' };
|
|
168
|
+
const paths = raw.map((p) => normalizeLockPath(p, opts.cwd));
|
|
169
|
+
for (const norm of paths) {
|
|
170
|
+
const holders = findLockHolders(opts.agents, opts.ownPaneId, norm);
|
|
171
|
+
if (holders.length === 0) continue;
|
|
172
|
+
if (opts.hard && !isBash) {
|
|
173
|
+
return {
|
|
174
|
+
kind: 'block',
|
|
175
|
+
paths,
|
|
176
|
+
holderPaneIds: holders,
|
|
177
|
+
reason: `file locked by ${holdersLabel(holders)}: ${norm} is being edited in another pane. Wait for it to settle, or coordinate before writing. ${LOCK_HOLDERS_HINT}`,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
kind: 'warn',
|
|
182
|
+
paths,
|
|
183
|
+
holderPaneIds: holders,
|
|
184
|
+
warning: isBash ? formatBashLockHint(norm, holders) : formatConflictWarning(norm, holders),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
return { kind: 'pass', paths };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function acquireTokensFor(normPaths: readonly string[], paneId: string): Record<string, string> {
|
|
191
|
+
const out: Record<string, string> = {};
|
|
192
|
+
for (const p of normPaths) out[lockTokenKey(p)] = lockTokenValue(p, paneId);
|
|
193
|
+
return out;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function releaseTokensFor(normPaths: readonly string[]): Record<string, null> {
|
|
197
|
+
const out: Record<string, null> = {};
|
|
198
|
+
for (const p of normPaths) out[lockTokenKey(p)] = null;
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Manifest composition (C7 v2 three-state permissions): role baseline ∪ model suggestion,
|
|
3
|
+
* minus deny rules, with the D82 unknown-tools stance carried through.
|
|
4
|
+
*/
|
|
5
|
+
import type { PermissionAction, RoleManifest, UnknownToolStance } from './role-manifest.ts';
|
|
6
|
+
import type { RuntimeRoleManifest } from './tool-gate.ts';
|
|
7
|
+
import { loadRoleConfig } from './role-loader.ts';
|
|
8
|
+
|
|
9
|
+
export interface ManifestSources {
|
|
10
|
+
roleBaseline: readonly string[];
|
|
11
|
+
modelSuggested: readonly string[];
|
|
12
|
+
rulePermissions: Record<string, PermissionAction>;
|
|
13
|
+
/** D82: stance for tools outside the composed set. */
|
|
14
|
+
unknownTools?: UnknownToolStance;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** What composeManifest produces: the runtime manifest minus the identity/section fields. */
|
|
18
|
+
export type ComposedManifest = Omit<RuntimeRoleManifest, 'role' | 'version' | 'guidelines' | 'instructions'>;
|
|
19
|
+
|
|
20
|
+
export class ManifestError extends Error {
|
|
21
|
+
readonly code: 'EMPTY_MANIFEST' | 'INVALID_ROLE_CONFIG';
|
|
22
|
+
constructor(code: 'EMPTY_MANIFEST' | 'INVALID_ROLE_CONFIG', message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'ManifestError';
|
|
25
|
+
this.code = code;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface ComposedForRole {
|
|
30
|
+
role: RoleManifest;
|
|
31
|
+
manifest: ComposedManifest;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function composeManifest(sources: ManifestSources): ComposedManifest {
|
|
35
|
+
const baseline = sources.roleBaseline.filter((t): t is string => typeof t === 'string' && t.trim() !== '');
|
|
36
|
+
if (baseline.length === 0) {
|
|
37
|
+
throw new ManifestError(
|
|
38
|
+
'INVALID_ROLE_CONFIG',
|
|
39
|
+
'INVALID_ROLE_CONFIG: baseline tools must not be empty',
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
const candidates = new Set<string>([
|
|
43
|
+
...baseline,
|
|
44
|
+
...sources.modelSuggested.filter((t): t is string => typeof t === 'string' && t.trim() !== ''),
|
|
45
|
+
]);
|
|
46
|
+
const rules = sources.rulePermissions ?? {};
|
|
47
|
+
const defaultAction: PermissionAction = rules['*'] ?? 'allow';
|
|
48
|
+
|
|
49
|
+
const tools: string[] = [];
|
|
50
|
+
const permissions: Record<string, PermissionAction> = {};
|
|
51
|
+
// Rule keys outside the candidate set stay in permissions: an allow-stance role keeps its
|
|
52
|
+
// deny rules for tools the model never suggested (D82 excluded families).
|
|
53
|
+
for (const [key, action] of Object.entries(rules)) {
|
|
54
|
+
if (key !== '*') permissions[key] = action;
|
|
55
|
+
}
|
|
56
|
+
for (const tool of candidates) {
|
|
57
|
+
const action = rules[tool] ?? defaultAction;
|
|
58
|
+
permissions[tool] = action;
|
|
59
|
+
if (action !== 'deny') tools.push(tool);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (tools.length === 0) {
|
|
63
|
+
throw new ManifestError(
|
|
64
|
+
'EMPTY_MANIFEST',
|
|
65
|
+
`Trimmed manifest is empty (deny-all trap?). Baseline: ${baseline.join(', ') || '(empty)'}; ` +
|
|
66
|
+
`model suggestions: ${sources.modelSuggested.join(', ') || '(none)'}; ` +
|
|
67
|
+
`rules: ${JSON.stringify(rules)}`,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
tools.sort();
|
|
72
|
+
return { tools, permissions, unknownTools: sources.unknownTools ?? 'deny' };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function composeForRole(
|
|
76
|
+
roleName: string,
|
|
77
|
+
modelSuggested: readonly string[],
|
|
78
|
+
opts?: { loadRole?: typeof loadRoleConfig; loadRoleOpts?: Parameters<typeof loadRoleConfig>[1] },
|
|
79
|
+
): ComposedForRole {
|
|
80
|
+
const load = opts?.loadRole ?? loadRoleConfig;
|
|
81
|
+
const role = load(roleName, opts?.loadRoleOpts);
|
|
82
|
+
const manifest = composeManifest({
|
|
83
|
+
roleBaseline: role.manifest.tools,
|
|
84
|
+
modelSuggested,
|
|
85
|
+
rulePermissions: role.manifest.rules ?? { '*': 'allow' },
|
|
86
|
+
unknownTools: role.manifest.unknownTools,
|
|
87
|
+
});
|
|
88
|
+
return { role, manifest };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Project a composed profile onto the runtime shape — the one place role-file fields become runtime fields. */
|
|
92
|
+
export function toRuntimeManifest(composed: ComposedForRole): RuntimeRoleManifest {
|
|
93
|
+
const { role, manifest } = composed;
|
|
94
|
+
return {
|
|
95
|
+
role: role.role,
|
|
96
|
+
version: role.version,
|
|
97
|
+
tools: manifest.tools,
|
|
98
|
+
permissions: manifest.permissions,
|
|
99
|
+
unknownTools: manifest.unknownTools,
|
|
100
|
+
...(role.guidelines?.length ? { guidelines: role.guidelines } : {}),
|
|
101
|
+
...(role.instructions ? { instructions: role.instructions } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/options.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Environment options. `PIHER_*` is the one namespace for *piher options* (timeouts,
|
|
3
|
+
* windows, toggles), and this file is their single registry.
|
|
4
|
+
*
|
|
5
|
+
* `PIHER_SUBAGENT`, `PIHER_DELEGATION`, `PIHER_ROLE_MANIFEST` and `PIHER_ROLE_BASE` are not
|
|
6
|
+
* listed here: they are *contracts* handed to the child process at spawn, not knobs a user sets.
|
|
7
|
+
*
|
|
8
|
+
* `optionValue()` is the single reader; this registry is also the single source of the numeric
|
|
9
|
+
* bounds/defaults behind `runtimePolicy` (runtime-policy.ts) and the `/piher-config` catalog
|
|
10
|
+
* (config-catalog-core.ts), so a knob cannot mean three different things.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** RuntimePolicy field fed by an integer option (see runtime-policy.ts). */
|
|
14
|
+
export type PolicyField =
|
|
15
|
+
| 'subagentTimeoutMs'
|
|
16
|
+
| 'gcTickMs'
|
|
17
|
+
| 'pollIntervalMs'
|
|
18
|
+
| 'settleWindowMs'
|
|
19
|
+
| 'sessionTtlSeconds'
|
|
20
|
+
| 'gitTimeoutMs'
|
|
21
|
+
| 'readinessTimeoutMs';
|
|
22
|
+
|
|
23
|
+
export interface OptionSpec {
|
|
24
|
+
readonly name: string;
|
|
25
|
+
/** Default when the variable is unset (or empty). */
|
|
26
|
+
readonly fallback: string;
|
|
27
|
+
readonly description: string;
|
|
28
|
+
/** Integer option: smallest value the readers accept (omitted for string/enum options). */
|
|
29
|
+
readonly min?: number;
|
|
30
|
+
/** Integer option read into `RuntimePolicy`. */
|
|
31
|
+
readonly policy?: PolicyField;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Every option, with its default and bounds. */
|
|
35
|
+
export const OPTION_SPECS: readonly OptionSpec[] = [
|
|
36
|
+
{ name: 'PIHER_GIT_TIMEOUT_MS', fallback: '10000', min: 1, policy: 'gitTimeoutMs', description: 'git command timeout (worktree list and status probes)' },
|
|
37
|
+
{ name: 'PIHER_SUBAGENT_TIMEOUT_MS', fallback: '300000', min: 1000, policy: 'subagentTimeoutMs', description: 'how long a subagent may stay silent before the master is told it never reported' },
|
|
38
|
+
{ name: 'PIHER_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)' },
|
|
39
|
+
{ name: 'PIHER_SETTLE_WINDOW_MS', fallback: '4000', min: 0, policy: 'settleWindowMs', description: 'debounce after a subagent turn ends before it is called settled (a new turn cancels it)' },
|
|
40
|
+
{ name: 'PIHER_POLL_INTERVAL_MS', fallback: '30000', min: 1000, policy: 'pollIntervalMs', description: 'safety re-check of a subagent when no core event arrives (events drive settlement)' },
|
|
41
|
+
{ name: 'PIHER_GC_TICK_MS', fallback: '30000', min: 1000, policy: 'gcTickMs', description: 'idle GC sweep cadence (panes)' },
|
|
42
|
+
{ name: 'PIHER_SESSION_TTL_SECONDS', fallback: '600', min: 0, policy: 'sessionTtlSeconds', description: 'session retention after a subagent exits, before GC' },
|
|
43
|
+
{ name: 'PIHER_WRITE_LOCK', fallback: '', description: '1 makes a conflicting write/edit block instead of warn (read by the lock layer)' },
|
|
44
|
+
{ name: 'PIHER_JOBS_DIR', fallback: '(system temp)/piher-jobs', description: 'directory receiving one log file per background job' },
|
|
45
|
+
{ name: 'PIHER_HMR', fallback: '', description: 'dev: enable the cordis HMR boundary (requires --expose-internals)' },
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** A knob's effective value, or undefined when it is unset (an empty string counts as unset). */
|
|
49
|
+
export function optionValue(
|
|
50
|
+
name: string,
|
|
51
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
52
|
+
): string | undefined {
|
|
53
|
+
const raw = env[name];
|
|
54
|
+
return typeof raw === 'string' && raw.trim() !== '' ? raw : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function optionRows(env: NodeJS.ProcessEnv = process.env): Array<{
|
|
58
|
+
name: string;
|
|
59
|
+
value: string;
|
|
60
|
+
source: 'env' | 'default';
|
|
61
|
+
description: string;
|
|
62
|
+
}> {
|
|
63
|
+
return OPTION_SPECS.map((spec) => {
|
|
64
|
+
const value = optionValue(spec.name, env);
|
|
65
|
+
return {
|
|
66
|
+
name: spec.name,
|
|
67
|
+
value: value ?? spec.fallback,
|
|
68
|
+
source: value === undefined ? 'default' : 'env',
|
|
69
|
+
description: spec.description,
|
|
70
|
+
} as const;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `/piher-config doctor` table, one line per option: `NAME = value (source) — description`. */
|
|
75
|
+
export function formatOptionRows(env: NodeJS.ProcessEnv = process.env): string[] {
|
|
76
|
+
return optionRows(env).map((row) =>
|
|
77
|
+
` ${row.name} = ${row.value} (${row.source}) — ${row.description}`);
|
|
78
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy the pi ExtensionAPI registration surface (D79).
|
|
3
|
+
*
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
import type { DisposeLedger } from './ledger.ts';
|
|
11
|
+
|
|
12
|
+
/** Minimal pi ToolResult shape used when a disposed tool is invoked. */
|
|
13
|
+
const INERT_TOOL_RESULT = {
|
|
14
|
+
content: [{ type: 'text' as const, text: 'Error: tool module disposed (hot-reloaded away); the new version has re-registered it.' }],
|
|
15
|
+
details: {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** Module-scoped surface: tombstone tools and events, while command replacement is safe because pi stores commands in a Map. */
|
|
19
|
+
export interface ScopedSurface {
|
|
20
|
+
registerTool(def: Record<string, unknown> & { name: string }): void;
|
|
21
|
+
registerCommand(name: string, options: Record<string, unknown>): void;
|
|
22
|
+
on(event: string, handler: (...args: unknown[]) => unknown): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface Group {
|
|
26
|
+
alive: boolean;
|
|
27
|
+
/** Monotonic mount generation used by the ledger disposer to determine its collection boundary. */
|
|
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;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Proxy for pi's registration surface (D79); non-registration methods (append/exec/setActiveTools, etc.) pass through raw. */
|
|
45
|
+
export class PiSurface<P extends object> {
|
|
46
|
+
private groups = new Map<string, Group>();
|
|
47
|
+
/** Generation history per key; mounting retires older generations so at most one stays alive until ledger collection. */
|
|
48
|
+
private generations = new Map<string, Group[]>();
|
|
49
|
+
private epochCounter = 0;
|
|
50
|
+
/** Ledger generation boundary; disposal collects epochs up to this value, then the next mount registers again. */
|
|
51
|
+
private ledgerEntryEpoch = new Map<string, number>();
|
|
52
|
+
private readonly pi: P;
|
|
53
|
+
private readonly ledger?: DisposeLedger;
|
|
54
|
+
|
|
55
|
+
constructor(pi: P, ledger?: DisposeLedger) {
|
|
56
|
+
this.pi = pi;
|
|
57
|
+
this.ledger = ledger;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Raw pi surface, bypassing wrappers; migrated modules must not use it for registration. */
|
|
61
|
+
get raw(): P {
|
|
62
|
+
return this.pi;
|
|
63
|
+
}
|
|
64
|
+
|
|
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. */
|
|
67
|
+
forModule(key: string): ScopedSurface {
|
|
68
|
+
this.epochCounter += 1;
|
|
69
|
+
const epoch = this.epochCounter;
|
|
70
|
+
for (const g of this.generations.get(key) ?? []) retireGroup(g);
|
|
71
|
+
const group: Group = { alive: true, epoch, unsubscribes: [] };
|
|
72
|
+
this.generations.set(key, [group]);
|
|
73
|
+
this.groups.set(key, group);
|
|
74
|
+
if (this.ledger && !this.ledgerEntryEpoch.has(key)) {
|
|
75
|
+
this.ledgerEntryEpoch.set(key, epoch);
|
|
76
|
+
this.ledger.add(key, () => {
|
|
77
|
+
const bound = this.ledgerEntryEpoch.get(key) ?? epoch;
|
|
78
|
+
this.ledgerEntryEpoch.delete(key);
|
|
79
|
+
this.generations.set(
|
|
80
|
+
key,
|
|
81
|
+
(this.generations.get(key) ?? []).filter((g) => {
|
|
82
|
+
if (g.epoch <= bound) {
|
|
83
|
+
retireGroup(g);
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
return {
|
|
92
|
+
registerTool: (def) => {
|
|
93
|
+
const original = def.execute as ((...a: unknown[]) => unknown) | undefined;
|
|
94
|
+
const wrapped = original
|
|
95
|
+
? async (...a: unknown[]) => {
|
|
96
|
+
if (!group!.alive) return INERT_TOOL_RESULT;
|
|
97
|
+
return original(...a);
|
|
98
|
+
}
|
|
99
|
+
: undefined;
|
|
100
|
+
(this.pi as { registerTool?: (d: unknown) => void }).registerTool?.(
|
|
101
|
+
wrapped ? { ...def, execute: wrapped } : def,
|
|
102
|
+
);
|
|
103
|
+
},
|
|
104
|
+
registerCommand: (name, options) => {
|
|
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.
|
|
107
|
+
const handler = options.handler as ((...a: unknown[]) => unknown) | undefined;
|
|
108
|
+
const wrapped = handler
|
|
109
|
+
? (...a: unknown[]) => (group!.alive ? handler(...a) : undefined)
|
|
110
|
+
: undefined;
|
|
111
|
+
(this.pi as { registerCommand?: (n: string, o: Record<string, unknown>) => void })
|
|
112
|
+
.registerCommand?.(name, wrapped ? { ...options, handler: wrapped } : options);
|
|
113
|
+
},
|
|
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.
|
|
117
|
+
const wrapped = (...a: unknown[]) => (group!.alive ? handler(...a) : undefined);
|
|
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
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
disposeModule(key: string): boolean {
|
|
129
|
+
const had = this.groups.has(key);
|
|
130
|
+
for (const g of this.generations.get(key) ?? []) retireGroup(g);
|
|
131
|
+
this.generations.delete(key);
|
|
132
|
+
this.groups.delete(key);
|
|
133
|
+
this.ledgerEntryEpoch.delete(key);
|
|
134
|
+
return had;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
get moduleCount(): number {
|
|
138
|
+
return this.groups.size;
|
|
139
|
+
}
|
|
140
|
+
}
|