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
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Role manifest file format + validator; the contract document is `schemas/role-manifest.schema.json`.
|
|
3
|
+
* Validation is hand-written with zero dependencies so workers carry no ajv overhead. Strict: unknown
|
|
4
|
+
* top-level keys are reported (a typo like "rulez" must not silently fail) and every issue is
|
|
5
|
+
* collected in one pass so a manifest can be repaired in one go.
|
|
6
|
+
*/
|
|
7
|
+
import { isPlainObject } from './guards.ts';
|
|
8
|
+
|
|
9
|
+
export type PermissionAction = 'allow' | 'deny';
|
|
10
|
+
/** D82: stance for unknown tools—visibility is a trust relationship (user→master via install; master→delegated via mirror). */
|
|
11
|
+
export type UnknownToolStance = 'allow' | 'deny';
|
|
12
|
+
/** pi's thinking levels, ascending. `--thinking` on the child's argv; never carried in its runtime env. */
|
|
13
|
+
export const THINKING_LEVELS = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const;
|
|
14
|
+
export type ThinkingLevel = (typeof THINKING_LEVELS)[number];
|
|
15
|
+
/** S9/S54: a worker's lifetime is the role's — `one-shot` settles, reports and exits, `persistent` stays for `send`. */
|
|
16
|
+
export const LIFECYCLES = ['persistent', 'one-shot'] as const;
|
|
17
|
+
export type Lifecycle = (typeof LIFECYCLES)[number];
|
|
18
|
+
|
|
19
|
+
export interface RoleManifest {
|
|
20
|
+
role: string;
|
|
21
|
+
version: string;
|
|
22
|
+
/** WS-D10: `provider/model` routing by role; omitted means follow the process default (spawn injects `--provider/--model`). */
|
|
23
|
+
model?: string;
|
|
24
|
+
/** Thinking level for the child launch (spawn injects `--thinking`); omitted means the process default. */
|
|
25
|
+
thinking?: ThinkingLevel;
|
|
26
|
+
description?: string;
|
|
27
|
+
/** P0 per-role guidelines (RFC §4.6): constraints a tool set cannot express; injected as a prompt section. */
|
|
28
|
+
guidelines?: string[];
|
|
29
|
+
/** Prose too long for a bullet: injected after `guidelines` in the same `piher-role` prompt section. */
|
|
30
|
+
instructions?: string;
|
|
31
|
+
/** Lifetime of a worker of this role (S9); omitted means `persistent`. Never a per-spawn choice. */
|
|
32
|
+
lifecycle?: Lifecycle;
|
|
33
|
+
/** `false` keeps the role launchable by name but out of the master's role catalog (internal/experimental). */
|
|
34
|
+
modelInvocable?: boolean;
|
|
35
|
+
manifest: {
|
|
36
|
+
tools: string[];
|
|
37
|
+
/** Three-state permissions; `*` supplies the default; omitted means `{"*":"allow"}`. */
|
|
38
|
+
rules?: Record<string, PermissionAction>;
|
|
39
|
+
/**
|
|
40
|
+
* D82 stance for tools outside the manifest (default deny; master/worker = allow — install grants
|
|
41
|
+
* the user's extension access). Separate axis from `rules['*']`: visibility vs enforcement.
|
|
42
|
+
* Excluded families (worker's subagent/terminal) need an explicit deny to stay blocked under allow.
|
|
43
|
+
*/
|
|
44
|
+
unknownTools?: UnknownToolStance;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type ValidateResult =
|
|
49
|
+
| { ok: true; value: RoleManifest }
|
|
50
|
+
| { ok: false; code: 'INVALID_ROLE_CONFIG'; issues: string[] };
|
|
51
|
+
|
|
52
|
+
const ROLE_NAME_RE = /^[a-z0-9-]+$/;
|
|
53
|
+
const SEMVER_RE = /^\d+\.\d+\.\d+$/;
|
|
54
|
+
const TOOL_KEY_RE = /^[a-z0-9_*-]+$/;
|
|
55
|
+
const ACTIONS: readonly PermissionAction[] = ['allow', 'deny'];
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
59
|
+
const issues: string[] = [];
|
|
60
|
+
if (!isPlainObject(input)) {
|
|
61
|
+
return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role manifest must be a JSON object'] };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const KNOWN_TOP = new Set(['role', 'version', 'model', 'thinking', 'description', 'guidelines', 'instructions', 'modelInvocable', 'lifecycle', 'manifest']);
|
|
65
|
+
for (const k of Object.keys(input)) {
|
|
66
|
+
if (!KNOWN_TOP.has(k)) issues.push(`unknown top-level key "${k}" (not in the contract; check spelling)`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
|
|
70
|
+
issues.push(`role must be a [a-z0-9-]+ string, got ${JSON.stringify(input.role)}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
|
|
74
|
+
issues.push(`version must be three dot-separated numbers x.y.z (e.g. 1.0.0), got ${JSON.stringify(input.version)}`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (input.model !== undefined) {
|
|
78
|
+
if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
|
|
79
|
+
issues.push(`model must be in provider/model form (e.g. opencode-go/muse-spark-1.2-contributor), got ${JSON.stringify(input.model)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (input.description !== undefined && typeof input.description !== 'string') {
|
|
84
|
+
issues.push('description must be a string');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (input.thinking !== undefined) {
|
|
88
|
+
if (typeof input.thinking !== 'string' || !THINKING_LEVELS.includes(input.thinking as ThinkingLevel)) {
|
|
89
|
+
issues.push(`thinking must be one of ${THINKING_LEVELS.join('/')}, got ${JSON.stringify(input.thinking)}`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (input.lifecycle !== undefined) {
|
|
94
|
+
if (typeof input.lifecycle !== 'string' || !LIFECYCLES.includes(input.lifecycle as Lifecycle)) {
|
|
95
|
+
issues.push(`lifecycle must be one of ${LIFECYCLES.join('/')}, got ${JSON.stringify(input.lifecycle)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (input.instructions !== undefined) {
|
|
100
|
+
if (typeof input.instructions !== 'string' || input.instructions.trim() === '') {
|
|
101
|
+
issues.push('instructions must be a non-empty string (omit it instead of passing an empty one)');
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (input.modelInvocable !== undefined && typeof input.modelInvocable !== 'boolean') {
|
|
106
|
+
issues.push(`modelInvocable must be a boolean, got ${JSON.stringify(input.modelInvocable)}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (input.guidelines !== undefined) {
|
|
110
|
+
if (!Array.isArray(input.guidelines)) {
|
|
111
|
+
issues.push('guidelines must be an array of strings');
|
|
112
|
+
} else if (input.guidelines.length === 0) {
|
|
113
|
+
issues.push('omit guidelines entirely instead of passing an empty array');
|
|
114
|
+
} else if (!input.guidelines.every((g) => typeof g === 'string' && g.trim() !== '')) {
|
|
115
|
+
issues.push('every guidelines entry must be a non-empty string');
|
|
116
|
+
} else if (input.guidelines.length > 20) {
|
|
117
|
+
issues.push('at most 20 guidelines (prompt budget protection)');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const m = input.manifest;
|
|
122
|
+
if (!isPlainObject(m)) {
|
|
123
|
+
issues.push('manifest must be an object');
|
|
124
|
+
} else {
|
|
125
|
+
const KNOWN_MANIFEST = new Set(['tools', 'rules', 'unknownTools']);
|
|
126
|
+
for (const k of Object.keys(m)) {
|
|
127
|
+
if (!KNOWN_MANIFEST.has(k)) issues.push(`unknown key "${k}" in manifest`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
131
|
+
issues.push(`manifest.unknownTools must be allow/deny, got ${JSON.stringify(m.unknownTools)}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const tools = m.tools;
|
|
135
|
+
if (!Array.isArray(tools) || tools.length === 0 || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
|
|
136
|
+
issues.push('manifest.tools must be a non-empty array of strings (tool names [a-z0-9_-])');
|
|
137
|
+
} else {
|
|
138
|
+
const seen = new Set<string>();
|
|
139
|
+
for (const t of tools) {
|
|
140
|
+
if (seen.has(t)) issues.push(`duplicate entry in manifest.tools "${t}"`);
|
|
141
|
+
seen.add(t);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (m.rules !== undefined) {
|
|
146
|
+
if (!isPlainObject(m.rules)) {
|
|
147
|
+
issues.push('manifest.rules must be an object');
|
|
148
|
+
} else {
|
|
149
|
+
for (const [k, v] of Object.entries(m.rules)) {
|
|
150
|
+
if (!TOOL_KEY_RE.test(k)) issues.push(`rules key "${k}" is not a valid tool name ([a-z0-9_-] or *)`);
|
|
151
|
+
if (v === 'ask') {
|
|
152
|
+
issues.push(`rules["${k}"]: "ask" is not a permission — piher ships no human-gate tool, so use "allow" or "deny"`);
|
|
153
|
+
} else if (!ACTIONS.includes(v as PermissionAction)) {
|
|
154
|
+
issues.push(`rules["${k}"] must be one of ${ACTIONS.join('/')}, got ${JSON.stringify(v)}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (issues.length > 0) return { ok: false, code: 'INVALID_ROLE_CONFIG', issues };
|
|
162
|
+
return { ok: true, value: input as unknown as RoleManifest };
|
|
163
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* P0 (RFC docs/rfc-pi-0.86-dynamic-tools.md §4): mutable role state for mid-session switching. pi
|
|
3
|
+
* 0.86 records `setActiveTools` deltas in the transcript, so a switch survives resume and branch
|
|
4
|
+
* navigation; this module owns piher's half — replaying the gate manifest from the last
|
|
5
|
+
* `piher.role-manifest` entry and planning the active-set transition.
|
|
6
|
+
*/
|
|
7
|
+
import type { PermissionAction, UnknownToolStance } from './role-manifest.ts';
|
|
8
|
+
import type { RuntimeRoleManifest } from './tool-gate.ts';
|
|
9
|
+
import { filterToolsByStance } from './tool-gate.ts';
|
|
10
|
+
|
|
11
|
+
/** Shape persisted in ROLE_MANIFEST_CUSTOM_TYPE entries: the runtime manifest plus the payload version and
|
|
12
|
+
* the switch provenance. `version` is the payload discriminator and `manifestVersion` the manifest semver
|
|
13
|
+
* — replay must not confuse the two. */
|
|
14
|
+
export type RoleManifestRecord = Omit<RuntimeRoleManifest, 'version'> & {
|
|
15
|
+
version: 1;
|
|
16
|
+
manifestVersion?: string;
|
|
17
|
+
/** 'switch' records a mid-session switch; the session_start anchor write omits it (env origin). */
|
|
18
|
+
origin?: 'switch';
|
|
19
|
+
switchedBy?: string;
|
|
20
|
+
ts?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** State carried across a process for the armed role system; manifest==null means unarmed (bare pi). */
|
|
24
|
+
export interface RoleState {
|
|
25
|
+
manifest: RuntimeRoleManifest | null;
|
|
26
|
+
origin: 'env' | 'switch';
|
|
27
|
+
switchedBy: string | null;
|
|
28
|
+
switchedAt: number | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function initialRoleState(manifest: RuntimeRoleManifest | null): RoleState {
|
|
32
|
+
return { manifest, origin: 'env', switchedBy: null, switchedAt: null };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Last `piher.role-manifest` record on the branch, or null. Session entries are pi JSONL objects
|
|
36
|
+
* ({ type: 'custom', customType, data }); unknown shapes are skipped, not fatal — replay must never break
|
|
37
|
+
* session startup. */
|
|
38
|
+
export function latestRoleManifestRecord(entries: readonly unknown[]): RoleManifestRecord | null {
|
|
39
|
+
let found: RoleManifestRecord | null = null;
|
|
40
|
+
for (const raw of entries) {
|
|
41
|
+
const entry = raw as { type?: unknown; customType?: unknown; data?: unknown };
|
|
42
|
+
if (entry?.type !== 'custom' || entry.customType !== 'piher.role-manifest') continue;
|
|
43
|
+
const data = entry.data as Partial<RoleManifestRecord> | undefined;
|
|
44
|
+
if (
|
|
45
|
+
!data ||
|
|
46
|
+
typeof data.role !== 'string' ||
|
|
47
|
+
!Array.isArray(data.tools) ||
|
|
48
|
+
!data.tools.every((t) => typeof t === 'string')
|
|
49
|
+
) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const guidelines = Array.isArray(data.guidelines)
|
|
53
|
+
? data.guidelines.filter((g): g is string => typeof g === 'string')
|
|
54
|
+
: [];
|
|
55
|
+
const rawPermissions = data.permissions;
|
|
56
|
+
found = {
|
|
57
|
+
version: 1,
|
|
58
|
+
role: data.role,
|
|
59
|
+
manifestVersion: typeof data.manifestVersion === 'string' ? data.manifestVersion : undefined,
|
|
60
|
+
tools: data.tools as string[],
|
|
61
|
+
permissions:
|
|
62
|
+
rawPermissions && typeof rawPermissions === 'object' && !Array.isArray(rawPermissions)
|
|
63
|
+
? (rawPermissions as RuntimeRoleManifest['permissions'])
|
|
64
|
+
: {},
|
|
65
|
+
unknownTools: data.unknownTools === 'allow' ? 'allow' : 'deny',
|
|
66
|
+
guidelines: guidelines.length > 0 ? guidelines : undefined,
|
|
67
|
+
origin: data.origin === 'switch' ? 'switch' : undefined,
|
|
68
|
+
switchedBy: typeof data.switchedBy === 'string' ? data.switchedBy : undefined,
|
|
69
|
+
ts: typeof data.ts === 'number' ? data.ts : undefined,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return found;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function manifestFromRecord(rec: RoleManifestRecord): RuntimeRoleManifest {
|
|
76
|
+
return {
|
|
77
|
+
role: rec.role,
|
|
78
|
+
version: rec.manifestVersion,
|
|
79
|
+
tools: rec.tools,
|
|
80
|
+
permissions: rec.permissions,
|
|
81
|
+
unknownTools: rec.unknownTools,
|
|
82
|
+
...(rec.guidelines && rec.guidelines.length > 0 ? { guidelines: rec.guidelines } : {}),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether the record differs from the state that would be written now (drives change-only writes). */
|
|
87
|
+
export function roleRecordDiffers(rec: RoleManifestRecord | null, state: RoleState): boolean {
|
|
88
|
+
const m = state.manifest;
|
|
89
|
+
if (!m) return false;
|
|
90
|
+
if (!rec) return true;
|
|
91
|
+
return rec.role !== m.role || JSON.stringify(rec.tools) !== JSON.stringify(m.tools);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Switch plan: widening (needs human confirm when driven by /piher-role) + diff summary. */
|
|
95
|
+
export interface RoleSwitchPlan {
|
|
96
|
+
widening: boolean;
|
|
97
|
+
added: string[];
|
|
98
|
+
removed: string[];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function planRoleSwitch(oldTools: readonly string[], newTools: readonly string[]): RoleSwitchPlan {
|
|
102
|
+
const oldSet = new Set(oldTools);
|
|
103
|
+
const newSet = new Set(newTools);
|
|
104
|
+
const added = newTools.filter((t) => !oldSet.has(t));
|
|
105
|
+
const removed = oldTools.filter((t) => !newSet.has(t));
|
|
106
|
+
return { widening: added.length > 0, added, removed };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Active set for a switch, over ALL registered tools rather than the current active set: a switch may
|
|
110
|
+
* re-admit tools an earlier session_start prune removed, which intersection semantics would lose. Stance
|
|
111
|
+
* handling is shared with planActiveTools (tool-gate.ts); the empty result is NOT a no-op here — a switch
|
|
112
|
+
* to a role with no overlap is a legitimate shrink. */
|
|
113
|
+
export function planSwitchActiveTools(
|
|
114
|
+
manifestTools: readonly string[],
|
|
115
|
+
registeredToolNames: readonly string[],
|
|
116
|
+
opts?: { unknownTools?: UnknownToolStance; permissions?: Record<string, PermissionAction> },
|
|
117
|
+
): string[] {
|
|
118
|
+
return filterToolsByStance(manifestTools, registeredToolNames, opts);
|
|
119
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"role": "master",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Master role: the pane that delegates. Core tools (subagent/bg_job) + pi built-ins + web_search, and unknownTools=allow under D82 so a newly installed extension tool is visible without editing this file. An `ask` rule is invalid: no permission can hand a decision to a human mid-call.",
|
|
5
|
+
"manifest": {
|
|
6
|
+
"unknownTools": "allow",
|
|
7
|
+
"tools": [
|
|
8
|
+
"subagent",
|
|
9
|
+
"bg_job",
|
|
10
|
+
"read",
|
|
11
|
+
"grep",
|
|
12
|
+
"glob",
|
|
13
|
+
"bash",
|
|
14
|
+
"pwsh",
|
|
15
|
+
"edit",
|
|
16
|
+
"write",
|
|
17
|
+
"web_search"
|
|
18
|
+
],
|
|
19
|
+
"rules": {
|
|
20
|
+
"*": "allow"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"role": "worker-default",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Default worker role (D83 inheritance): mirrors master.tools minus subagent and bg_job (no nesting, the worker owns no other worker); the excluded families are also denied explicitly, so the ban survives the D82 unknownTools=allow stance.",
|
|
5
|
+
"manifest": {
|
|
6
|
+
"unknownTools": "allow",
|
|
7
|
+
"tools": [
|
|
8
|
+
"read",
|
|
9
|
+
"grep",
|
|
10
|
+
"glob",
|
|
11
|
+
"bash",
|
|
12
|
+
"pwsh",
|
|
13
|
+
"edit",
|
|
14
|
+
"write",
|
|
15
|
+
"web_search"
|
|
16
|
+
],
|
|
17
|
+
"rules": {
|
|
18
|
+
"subagent": "deny",
|
|
19
|
+
"bg_job": "deny",
|
|
20
|
+
"*": "allow"
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every field is fed by the `OPTION_SPECS` registry, so the numbers cannot drift from
|
|
3
|
+
* `/piher-config doctor`; an out-of-range value warns and falls back to the registry default.
|
|
4
|
+
*/
|
|
5
|
+
import { OPTION_SPECS, optionValue, type PolicyField } from './options.ts';
|
|
6
|
+
|
|
7
|
+
export interface RuntimePolicy {
|
|
8
|
+
readonly subagentTimeoutMs: number
|
|
9
|
+
readonly gcTickMs: number
|
|
10
|
+
readonly pollIntervalMs: number
|
|
11
|
+
/** Post-settle window before auto-consume */
|
|
12
|
+
readonly settleWindowMs: number
|
|
13
|
+
readonly sessionTtlSeconds: number
|
|
14
|
+
readonly gitTimeoutMs: number
|
|
15
|
+
/** Subagent pane pipe readiness wait */
|
|
16
|
+
readonly readinessTimeoutMs: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function policyValue(field: PolicyField): number {
|
|
20
|
+
const spec = OPTION_SPECS.find((o) => o.policy === field);
|
|
21
|
+
if (!spec) throw new Error(`no OPTION_SPECS entry feeds RuntimePolicy.${field}`);
|
|
22
|
+
const fallback = Number(spec.fallback);
|
|
23
|
+
const raw = optionValue(spec.name);
|
|
24
|
+
if (raw === undefined) return fallback;
|
|
25
|
+
const parsed = Number.parseInt(raw, 10);
|
|
26
|
+
if (!Number.isFinite(parsed) || parsed < (spec.min ?? 0)) {
|
|
27
|
+
console.warn(`Invalid ${spec.name}="${raw}", using default ${spec.fallback}`);
|
|
28
|
+
return fallback;
|
|
29
|
+
}
|
|
30
|
+
return parsed;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function createRuntimePolicy(overrides?: Partial<RuntimePolicy>): RuntimePolicy {
|
|
34
|
+
const policy = {} as { -readonly [K in keyof RuntimePolicy]: number };
|
|
35
|
+
for (const spec of OPTION_SPECS) {
|
|
36
|
+
if (spec.policy) policy[spec.policy] = policyValue(spec.policy);
|
|
37
|
+
}
|
|
38
|
+
for (const [key, value] of Object.entries(overrides ?? {})) {
|
|
39
|
+
if (typeof value === 'number') policy[key as keyof RuntimePolicy] = value;
|
|
40
|
+
}
|
|
41
|
+
return policy;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Singleton for production; tests inject via createRuntimePolicy(). */
|
|
45
|
+
export const runtimePolicy = createRuntimePolicy()
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* piher's per-session state root: `<sessionDir>/piher/<sessionId>/`, the one place the extension owns
|
|
3
|
+
* beside pi's transcript. The worker marker and the session's task view live here, written in place
|
|
4
|
+
* rather than appended to the session, which is an immutable log with no replace.
|
|
5
|
+
*/
|
|
6
|
+
import { dirname, join } from 'node:path';
|
|
7
|
+
import { bareSessionId } from './session-tail.ts';
|
|
8
|
+
import { isValidSessionId } from './storage-layout.ts';
|
|
9
|
+
|
|
10
|
+
/** `<sessionDir>/piher/<sessionId>/<name>`; a bare id (`PI_SESSION_FILE=oneshot-sess-0001`, no transcript
|
|
11
|
+
* directory) has nowhere to sit, so no state file — callers must then fall back to what they have. */
|
|
12
|
+
export function sessionStatePath(sessionFile: string | null | undefined, name: string): string | null {
|
|
13
|
+
if (!sessionFile || typeof sessionFile !== 'string') return null;
|
|
14
|
+
const dir = dirname(sessionFile);
|
|
15
|
+
if (dir === '' || dir === '.') return null;
|
|
16
|
+
const id = bareSessionId(sessionFile);
|
|
17
|
+
if (!isValidSessionId(id)) return null;
|
|
18
|
+
return join(dir, 'piher', id, name);
|
|
19
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tail parsing for pi session JSONL (pure): child-agent results come from the child session file,
|
|
3
|
+
* not pane text. Depends only on the pi session format:
|
|
4
|
+
* line = {type, id, parentId, message:{role, content:[{type:'text',text}...], timestamp, stopReason}}
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'node:fs';
|
|
7
|
+
import * as path from 'node:path';
|
|
8
|
+
import { piSessionDirCandidates, sessionDirName, isValidSessionId } from './storage-layout.ts';
|
|
9
|
+
|
|
10
|
+
export { sessionDirName };
|
|
11
|
+
|
|
12
|
+
export interface SessionMessageLike {
|
|
13
|
+
role?: string;
|
|
14
|
+
content?: Array<{ type?: string; text?: string } | unknown> | unknown;
|
|
15
|
+
timestamp?: number;
|
|
16
|
+
stopReason?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SessionEntryLike {
|
|
20
|
+
type?: string;
|
|
21
|
+
message?: SessionMessageLike | unknown;
|
|
22
|
+
[k: string]: unknown;
|
|
23
|
+
}
|
|
24
|
+
export function parseSessionEntries(text: string): SessionEntryLike[] {
|
|
25
|
+
const entries: SessionEntryLike[] = [];
|
|
26
|
+
for (const line of (text ?? '').split(/\r?\n/)) {
|
|
27
|
+
const t = line.trim();
|
|
28
|
+
if (!t) continue;
|
|
29
|
+
try {
|
|
30
|
+
const obj = JSON.parse(t);
|
|
31
|
+
if (obj && typeof obj === 'object') entries.push(obj as SessionEntryLike);
|
|
32
|
+
} catch {
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return entries;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function messageOf(entry: SessionEntryLike): SessionMessageLike | null {
|
|
39
|
+
if (entry.type !== 'message') return null;
|
|
40
|
+
const m = entry.message;
|
|
41
|
+
if (typeof m !== 'object' || m === null) return null;
|
|
42
|
+
return m as SessionMessageLike;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function textOf(m: SessionMessageLike): string {
|
|
46
|
+
const content = m.content;
|
|
47
|
+
if (!Array.isArray(content)) return '';
|
|
48
|
+
return content
|
|
49
|
+
.map((c) => {
|
|
50
|
+
if (typeof c === 'object' && c !== null && (c as { type?: string }).type === 'text') {
|
|
51
|
+
return (c as { text?: string }).text ?? '';
|
|
52
|
+
}
|
|
53
|
+
return '';
|
|
54
|
+
})
|
|
55
|
+
.join('\n')
|
|
56
|
+
.trim();
|
|
57
|
+
}
|
|
58
|
+
/** Latest finalized assistant text (`stopReason === 'stop'`), excluding toolUse intermediates;
|
|
59
|
+
* `sinceTs` filters out messages predating the injection point. */
|
|
60
|
+
export function lastAssistantText(
|
|
61
|
+
entries: readonly SessionEntryLike[],
|
|
62
|
+
opts: { sinceTs?: number } = {},
|
|
63
|
+
): { text: string; timestamp: number } | null {
|
|
64
|
+
let best: { text: string; timestamp: number } | null = null;
|
|
65
|
+
for (const entry of entries) {
|
|
66
|
+
const m = messageOf(entry);
|
|
67
|
+
if (!m || m.role !== 'assistant') continue;
|
|
68
|
+
if (m.stopReason !== 'stop') continue;
|
|
69
|
+
const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
|
|
70
|
+
if (opts.sinceTs !== undefined && ts < opts.sinceTs) continue;
|
|
71
|
+
if (!best || ts >= best.timestamp) best = { text: textOf(m), timestamp: ts };
|
|
72
|
+
}
|
|
73
|
+
return best;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Newest assistant text after `sinceTs` with no stopReason filter: a turn can end by hitting the token
|
|
77
|
+
* cap or by an abort, and those words are still the report the master should receive instead of
|
|
78
|
+
* "left no closing message".
|
|
79
|
+
*/
|
|
80
|
+
export function lastAssistantAnyText(
|
|
81
|
+
entries: readonly SessionEntryLike[],
|
|
82
|
+
sinceTs: number,
|
|
83
|
+
): string | null {
|
|
84
|
+
let best: { text: string; timestamp: number } | null = null;
|
|
85
|
+
for (const entry of entries) {
|
|
86
|
+
const m = messageOf(entry);
|
|
87
|
+
if (!m || m.role !== 'assistant') continue;
|
|
88
|
+
const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
|
|
89
|
+
if (ts < sinceTs) continue;
|
|
90
|
+
const text = textOf(m).trim();
|
|
91
|
+
if (!text) continue;
|
|
92
|
+
if (!best || ts >= best.timestamp) best = { text, timestamp: ts };
|
|
93
|
+
}
|
|
94
|
+
return best?.text ?? null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Return whether any assistant message occurs after the injection point, regardless of stopReason. */
|
|
98
|
+
export function hasAssistantAfter(
|
|
99
|
+
entries: readonly SessionEntryLike[],
|
|
100
|
+
sinceTs: number,
|
|
101
|
+
): boolean {
|
|
102
|
+
return entries.some((e) => {
|
|
103
|
+
const m = messageOf(e);
|
|
104
|
+
return !!m && m.role === 'assistant' && (typeof m.timestamp === 'number' ? m.timestamp : 0) >= sinceTs;
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Whether the NEWEST assistant message after `sinceTs` ended its turn: a worker between tool calls
|
|
109
|
+
* has "an assistant message" but has NOT finished, so announcing it settled loses supervision of
|
|
110
|
+
* live work. `stopReason === 'toolUse'` means tools were requested; a non-string stopReason (still
|
|
111
|
+
* streaming / older shapes) counts as "not ended" on purpose.
|
|
112
|
+
*/
|
|
113
|
+
export function lastAssistantTurnEnded(
|
|
114
|
+
entries: readonly SessionEntryLike[],
|
|
115
|
+
sinceTs: number,
|
|
116
|
+
): boolean {
|
|
117
|
+
let newest: { ts: number; stopReason: unknown } | null = null;
|
|
118
|
+
for (const entry of entries) {
|
|
119
|
+
const m = messageOf(entry);
|
|
120
|
+
if (!m || m.role !== 'assistant') continue;
|
|
121
|
+
const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
|
|
122
|
+
if (ts < sinceTs) continue;
|
|
123
|
+
if (!newest || ts >= newest.ts) newest = { ts, stopReason: m.stopReason };
|
|
124
|
+
}
|
|
125
|
+
if (!newest) return false;
|
|
126
|
+
const sr = newest.stopReason;
|
|
127
|
+
return typeof sr === 'string' && sr !== 'toolUse' && sr !== 'pending';
|
|
128
|
+
}
|
|
129
|
+
/** Whether an initiated toolCall still lacks a result after injection (a task waiting on the tool's
|
|
130
|
+
* own completion), meaning settlement has not completed. */
|
|
131
|
+
export function hasPendingToolCall(entries: readonly SessionEntryLike[], sinceTs: number): boolean {
|
|
132
|
+
let depth = 0;
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
const m = messageOf(entry);
|
|
135
|
+
if (!m) continue;
|
|
136
|
+
const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
|
|
137
|
+
if (ts < sinceTs) continue;
|
|
138
|
+
if (m.role === 'assistant') {
|
|
139
|
+
const content = m.content;
|
|
140
|
+
if (Array.isArray(content)) {
|
|
141
|
+
depth += content.filter((c) => typeof c === 'object' && c !== null && (c as { type?: string }).type === 'toolCall').length;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (m.role === 'toolResult') depth -= 1;
|
|
145
|
+
}
|
|
146
|
+
return depth > 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Why a turn ended with nothing to say, in words the master can act on. A refused tool is the case
|
|
151
|
+
* that matters: piher's gate refuses it, the model stops, and without this the master waited the whole
|
|
152
|
+
* vacuum budget for a report that was never coming.
|
|
153
|
+
*/
|
|
154
|
+
export function explainSilentEnd(
|
|
155
|
+
entries: readonly SessionEntryLike[],
|
|
156
|
+
sinceTs: number,
|
|
157
|
+
): string | null {
|
|
158
|
+
let refusal: string | null = null;
|
|
159
|
+
let failed = false;
|
|
160
|
+
for (const entry of entries) {
|
|
161
|
+
const m = messageOf(entry);
|
|
162
|
+
if (!m) continue;
|
|
163
|
+
const ts = typeof m.timestamp === 'number' ? m.timestamp : 0;
|
|
164
|
+
if (ts < sinceTs) continue;
|
|
165
|
+
if (m.role === 'toolResult') {
|
|
166
|
+
const hit = /role "([^"]+)" does not permit tool "([^"]+)"/.exec(textOf(m));
|
|
167
|
+
if (hit) refusal = `the \`${hit[1]}\` manifest does not permit \`${hit[2]}\``;
|
|
168
|
+
}
|
|
169
|
+
if (m.role === 'assistant' && m.stopReason === 'error') failed = true;
|
|
170
|
+
}
|
|
171
|
+
if (refusal) {
|
|
172
|
+
return `It stopped after a refused tool call: ${refusal}. Fix the role profile or the task, then continue it.`;
|
|
173
|
+
}
|
|
174
|
+
return failed ? 'Its turn ended with an error and no message.' : null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Derived settlement state of one child session, from the inject point onward. */
|
|
178
|
+
export interface SubSessionState {
|
|
179
|
+
text: string | null;
|
|
180
|
+
pendingTool: boolean;
|
|
181
|
+
activity: boolean;
|
|
182
|
+
turnEnded: boolean;
|
|
183
|
+
/** Nothing to say, but there is a reason the master can act on: a refused tool, an errored turn. */
|
|
184
|
+
why?: string | null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Closing text is terminal by construction (lastAssistantText requires stopReason 'stop'); an
|
|
188
|
+
* assistant message alone is not a settlement — the turn must have ENDED. */
|
|
189
|
+
export function deriveSubSessionState(
|
|
190
|
+
entries: readonly SessionEntryLike[],
|
|
191
|
+
sinceTs: number,
|
|
192
|
+
): SubSessionState {
|
|
193
|
+
const r = lastAssistantText(entries, { sinceTs });
|
|
194
|
+
if (r?.text) return { text: r.text, pendingTool: false, activity: true, turnEnded: true, why: null };
|
|
195
|
+
if (hasPendingToolCall(entries, sinceTs)) return { text: null, pendingTool: true, activity: true, turnEnded: false, why: null };
|
|
196
|
+
if (hasAssistantAfter(entries, sinceTs)) {
|
|
197
|
+
const turnEnded = lastAssistantTurnEnded(entries, sinceTs);
|
|
198
|
+
// An ended turn always has words to deliver, even when they did not come from a clean 'stop'
|
|
199
|
+
// (token cap, abort). Without this the poller saw "turn ended, no text" and announced
|
|
200
|
+
// "It left no closing message" for a worker that had answered.
|
|
201
|
+
const any = turnEnded ? lastAssistantAnyText(entries, sinceTs) : null;
|
|
202
|
+
if (any) return { text: any, pendingTool: false, activity: true, turnEnded: true, why: null };
|
|
203
|
+
return { text: null, pendingTool: false, activity: true, turnEnded, why: explainSilentEnd(entries, sinceTs) };
|
|
204
|
+
}
|
|
205
|
+
return { text: null, pendingTool: false, activity: false, turnEnded: false, why: null };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Newest `limit` session files under cwd's session dir (pi core's name first, then piher's old encodings). */
|
|
209
|
+
export function listSessionFiles(cwd: string, agentDir: string, limit = 4): string[] {
|
|
210
|
+
const files: Array<{ file: string; mtime: number }> = [];
|
|
211
|
+
for (const name of piSessionDirCandidates(cwd)) {
|
|
212
|
+
const dir = path.join(agentDir, name);
|
|
213
|
+
let names: string[];
|
|
214
|
+
try {
|
|
215
|
+
names = fs.readdirSync(dir);
|
|
216
|
+
} catch {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
for (const fileName of names) {
|
|
220
|
+
if (!fileName.endsWith('.jsonl')) continue;
|
|
221
|
+
const full = path.join(dir, fileName);
|
|
222
|
+
try {
|
|
223
|
+
files.push({ file: full, mtime: fs.statSync(full).mtimeMs });
|
|
224
|
+
} catch {
|
|
225
|
+
/* vanished between readdir and stat */
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
files.sort((a, b) => b.mtime - a.mtime);
|
|
230
|
+
return files.slice(0, limit).map((f) => f.file);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Locate a session file by id (`<ts>_<id>.jsonl`): pi core's directory name first, then piher's
|
|
234
|
+
* legacy flattened dirs. */
|
|
235
|
+
export function sessionFileById(cwd: string, agentDir: string, id: string): string | null {
|
|
236
|
+
const suffix = `_${id}.jsonl`;
|
|
237
|
+
for (const name of piSessionDirCandidates(cwd)) {
|
|
238
|
+
const dir = path.join(agentDir, name);
|
|
239
|
+
let names: string[];
|
|
240
|
+
try {
|
|
241
|
+
names = fs.readdirSync(dir);
|
|
242
|
+
} catch {
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
for (const fileName of names) {
|
|
246
|
+
if (fileName.endsWith(suffix)) return path.join(dir, fileName);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Normalize a session path or id to the bare id used across piher state (`<timestamp>_<uuid>.jsonl`
|
|
254
|
+
* → `<uuid>`; `sub-session.jsonl` stays whole). Only pi's real transcript prefix is stripped, so
|
|
255
|
+
* arbitrary ids like PI_SESSION_FILE=sess_idx_prune survive untouched. Same-normalization comparisons
|
|
256
|
+
* (own session vs candidate paths) must go through here.
|
|
257
|
+
*/
|
|
258
|
+
export function bareSessionId(raw: string): string {
|
|
259
|
+
const base = raw.replaceAll('\\', '/').split('/').pop()!.replace(/\.jsonl$/, '');
|
|
260
|
+
const stripped = base.replace(/^\d{4}-\d{2}-\d{2}T[\d-]+Z_/, '');
|
|
261
|
+
return isValidSessionId(stripped) ? stripped : base;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Herdr reports session ids and paths interchangeably: accept a `.jsonl` path as-is, else map the id. */
|
|
265
|
+
export function resolveSessionFileValue(cwd: string, agentDir: string, value: string | null | undefined): string | null {
|
|
266
|
+
if (!value) return null;
|
|
267
|
+
return /\.jsonl$/i.test(value) ? value : sessionFileById(cwd, agentDir, value);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function readSessionFile(file: string): SessionEntryLike[] | null {
|
|
271
|
+
try {
|
|
272
|
+
return parseSessionEntries(fs.readFileSync(file, 'utf8'));
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|