pi-pier 0.4.1 → 0.4.3
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 +1 -2
- package/package.json +1 -1
- package/schemas/efficiency-config.schema.json +25 -19
- package/schemas/role-manifest.schema.json +9 -9
- package/src/ask-dialog.ts +9 -2
- package/src/ask-user.ts +142 -74
- package/src/compact-coordinator.ts +204 -33
- package/src/compact-economics-core.ts +62 -29
- package/src/config-catalog-core.ts +1 -0
- package/src/delegation-core.ts +1 -1
- package/src/efficiency-config-core.ts +34 -20
- package/src/efficiency-store.ts +42 -5
- package/src/herdr-client.ts +15 -5
- package/src/index-notices.ts +1 -1
- package/src/index-pipe.ts +0 -8
- package/src/index.ts +71 -87
- package/src/jev-client.ts +32 -7
- package/src/jev-core.ts +70 -33
- package/src/manifest-compose.ts +4 -4
- package/src/observation-core.ts +13 -1
- package/src/pipe-channel.ts +0 -8
- package/src/plugins/heat-plan.ts +18 -3
- package/src/plugins/observation.ts +118 -46
- package/src/plugins/subagent.ts +53 -40
- package/src/plugins/terminal.ts +192 -47
- package/src/plugins/todo.ts +44 -22
- package/src/reducer-invoker.ts +28 -14
- package/src/renderers.ts +1 -13
- package/src/role-loader.ts +8 -8
- package/src/role-manifest.ts +22 -22
- package/src/roles/master.json +1 -1
- package/src/roles/worker-default.json +3 -3
- package/src/settle-wake-core.ts +8 -2
- package/src/subagent-core.ts +41 -3
- package/src/subagent-poller.ts +52 -18
- package/src/subagent-session.ts +67 -4
- package/src/subagent-spawn.ts +6 -5
- package/src/terminal-core.ts +111 -17
- package/src/todo-core.ts +47 -2
- package/src/todo-read-hook.ts +31 -17
- package/src/todo-reminder-core.ts +9 -5
- package/src/todo-window.ts +8 -4
- package/src/todos-service.ts +12 -0
- package/src/tree-view.ts +10 -10
- package/src/vocab.ts +7 -1
package/src/renderers.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* needed and older pi builds degrade to "no renderer".
|
|
5
5
|
*/
|
|
6
6
|
import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
|
|
7
|
-
import { SUBS_CUSTOM_TYPE,
|
|
7
|
+
import { SUBS_CUSTOM_TYPE, subRef, type SubsRegistry } from './subagent-core.ts';
|
|
8
8
|
import { DELEGATION_CUSTOM_TYPE } from './delegation-core.ts';
|
|
9
9
|
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
10
10
|
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
@@ -159,17 +159,6 @@ function approvalLines(data: unknown, theme: RenderTheme): string[] {
|
|
|
159
159
|
return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
-
/** Phase 2: worker progress note (display-less custom message) as a one-line card — the sender
|
|
163
|
-
* pane rides `details.paneId`; keep it recognizable without flooding the transcript. */
|
|
164
|
-
function progressLines(message: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
165
|
-
const rec = (message ?? {}) as { content?: unknown; details?: { paneId?: unknown } };
|
|
166
|
-
const from = typeof rec.details?.paneId === 'string' && rec.details.paneId ? rec.details.paneId : '?';
|
|
167
|
-
const content = typeof rec.content === 'string' ? rec.content : '';
|
|
168
|
-
const head = `${theme.fg('accent', '⤴ progress')} ${theme.fg('dim', `· ${from}: ${clip(content, 100)}`)}`;
|
|
169
|
-
if (!expanded) return [head];
|
|
170
|
-
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
171
|
-
}
|
|
172
|
-
|
|
173
162
|
/** Reminder custom messages carry the full injected text; restyle without altering it. */
|
|
174
163
|
function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
|
|
175
164
|
const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
|
|
@@ -215,7 +204,6 @@ export function installRenderers(pi: unknown): string[] {
|
|
|
215
204
|
entry(DELEGATION_CUSTOM_TYPE, delegationLines);
|
|
216
205
|
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
217
206
|
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
218
|
-
message(PROGRESS_CUSTOM_TYPE, progressLines);
|
|
219
207
|
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
|
220
208
|
message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
|
|
221
209
|
return registered;
|
package/src/role-loader.ts
CHANGED
|
@@ -98,7 +98,7 @@ export function listRoleNames(baseDir?: string, userDir?: string): string[] {
|
|
|
98
98
|
|
|
99
99
|
export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManifest {
|
|
100
100
|
if (typeof name !== 'string' || !ROLE_NAME_RE.test(name)) {
|
|
101
|
-
throw new RoleLoaderError('ROLE_NOT_FOUND', `role
|
|
101
|
+
throw new RoleLoaderError('ROLE_NOT_FOUND', `role not found or invalid name ([a-z0-9-]+): ${JSON.stringify(name)}`);
|
|
102
102
|
}
|
|
103
103
|
const fileName = `${name}.json`;
|
|
104
104
|
|
|
@@ -108,7 +108,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
|
|
|
108
108
|
text = opts.read(fileName);
|
|
109
109
|
} catch (err) {
|
|
110
110
|
const code = (err as { code?: unknown })?.code;
|
|
111
|
-
throw new RoleLoaderError('ROLE_NOT_FOUND', `role "${name}"
|
|
111
|
+
throw new RoleLoaderError('ROLE_NOT_FOUND', `role "${name}" not found (failed to read ${fileName}: ${typeof code === 'string' ? code : 'error'})`);
|
|
112
112
|
}
|
|
113
113
|
return parseAndValidate(name, fileName, text, 'builtin (src/roles/)');
|
|
114
114
|
}
|
|
@@ -123,7 +123,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
|
|
|
123
123
|
if (layerRead(layer.dir, fileName) != null) {
|
|
124
124
|
throw new RoleLoaderError(
|
|
125
125
|
'ROLE_RESERVED',
|
|
126
|
-
`role "${name}"
|
|
126
|
+
`role "${name}" is a reserved built-in name and cannot be defined in the ${layer.label} layer (built-in manifests take precedence; delete that file or rename it)`,
|
|
127
127
|
);
|
|
128
128
|
}
|
|
129
129
|
}
|
|
@@ -136,7 +136,7 @@ export function loadRoleConfig(name: string, opts?: LoadRoleOptions): RoleManife
|
|
|
136
136
|
}
|
|
137
137
|
throw new RoleLoaderError(
|
|
138
138
|
'ROLE_NOT_FOUND',
|
|
139
|
-
`role "${name}"
|
|
139
|
+
`role "${name}" not found (searched layers: ${layers.map((l) => l.label).join(' → ')})`,
|
|
140
140
|
);
|
|
141
141
|
}
|
|
142
142
|
|
|
@@ -145,22 +145,22 @@ function parseAndValidate(name: string, fileName: string, text: string, layerLab
|
|
|
145
145
|
try {
|
|
146
146
|
parsed = JSON.parse(text);
|
|
147
147
|
} catch (err) {
|
|
148
|
-
throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}"
|
|
148
|
+
throw new RoleLoaderError('INVALID_ROLE_CONFIG', `role "${name}" (${layerLabel}) is not valid JSON: ${(err as Error).message}`);
|
|
149
149
|
}
|
|
150
150
|
const result = validateRoleManifest(parsed);
|
|
151
151
|
// `ok === false` (instead of `!ok`) is required to narrow without strictNullChecks.
|
|
152
152
|
if (result.ok === false) {
|
|
153
153
|
throw new RoleLoaderError(
|
|
154
154
|
'INVALID_ROLE_CONFIG',
|
|
155
|
-
`role "${name}"
|
|
155
|
+
`role "${name}" (${layerLabel}) failed validation:\n${result.issues.map((i) => ` - ${i}`).join('\n')}`,
|
|
156
156
|
result.issues,
|
|
157
157
|
);
|
|
158
158
|
}
|
|
159
159
|
if (result.value.role !== name) {
|
|
160
160
|
throw new RoleLoaderError(
|
|
161
161
|
'INVALID_ROLE_CONFIG',
|
|
162
|
-
`role
|
|
163
|
-
[`role
|
|
162
|
+
`role manifest name mismatch: role="${result.value.role}" in ${fileName} of ${layerLabel}`,
|
|
163
|
+
[`role field "${result.value.role}" does not match the requested "${name}"`],
|
|
164
164
|
);
|
|
165
165
|
}
|
|
166
166
|
return result.value;
|
package/src/role-manifest.ts
CHANGED
|
@@ -53,79 +53,79 @@ const ACTIONS: readonly PermissionAction[] = ['allow', 'ask', 'deny'];
|
|
|
53
53
|
export function validateRoleManifest(input: unknown): ValidateResult {
|
|
54
54
|
const issues: string[] = [];
|
|
55
55
|
if (!isPlainObject(input)) {
|
|
56
|
-
return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role
|
|
56
|
+
return { ok: false, code: 'INVALID_ROLE_CONFIG', issues: ['role manifest must be a JSON object'] };
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
const KNOWN_TOP = new Set(['role', 'version', 'model', 'description', 'guidelines', 'manifest', 'services']);
|
|
60
60
|
for (const k of Object.keys(input)) {
|
|
61
|
-
if (!KNOWN_TOP.has(k)) issues.push(
|
|
61
|
+
if (!KNOWN_TOP.has(k)) issues.push(`unknown top-level key "${k}" (not in the contract; check spelling)`);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
if (typeof input.role !== 'string' || !ROLE_NAME_RE.test(input.role)) {
|
|
65
|
-
issues.push(`role
|
|
65
|
+
issues.push(`role must be a [a-z0-9-]+ string, got ${JSON.stringify(input.role)}`);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
if (typeof input.version !== 'string' || !SEMVER_RE.test(input.version)) {
|
|
69
|
-
issues.push(`version
|
|
69
|
+
issues.push(`version must be three dot-separated numbers x.y.z (e.g. 1.0.0), got ${JSON.stringify(input.version)}`);
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
if (input.model !== undefined) {
|
|
73
73
|
if (typeof input.model !== 'string' || !/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/.test(input.model)) {
|
|
74
|
-
issues.push(`model
|
|
74
|
+
issues.push(`model must be in provider/model form (e.g. opencode-go/muse-spark-1.2-contributor), got ${JSON.stringify(input.model)}`);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
if (input.description !== undefined && typeof input.description !== 'string') {
|
|
79
|
-
issues.push('description
|
|
79
|
+
issues.push('description must be a string');
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
if (input.guidelines !== undefined) {
|
|
83
83
|
if (!Array.isArray(input.guidelines)) {
|
|
84
|
-
issues.push('guidelines
|
|
84
|
+
issues.push('guidelines must be an array of strings');
|
|
85
85
|
} else if (input.guidelines.length === 0) {
|
|
86
|
-
issues.push('guidelines
|
|
86
|
+
issues.push('omit guidelines entirely instead of passing an empty array');
|
|
87
87
|
} else if (!input.guidelines.every((g) => typeof g === 'string' && g.trim() !== '')) {
|
|
88
|
-
issues.push('guidelines
|
|
88
|
+
issues.push('every guidelines entry must be a non-empty string');
|
|
89
89
|
} else if (input.guidelines.length > 20) {
|
|
90
|
-
issues.push('
|
|
90
|
+
issues.push('at most 20 guidelines (prompt budget protection)');
|
|
91
91
|
}
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
const m = input.manifest;
|
|
95
95
|
if (!isPlainObject(m)) {
|
|
96
|
-
issues.push('manifest
|
|
96
|
+
issues.push('manifest must be an object');
|
|
97
97
|
} else {
|
|
98
98
|
const KNOWN_MANIFEST = new Set(['tools', 'rules', 'unknownTools']);
|
|
99
99
|
for (const k of Object.keys(m)) {
|
|
100
|
-
if (!KNOWN_MANIFEST.has(k)) issues.push(`
|
|
100
|
+
if (!KNOWN_MANIFEST.has(k)) issues.push(`unknown key "${k}" in manifest`);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
if (m.unknownTools !== undefined && m.unknownTools !== 'allow' && m.unknownTools !== 'deny') {
|
|
104
|
-
issues.push(`manifest.unknownTools
|
|
104
|
+
issues.push(`manifest.unknownTools must be allow/deny, got ${JSON.stringify(m.unknownTools)}`);
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
const tools = m.tools;
|
|
108
108
|
if (!Array.isArray(tools) || !tools.every((t) => typeof t === 'string' && TOOL_KEY_RE.test(t))) {
|
|
109
|
-
issues.push('manifest.tools
|
|
109
|
+
issues.push('manifest.tools must be a non-empty array of strings (tool names [a-z0-9_-])');
|
|
110
110
|
} else {
|
|
111
111
|
const seen = new Set<string>();
|
|
112
112
|
for (const t of tools) {
|
|
113
|
-
if (seen.has(t)) issues.push(`manifest.tools
|
|
113
|
+
if (seen.has(t)) issues.push(`duplicate entry in manifest.tools "${t}"`);
|
|
114
114
|
seen.add(t);
|
|
115
115
|
}
|
|
116
116
|
for (const need of COORDINATION_TOOLS) {
|
|
117
|
-
if (!seen.has(need)) issues.push(
|
|
117
|
+
if (!seen.has(need)) issues.push(`non-empty baseline constraint: manifest.tools must include "${need}" (coordination tool)`);
|
|
118
118
|
}
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
if (m.rules !== undefined) {
|
|
122
122
|
if (!isPlainObject(m.rules)) {
|
|
123
|
-
issues.push('manifest.rules
|
|
123
|
+
issues.push('manifest.rules must be an object');
|
|
124
124
|
} else {
|
|
125
125
|
for (const [k, v] of Object.entries(m.rules)) {
|
|
126
|
-
if (!TOOL_KEY_RE.test(k)) issues.push(`rules
|
|
126
|
+
if (!TOOL_KEY_RE.test(k)) issues.push(`rules key "${k}" is not a valid tool name ([a-z0-9_-] or *)`);
|
|
127
127
|
if (!ACTIONS.includes(v as PermissionAction)) {
|
|
128
|
-
issues.push(`rules["${k}"]
|
|
128
|
+
issues.push(`rules["${k}"] must be one of ${ACTIONS.join('/')}, got ${JSON.stringify(v)}`);
|
|
129
129
|
}
|
|
130
130
|
}
|
|
131
131
|
}
|
|
@@ -134,14 +134,14 @@ export function validateRoleManifest(input: unknown): ValidateResult {
|
|
|
134
134
|
|
|
135
135
|
if (input.services !== undefined) {
|
|
136
136
|
if (!isPlainObject(input.services)) {
|
|
137
|
-
issues.push('services
|
|
137
|
+
issues.push('services must be an object');
|
|
138
138
|
} else if (input.services.todos !== undefined) {
|
|
139
139
|
const t = input.services.todos;
|
|
140
140
|
if (!isPlainObject(t)) {
|
|
141
|
-
issues.push('services.todos
|
|
141
|
+
issues.push('services.todos must be an object');
|
|
142
142
|
} else {
|
|
143
143
|
if (t.mode !== undefined && t.mode !== 'serial' && t.mode !== 'parallel') {
|
|
144
|
-
issues.push(`services.todos.mode
|
|
144
|
+
issues.push(`services.todos.mode must be serial/parallel, got ${JSON.stringify(t.mode)}`);
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
}
|
package/src/roles/master.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "master",
|
|
3
3
|
"version": "1.4.0",
|
|
4
|
-
"description": "
|
|
4
|
+
"description": "Master role (WS-D7 self-application; D83 mirror source): core tools (todos/coordination/subagent/terminal) + pi built-ins + web_search; D82 unknownTools=allow. v1.4.0: tool consolidation terminal 6→1, subagent 5→1",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"role": "worker-default",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.6.0",
|
|
4
|
+
"description": "Default worker role (D83 inheritance): mirrors master.tools minus subagent/terminal (no nesting, resource ownership); excluded families explicitly denied — still banned under the D82 unknownTools=allow stance; todos serial. v1.6.0: removed report_progress (0 calls in a week, D125); v1.5.0: Phase 2 explicitly allowed report_progress; v1.4.0: refreshed the deny list after tool consolidation",
|
|
5
5
|
"manifest": {
|
|
6
6
|
"unknownTools": "allow",
|
|
7
7
|
"tools": [
|
|
8
|
-
"todo_write", "ask_user_question",
|
|
8
|
+
"todo_write", "ask_user_question",
|
|
9
9
|
"read", "grep", "glob", "bash", "pwsh", "edit", "write",
|
|
10
10
|
"web_search"
|
|
11
11
|
],
|
package/src/settle-wake-core.ts
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
|
|
8
8
|
export const ABORT_STOP_REASON = 'aborted';
|
|
9
9
|
|
|
10
|
+
/** pi/provider failure stop. pi auto-retries these, so the run has not settled for the user. */
|
|
11
|
+
export const ERROR_STOP_REASON = 'error';
|
|
12
|
+
|
|
10
13
|
/** D96: cooldown before repeating a notice for the same running set. */
|
|
11
14
|
export const D96_REPEAT_NOTICE_MS = 10 * 60_000;
|
|
12
15
|
|
|
@@ -80,7 +83,9 @@ export function planSettleWake(input: SettleWakeInput): SettleWakePlan {
|
|
|
80
83
|
* coming: answering now reports "finished, no closing message" for live work and — worse —
|
|
81
84
|
* consumes the one-shot request plus the parent's claim latch, so the real closing message can
|
|
82
85
|
* never be delivered (observed: a worker mid-compaction announced finished; its final report
|
|
83
|
-
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run
|
|
86
|
+
* never reached the parent). A user abort ('aborted') likewise settles a run that may re-run, and
|
|
87
|
+
* an 'error' stop is an upstream failure pi retries — answering then consumes the latch for a
|
|
88
|
+
* parent's claim that the retry's closing message still has to satisfy.
|
|
84
89
|
*/
|
|
85
90
|
export function shouldPushSettleReply(input: {
|
|
86
91
|
intentionalAbort?: boolean;
|
|
@@ -92,5 +97,6 @@ export function shouldPushSettleReply(input: {
|
|
|
92
97
|
return !input.intentionalAbort
|
|
93
98
|
&& input.aborted !== true
|
|
94
99
|
&& !input.compactionInFlight
|
|
95
|
-
&& input.lastStopReason !== ABORT_STOP_REASON
|
|
100
|
+
&& input.lastStopReason !== ABORT_STOP_REASON
|
|
101
|
+
&& input.lastStopReason !== ERROR_STOP_REASON;
|
|
96
102
|
}
|
package/src/subagent-core.ts
CHANGED
|
@@ -114,7 +114,7 @@ export function buildResumeArgv(workerArgv: readonly string[], piBin = 'pi'): st
|
|
|
114
114
|
export const SUBAGENT_OUTPUT_SCHEMA = Type.Object({
|
|
115
115
|
action: Type.Union([
|
|
116
116
|
Type.Literal('spawn'), Type.Literal('resume'), Type.Literal('list'),
|
|
117
|
-
Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'),
|
|
117
|
+
Type.Literal('send'), Type.Literal('interrupt'), Type.Literal('output'), Type.Literal('role'), Type.Literal('help'),
|
|
118
118
|
]),
|
|
119
119
|
paneId: Type.Optional(Type.String()),
|
|
120
120
|
taskId: Type.Optional(Type.String()),
|
|
@@ -195,6 +195,14 @@ export interface AliveProbe {
|
|
|
195
195
|
foregroundCwd?: string | null;
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/** Trailing line for isolate spawn results: the branch (`pier/<slug>`) reads like a directory and
|
|
199
|
+
* masters have cd'd into `<repo>/pier/<slug>`; only the worktree path is an actual directory. */
|
|
200
|
+
export function formatIsolateLocation(
|
|
201
|
+
isolate: Pick<NonNullable<SubEntry['isolate']>, 'worktreePath' | 'branch'> | null | undefined,
|
|
202
|
+
): string {
|
|
203
|
+
return isolate ? `\nWorktree: ${isolate.worktreePath} (branch ${isolate.branch})` : '';
|
|
204
|
+
}
|
|
205
|
+
|
|
198
206
|
/**
|
|
199
207
|
* A subagent outlives its "no output" result: a working/blocked agent is alive regardless of session
|
|
200
208
|
* age, otherwise recent session writes decide. `paneExists: false` is death — an unavailable probe is
|
|
@@ -224,7 +232,7 @@ export function buildAliveNotice(
|
|
|
224
232
|
return [
|
|
225
233
|
`Subagent "${description}" is still running in pane ${paneId} (agent_status=${status}${activity}).`,
|
|
226
234
|
'The foreground wait has moved it to background so this call returns now.',
|
|
227
|
-
'Do NOT redo its work
|
|
235
|
+
'Do NOT redo its work and do NOT sleep or poll: when it settles, its result is delivered automatically as a settlement notice at your next turn boundary.',
|
|
228
236
|
'Use subagent(action: "list") to check its live state; subagent(action: "send") to give it follow-up work.',
|
|
229
237
|
].join(' ');
|
|
230
238
|
}
|
|
@@ -274,6 +282,9 @@ export interface SubEntry {
|
|
|
274
282
|
userTakeover?: boolean;
|
|
275
283
|
observationStartedAt?: number | null;
|
|
276
284
|
lastAgentStatus?: string | null;
|
|
285
|
+
/** The final report `subagent output` already returned (tail fingerprint + time): the settlement
|
|
286
|
+
* notice then points at that delivery instead of repeating the report. Durable across resume. */
|
|
287
|
+
reportDelivered?: { fingerprint: string; at: number } | null;
|
|
277
288
|
/** D98: isolate worktree metadata; `releasedAt` set once the worktree is removed. */
|
|
278
289
|
isolate?: {
|
|
279
290
|
worktreePath: string;
|
|
@@ -317,7 +328,30 @@ export function subRef(sub: { paneId: string; slug?: string }): string {
|
|
|
317
328
|
return sub.slug ? `${sub.slug}@${sub.paneId}` : sub.paneId;
|
|
318
329
|
}
|
|
319
330
|
|
|
320
|
-
|
|
331
|
+
/** Identity of a final report across the output action and the settlement notice: its tail. */
|
|
332
|
+
export function reportFingerprint(report: string): string {
|
|
333
|
+
return report.slice(-64);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Shorter sleeps are ordinary polling of builds/logs; from a minute up, they were waits on workers. */
|
|
337
|
+
const SLEEP_NOTE_MIN_SECONDS = 60;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Note for a master bash call that sleeps ≥ SLEEP_NOTE_MIN_SECONDS while background workers run:
|
|
341
|
+
* masters slept 660-840 s per worker although the settlement notice arrives on its own. Worded so a
|
|
342
|
+
* sleep that waits on something else (a load test, a build) is left alone.
|
|
343
|
+
*/
|
|
344
|
+
export function sleepWhileSubsRunningNote(
|
|
345
|
+
command: string,
|
|
346
|
+
running: ReadonlyArray<{ paneId: string; slug?: string }>,
|
|
347
|
+
): string | null {
|
|
348
|
+
if (running.length === 0) return null;
|
|
349
|
+
const longest = Math.max(0, ...[...command.matchAll(/\bsleep\s+(\d+)s?\b/g)].map((m) => Number(m[1])));
|
|
350
|
+
if (longest < SLEEP_NOTE_MIN_SECONDS) return null;
|
|
351
|
+
return `[pier] ${running.length} background subagent(s) running (${running.map(subRef).join(', ')}): their settlement `
|
|
352
|
+
+ 'notice arrives automatically at your next tool boundary, so do not sleep to wait for them — keep working or end '
|
|
353
|
+
+ 'your turn. Sleeping is only needed for other work (builds, logs).';
|
|
354
|
+
}
|
|
321
355
|
|
|
322
356
|
export interface SubsRegistry {
|
|
323
357
|
version: 2;
|
|
@@ -372,6 +406,10 @@ export function foldSubsRegistry(entries: ReadonlyArray<{ type?: string; customT
|
|
|
372
406
|
retainNotified: s.isolate.retainNotified === true,
|
|
373
407
|
}
|
|
374
408
|
: undefined,
|
|
409
|
+
reportDelivered: s.reportDelivered && typeof s.reportDelivered === 'object'
|
|
410
|
+
&& typeof s.reportDelivered.fingerprint === 'string' && typeof s.reportDelivered.at === 'number'
|
|
411
|
+
? { fingerprint: s.reportDelivered.fingerprint, at: s.reportDelivered.at }
|
|
412
|
+
: undefined,
|
|
375
413
|
}));
|
|
376
414
|
found = { version: 2, subs };
|
|
377
415
|
}
|
package/src/subagent-poller.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { runtimePolicy, type RuntimePolicy } from './runtime-policy.ts';
|
|
|
10
10
|
import type { JevRuntime } from './jev-client.ts';
|
|
11
11
|
import { evaluateSettleVerdict, settleAskIsSafe, settleVerdictRequest } from './jev-core.ts';
|
|
12
12
|
import { formatSettlementNotice, type SettlementNullReason } from './vocab.ts';
|
|
13
|
-
import { buildBlockedGateNotice, sleep, type SubEntry } from './subagent-core.ts';
|
|
13
|
+
import { buildBlockedGateNotice, reportFingerprint, sleep, type SubEntry } from './subagent-core.ts';
|
|
14
14
|
import type { SessionIo } from './subagent-session.ts';
|
|
15
15
|
import type { GitIo } from './subagent-spawn.ts';
|
|
16
16
|
|
|
@@ -70,8 +70,17 @@ export function planObservationTick(input: {
|
|
|
70
70
|
agentStatus: string | null;
|
|
71
71
|
machineInjectAgoMs: number;
|
|
72
72
|
machineInjectGraceMs: number;
|
|
73
|
+
/** The transcript already holds a finalized answer (stopReason 'stop', no pending tool call). */
|
|
74
|
+
finalized?: boolean;
|
|
73
75
|
}): ObservationTickPlan {
|
|
74
|
-
if (!input.observationStartedAt)
|
|
76
|
+
if (!input.observationStartedAt) {
|
|
77
|
+
// A finalized answer on a pane that is not working settles on the detecting tick: the window
|
|
78
|
+
// would only delay the result, and the transcript cannot grow under an idle pane. A working or
|
|
79
|
+
// blocked pane keeps the window path — that is the human-takeover guard.
|
|
80
|
+
const paneBusy = input.agentStatus === 'working' || input.agentStatus === 'blocked';
|
|
81
|
+
if (input.finalized && !paneBusy) return { kind: 'settle' };
|
|
82
|
+
return { kind: 'start-observation' };
|
|
83
|
+
}
|
|
75
84
|
if (input.agentStatus === 'working') {
|
|
76
85
|
if (input.machineInjectAgoMs > input.machineInjectGraceMs) return { kind: 'user-takeover' };
|
|
77
86
|
return { kind: 'machine-inject-reset' };
|
|
@@ -118,8 +127,9 @@ export function buildSettlementNoticeText(
|
|
|
118
127
|
closing: string | null,
|
|
119
128
|
statLine: string | null,
|
|
120
129
|
nullReason: SettlementNullReason = 'silent',
|
|
130
|
+
reportDeliveredAt: number | null = null,
|
|
121
131
|
): string {
|
|
122
|
-
const base = formatSettlementNotice(agentLabel, closing, nullReason);
|
|
132
|
+
const base = formatSettlementNotice(agentLabel, closing, nullReason, reportDeliveredAt);
|
|
123
133
|
return statLine ? `${base}\n${statLine}` : base;
|
|
124
134
|
}
|
|
125
135
|
|
|
@@ -183,7 +193,7 @@ export interface PollerHost {
|
|
|
183
193
|
lastMachineInjectAt: Map<string, number>;
|
|
184
194
|
session: SessionIo;
|
|
185
195
|
git: GitIo;
|
|
186
|
-
injectNotice(content: string): Promise<void>;
|
|
196
|
+
injectNotice(content: string, paneId?: string): Promise<void>;
|
|
187
197
|
reconcileOnSettlement(description: string, outcome: 'settled' | 'failed'): string[];
|
|
188
198
|
withReconcileNotes(base: string, notes: readonly string[]): string;
|
|
189
199
|
claimSettleNotice(key: string): boolean;
|
|
@@ -222,6 +232,9 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
222
232
|
): Promise<void> {
|
|
223
233
|
const startedAt = now();
|
|
224
234
|
let lastActivityAt = now();
|
|
235
|
+
/** Claimed settle key: on a rejected injection the claim stays ours, so the next tick retries
|
|
236
|
+
* the notice instead of suppressing it as someone else's. */
|
|
237
|
+
let claimedKey: string | null = null;
|
|
225
238
|
/** Stop watching this pane: persist + ledger first, or a restart replays a ghost running row. */
|
|
226
239
|
const consume = (outcome: string | null, via: string, at: number): void => {
|
|
227
240
|
const row = h.subs.get(paneId);
|
|
@@ -323,13 +336,6 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
323
336
|
|
|
324
337
|
if (isSettlementCandidate(s)) {
|
|
325
338
|
const closing = s.text;
|
|
326
|
-
if (!entry.observationStartedAt) {
|
|
327
|
-
entry.observationStartedAt = now();
|
|
328
|
-
entry.lastAgentStatus = 'idle';
|
|
329
|
-
h.persistSubs();
|
|
330
|
-
await doSleep(OBSERVATION_TICK_MS);
|
|
331
|
-
continue;
|
|
332
|
-
}
|
|
333
339
|
let agentStatus: string | null = null;
|
|
334
340
|
try {
|
|
335
341
|
agentStatus = (await h.client.listAgents()).find((a) => a.paneId === paneId)?.status ?? null;
|
|
@@ -342,7 +348,15 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
342
348
|
agentStatus,
|
|
343
349
|
machineInjectAgoMs: now() - (h.lastMachineInjectAt.get(paneId) ?? 0),
|
|
344
350
|
machineInjectGraceMs,
|
|
351
|
+
finalized: closing != null && !s.pendingTool,
|
|
345
352
|
});
|
|
353
|
+
if (obs.kind === 'start-observation') {
|
|
354
|
+
entry.observationStartedAt = now();
|
|
355
|
+
entry.lastAgentStatus = 'idle';
|
|
356
|
+
h.persistSubs();
|
|
357
|
+
await doSleep(OBSERVATION_TICK_MS);
|
|
358
|
+
continue;
|
|
359
|
+
}
|
|
346
360
|
if (obs.kind === 'user-takeover') {
|
|
347
361
|
entry.userTakeover = true;
|
|
348
362
|
entry.observationStartedAt = now();
|
|
@@ -409,19 +423,39 @@ export function createPoller(h: PollerHost): Poller {
|
|
|
409
423
|
await h.session.resolveSessionFile(paneId, cwd, undefined, { minMtimeMs: current.injectTs - 2_000 }),
|
|
410
424
|
);
|
|
411
425
|
}
|
|
412
|
-
|
|
426
|
+
// Claim before delivering: the pipe fast-path may already own this settlement's notice.
|
|
427
|
+
// The row is consumed only after the notice is accepted, and a claim we own is retried
|
|
428
|
+
// on the next tick — a rejected injection must not strand a finished worker as 'running'
|
|
429
|
+
// with no notice, nor consume the row while the result is still undelivered.
|
|
430
|
+
const claimKey = `${paneId}:${current.requestId}`;
|
|
431
|
+
if (claimedKey !== claimKey) {
|
|
432
|
+
if (!h.claimSettleNotice(claimKey)) {
|
|
433
|
+
consume(closing, 'poll-settle', now());
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
claimedKey = claimKey;
|
|
437
|
+
}
|
|
413
438
|
const notes = h.reconcileOnSettlement(current.description, 'settled');
|
|
414
439
|
const statLine = await h.git.worktreeStatLine(entry);
|
|
440
|
+
// `subagent output` may already have returned this exact report: point there, don't repeat it.
|
|
441
|
+
const delivered = closing && entry.reportDelivered?.fingerprint === reportFingerprint(closing)
|
|
442
|
+
? entry.reportDelivered.at
|
|
443
|
+
: null;
|
|
415
444
|
const notice = h.withReconcileNotes(
|
|
416
|
-
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason),
|
|
445
|
+
buildSettlementNoticeText(`${paneId} (${current.description})`, closing, statLine, nullReason, delivered),
|
|
417
446
|
notes,
|
|
418
447
|
);
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
448
|
+
try {
|
|
449
|
+
// Tag the pane: a notice buffered during compaction must keep its row GC-exempt
|
|
450
|
+
// until the post-compaction flush actually delivers it.
|
|
451
|
+
await h.injectNotice(notice, paneId);
|
|
452
|
+
} catch {
|
|
453
|
+
// Notification refused (e.g. the master is compacting): leave the row running and
|
|
454
|
+
// re-detect the same closing text on the next tick.
|
|
455
|
+
await pace();
|
|
456
|
+
continue;
|
|
424
457
|
}
|
|
458
|
+
consume(closing, 'poll-settle', now());
|
|
425
459
|
return;
|
|
426
460
|
}
|
|
427
461
|
}
|
package/src/subagent-session.ts
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
type SessionEntryLike,
|
|
17
17
|
type SubSessionState,
|
|
18
18
|
} from './session-tail.ts';
|
|
19
|
-
import { idParam, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
19
|
+
import { idParam, reportFingerprint, sleep, type AliveProbe, type SubEntry } from './subagent-core.ts';
|
|
20
20
|
import { toolError } from './tool-error.ts';
|
|
21
21
|
|
|
22
22
|
/* ── subagent output delta (pure) ───────────────────────────────── */
|
|
@@ -26,6 +26,61 @@ export const OUTPUT_HARD_CAP_CHARS = 16000;
|
|
|
26
26
|
const OUTPUT_MIN_OVERLAP = 16;
|
|
27
27
|
const OUTPUT_TAIL_CHARS = 500;
|
|
28
28
|
|
|
29
|
+
const BOX_CHARS = '│┃║╭╮╰╯┌┐└┘├┤┬┴┼─━═╌╍';
|
|
30
|
+
const FRAME_ONLY_LINE = new RegExp(`^[\\s${BOX_CHARS}]*[${BOX_CHARS}][\\s${BOX_CHARS}]*$`);
|
|
31
|
+
const SIDE_BORDER = /^\s*[│┃║] ?|\s*[│┃║]\s*$/g;
|
|
32
|
+
/** pi's status footer: `↑43k ↓13k R820k CH99.1% $0.017 …`, preceded by a `~/cwd (branch)` line. */
|
|
33
|
+
const PI_FOOTER_LINE = /^↑[\d.]+[kKmM]?\s+↓[\d.]+[kKmM]?\s/;
|
|
34
|
+
const PI_FOOTER_CWD_LINE = /^~?\/\S*( \([^)]*\))?$/;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Strip TUI chrome from a worker pane read: frame-only box-drawing rows, side borders, right-padding,
|
|
38
|
+
* pi's footer, and blank-line runs. A real read was ~20% whitespace, box glyphs and footer. Applied
|
|
39
|
+
* before the delta, so the cursor tracks the same cleaned text on every call.
|
|
40
|
+
*/
|
|
41
|
+
export function cleanPaneChrome(text: string): string {
|
|
42
|
+
// Frame rows are judged before border stripping, which would otherwise leave them as blank lines.
|
|
43
|
+
const lines = text.split('\n')
|
|
44
|
+
.filter((line) => !FRAME_ONLY_LINE.test(line))
|
|
45
|
+
.map((line) => line.replace(SIDE_BORDER, '').trimEnd());
|
|
46
|
+
const kept: string[] = [];
|
|
47
|
+
for (let i = 0; i < lines.length; i++) {
|
|
48
|
+
const line = lines[i]!;
|
|
49
|
+
if (PI_FOOTER_LINE.test(line)) continue;
|
|
50
|
+
if (PI_FOOTER_CWD_LINE.test(line) && PI_FOOTER_LINE.test(lines[i + 1] ?? '')) continue;
|
|
51
|
+
if (line === '' && kept[kept.length - 1] === '') continue;
|
|
52
|
+
kept.push(line);
|
|
53
|
+
}
|
|
54
|
+
return kept.join('\n');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Normalized-text length of the report opening that identifies the pane's rendered copy. */
|
|
58
|
+
const REPORT_KEY_CHARS = 24;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The pane shows the final report rendered (markdown stripped, long lines cut at pane width), so it
|
|
62
|
+
* is a lossy copy of the transcript text. When the transcript report is attached, cut the pane delta
|
|
63
|
+
* where the report's opening appears (whitespace and markdown markers ignored). Null: no copy found.
|
|
64
|
+
*/
|
|
65
|
+
export function cutPaneReportCopy(delta: string, report: string): string | null {
|
|
66
|
+
const isNoise = (ch: string): boolean => /[\s`*_#>]/.test(ch);
|
|
67
|
+
const key = [...report].filter((ch) => !isNoise(ch)).slice(0, REPORT_KEY_CHARS).join('');
|
|
68
|
+
if (key.length < REPORT_KEY_CHARS) return null;
|
|
69
|
+
let normalized = '';
|
|
70
|
+
const origin: number[] = [];
|
|
71
|
+
for (let i = 0; i < delta.length; i++) {
|
|
72
|
+
if (isNoise(delta[i]!)) continue;
|
|
73
|
+
normalized += delta[i];
|
|
74
|
+
origin.push(i);
|
|
75
|
+
}
|
|
76
|
+
const at = normalized.indexOf(key);
|
|
77
|
+
if (at < 0) return null;
|
|
78
|
+
const lineStart = delta.lastIndexOf('\n', origin[at]!) + 1;
|
|
79
|
+
const before = delta.slice(0, lineStart).trimEnd();
|
|
80
|
+
const marker = '(the pane\'s rendered copy of the final report is omitted — full text in the Subagent Report section below)';
|
|
81
|
+
return before ? `${before}\n${marker}` : marker;
|
|
82
|
+
}
|
|
83
|
+
|
|
29
84
|
export type SubagentStatus = 'running' | 'idle' | 'blocked' | 'settled';
|
|
30
85
|
|
|
31
86
|
export interface SubagentOutputCursor {
|
|
@@ -440,6 +495,8 @@ export async function executeSubagentOutput(
|
|
|
440
495
|
/** Last finalized assistant text from the worker's transcript: the pane read alone cannot
|
|
441
496
|
* recover it when the fullscreen TUI shows only the status overlay. Optional for tests. */
|
|
442
497
|
readFinalReport?: (paneId: string, entryCwd: string) => Promise<string | null>;
|
|
498
|
+
/** Persists `entry.reportDelivered` so the settlement notice can skip a report already shown. */
|
|
499
|
+
persistSubs?: () => void;
|
|
443
500
|
},
|
|
444
501
|
) {
|
|
445
502
|
const rawId = idParam(params, 'agentId', 'taskId');
|
|
@@ -472,8 +529,9 @@ export async function executeSubagentOutput(
|
|
|
472
529
|
const maxChars = typeof params?.max_chars === 'number'
|
|
473
530
|
? params.max_chars
|
|
474
531
|
: typeof params?.maxChars === 'number' ? params.maxChars : undefined;
|
|
475
|
-
const deltaResult = computeSubagentOutputDelta(prevCursor, rawRead.text, { maxChars });
|
|
476
|
-
|
|
532
|
+
const deltaResult = computeSubagentOutputDelta(prevCursor, cleanPaneChrome(rawRead.text), { maxChars });
|
|
533
|
+
// A resumed process has no cursor; the durable delivery record keeps the report from repeating.
|
|
534
|
+
deltaResult.nextCursor.reportTail ??= prevCursor?.reportTail ?? entry.reportDelivered?.fingerprint;
|
|
477
535
|
|
|
478
536
|
// Transcript fallback: the terminal delta can be empty (or footer-only) while the finished
|
|
479
537
|
// report exists only in the session file. Surface it once per distinct report.
|
|
@@ -481,11 +539,16 @@ export async function executeSubagentOutput(
|
|
|
481
539
|
if ((status === 'settled' || status === 'idle') && deps.readFinalReport) {
|
|
482
540
|
try {
|
|
483
541
|
const report = await deps.readFinalReport(entry.paneId, entry.cwd);
|
|
484
|
-
const fingerprint = report ? report
|
|
542
|
+
const fingerprint = report ? reportFingerprint(report) : '';
|
|
485
543
|
if (report && fingerprint && fingerprint !== deltaResult.nextCursor.reportTail) {
|
|
486
544
|
const bounded = boundText(report, Math.max(1, Math.min(maxChars ?? OUTPUT_DEFAULT_MAX_CHARS, OUTPUT_HARD_CAP_CHARS)));
|
|
487
545
|
reportSection = `[Subagent Report | latest finalized message from session transcript${bounded.truncated ? ' | truncated' : ''}]\n${bounded.text}`;
|
|
488
546
|
deltaResult.nextCursor.reportTail = fingerprint;
|
|
547
|
+
deltaResult.delta = cutPaneReportCopy(deltaResult.delta, report) ?? deltaResult.delta;
|
|
548
|
+
if (!bounded.truncated) {
|
|
549
|
+
entry.reportDelivered = { fingerprint, at: Date.now() };
|
|
550
|
+
deps.persistSubs?.();
|
|
551
|
+
}
|
|
489
552
|
}
|
|
490
553
|
} catch {
|
|
491
554
|
}
|