brl-subagent 2.3.5 → 2.3.7
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/AGENT.md +7 -31
- package/README.md +1 -12
- package/package.json +6 -6
- package/src/index.ts +93 -89
- package/src/logging.ts +39 -15
- package/src/notify-completion.ts +38 -7
- package/src/runner.ts +2 -1
- package/src/session-manager.ts +199 -37
- package/src/state.ts +0 -10
- package/src/templates.ts +1 -1
- package/src/transcript.ts +11 -6
- package/src/tui.ts +13 -79
- package/src/types.ts +143 -17
- package/src/update.ts +0 -109
package/AGENT.md
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
|
-
# AGENT.md — brl-subagent capability reference
|
|
1
|
+
# AGENT.md — brl-subagent capability reference
|
|
2
2
|
|
|
3
3
|
> The `delegate_task` prompt guidelines are a derived summary of this file; if they disagree, this file wins.
|
|
4
4
|
|
|
5
|
+
> **Naming:** deliberately `AGENT.md`, **not** `AGENTS.md`. This is *not* a pi
|
|
6
|
+
> context file — it is read on demand via the extension's `delegate_task`
|
|
7
|
+
> guidance, not auto-loaded into the system prompt. Renaming it would change
|
|
8
|
+
> nothing for users (a package directory is not in pi's context-file lookup
|
|
9
|
+
> chain) while adding its full contents to every session run inside this repo.
|
|
10
|
+
|
|
5
11
|
This file is the authoritative reference for how the brl-subagent extension behaves
|
|
6
12
|
and how you (the conductor) should judge a delegation. Read it when planning
|
|
7
13
|
delegation-heavy work.
|
|
@@ -56,33 +62,3 @@ Presets and templates are documented in the schema, not here. Judgment only:
|
|
|
56
62
|
- Recurring task shapes → a saved template.
|
|
57
63
|
|
|
58
64
|
The schema's `delegate_task` parameters, the `templateSummary` and `presetRestrictionSummary` lines in the prompt guidelines, and `get_subagent_result`'s description are the mechanics reference.
|
|
59
|
-
|
|
60
|
-
## Local development
|
|
61
|
-
|
|
62
|
-
This repo is the package source. Daily use runs the **published npm package**;
|
|
63
|
-
development runs the **local checkout**. Both are `pi install` entries and cannot
|
|
64
|
-
coexist — two loaded copies register the same tools — so remove one first.
|
|
65
|
-
|
|
66
|
-
```bash
|
|
67
|
-
# Daily: the published artifact — the same thing users install
|
|
68
|
-
pi remove <local-repo-path>
|
|
69
|
-
pi install npm:brl-subagent
|
|
70
|
-
|
|
71
|
-
# Develop: load the checkout in place — no copying, no syncing
|
|
72
|
-
pi remove npm:brl-subagent
|
|
73
|
-
pi install <local-repo-path> # or a worktree: …/brl-subagent-wt-<n>
|
|
74
|
-
# then /reload pi after edits
|
|
75
|
-
|
|
76
|
-
# Back to the published artifact
|
|
77
|
-
pi remove <local-repo-path>
|
|
78
|
-
pi install npm:brl-subagent
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
A local-path install loads the directory in place and can point at an **unmerged
|
|
82
|
-
worktree**, so it doubles as live verification for a PR. `pi update --extensions`
|
|
83
|
-
advances the npm install and leaves a local-path install alone.
|
|
84
|
-
|
|
85
|
-
(The former `sync-extension.sh` — rsync into `~/.pi/agent/extensions/brl-subagent`
|
|
86
|
-
— was retired once the package was published: the local-path install supersedes it
|
|
87
|
-
and does not fight `pi update --extensions` by mutating a package-manager-owned
|
|
88
|
-
directory.)
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
> Multi-agent orchestration for [pi](https://github.com/earendil-works/pi) — chain, parallel, and dependency-graph delegation to isolated subagents with per-step model routing, preset-driven tool scoping, thinking-level control, and background execution with live monitoring, real abort, and per-agent timeouts.
|
|
4
4
|
|
|
5
|
-
**Version:** 2.3.
|
|
5
|
+
**Version:** 2.3.7 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
|
|
6
6
|
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -35,16 +35,6 @@ pi update --extensions # update installed packages
|
|
|
35
35
|
pi remove npm:brl-subagent # uninstall
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
<details>
|
|
39
|
-
<summary>Alternative: install from git</summary>
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
pi install git:github.com/azmeenafandi/brl-subagent@v2.3.4
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Git installs are **pinned** to the ref you specify. `pi update --extensions` reconciles to that ref but never advances it — upgrading means re-running `pi install` with a newer `@ref`.
|
|
46
|
-
</details>
|
|
47
|
-
|
|
48
38
|
---
|
|
49
39
|
|
|
50
40
|
## Quick reference
|
|
@@ -70,7 +60,6 @@ Git installs are **pinned** to the ref you specify. `pi update --extensions` rec
|
|
|
70
60
|
| `/brl-subagent monitor` | Live monitor running subagents |
|
|
71
61
|
| `/brl-subagent dashboard` | Live observability dashboard |
|
|
72
62
|
| `/brl-subagent retry` | Browse failed runs to retry |
|
|
73
|
-
| `/brl-subagent update-check` | Toggle update check on startup |
|
|
74
63
|
| `/brl-subagent reset` | Reset all configuration |
|
|
75
64
|
|
|
76
65
|
All settings persist across sessions.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brl-subagent",
|
|
3
|
-
"version": "2.3.
|
|
3
|
+
"version": "2.3.7",
|
|
4
4
|
"description": "Subagent extension for pi with configurable model and thinking mode via selection UI",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package"
|
|
@@ -35,14 +35,14 @@
|
|
|
35
35
|
"typebox": "*"
|
|
36
36
|
},
|
|
37
37
|
"devDependencies": {
|
|
38
|
-
"@earendil-works/pi-agent-core": "^0.
|
|
39
|
-
"@earendil-works/pi-ai": "^0.
|
|
40
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
41
|
-
"@earendil-works/pi-tui": "^0.
|
|
38
|
+
"@earendil-works/pi-agent-core": "^0.86.0",
|
|
39
|
+
"@earendil-works/pi-ai": "^0.86.0",
|
|
40
|
+
"@earendil-works/pi-coding-agent": "^0.86.0",
|
|
41
|
+
"@earendil-works/pi-tui": "^0.86.0",
|
|
42
42
|
"jiti": "^2.7.0",
|
|
43
43
|
"typebox": "^1.3.25",
|
|
44
44
|
"typescript": "^5.9.3",
|
|
45
|
-
"vitest": "^
|
|
45
|
+
"vitest": "^5.0.0"
|
|
46
46
|
},
|
|
47
47
|
"overrides": {
|
|
48
48
|
"nanoid": "^3.3.17"
|
package/src/index.ts
CHANGED
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
* See AGENT.md for the conductor-facing capability reference.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import * as fs from "node:fs";
|
|
8
7
|
import * as path from "node:path";
|
|
9
8
|
import type {
|
|
10
9
|
AgentToolResult,
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
EMPTY_USAGE,
|
|
41
40
|
getFinalOutput,
|
|
42
41
|
isSubagentError,
|
|
42
|
+
countSubagentOutcomes,
|
|
43
43
|
classifyError,
|
|
44
44
|
MAX_CHAIN_STEPS,
|
|
45
45
|
MAX_PARALLEL_TASKS,
|
|
@@ -93,7 +93,6 @@ import {
|
|
|
93
93
|
showCompletionNotifySelector,
|
|
94
94
|
showPresetManager,
|
|
95
95
|
showTemplateManager,
|
|
96
|
-
showUpdateCheckToggle,
|
|
97
96
|
showSLAConfig,
|
|
98
97
|
showSLAStats,
|
|
99
98
|
showConfigMenu,
|
|
@@ -104,17 +103,14 @@ import {
|
|
|
104
103
|
renderDelegateCall,
|
|
105
104
|
renderDelegateResult,
|
|
106
105
|
} from "./tui";
|
|
107
|
-
import { createLogger, type Logger } from "./logging";
|
|
106
|
+
import { createLogger, setLogCwd, type Logger } from "./logging";
|
|
108
107
|
import { Intercom } from "./messaging";
|
|
109
|
-
import { checkForUpdates } from "./update";
|
|
110
|
-
import { UPDATE_CHECK_INTERVAL_MS } from "./types";
|
|
111
108
|
import * as eventBus from "./event-bus";
|
|
112
109
|
import {
|
|
113
110
|
buildCompletionMessage,
|
|
114
111
|
resolveDelivery,
|
|
115
112
|
sendCompletionNotification,
|
|
116
113
|
markTerminalSeen,
|
|
117
|
-
normalizeCompletionStatus,
|
|
118
114
|
resolveRunEntry,
|
|
119
115
|
} from "./notify-completion";
|
|
120
116
|
|
|
@@ -198,16 +194,6 @@ export function buildTemplateGuideline(templateSummary: string): string {
|
|
|
198
194
|
export default function (pi: ExtensionAPI) {
|
|
199
195
|
const log = createLogger("brl-subagent");
|
|
200
196
|
|
|
201
|
-
// Read current version from package.json
|
|
202
|
-
const currentVersion = (() => {
|
|
203
|
-
try {
|
|
204
|
-
const pkg = JSON.parse(fs.readFileSync(pkgPath("package.json"), "utf-8"));
|
|
205
|
-
return pkg.version || "0.0.0";
|
|
206
|
-
} catch {
|
|
207
|
-
return "0.0.0";
|
|
208
|
-
}
|
|
209
|
-
})();
|
|
210
|
-
|
|
211
197
|
// F7: Session-bound state — initialized per session
|
|
212
198
|
let state = createSessionState(log);
|
|
213
199
|
|
|
@@ -810,7 +796,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
810
796
|
previousOutput = getFinalOutput(result.messages);
|
|
811
797
|
}
|
|
812
798
|
|
|
813
|
-
chainSuccess = chainResults
|
|
799
|
+
chainSuccess = countSubagentOutcomes(chainResults).failed === 0;
|
|
814
800
|
|
|
815
801
|
// Compute aggregated totals
|
|
816
802
|
const totalInput = chainResults.reduce(
|
|
@@ -1217,11 +1203,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
1217
1203
|
}
|
|
1218
1204
|
|
|
1219
1205
|
// Emit progress update
|
|
1220
|
-
const
|
|
1221
|
-
const
|
|
1222
|
-
(r) => r && r.exitCode === 0,
|
|
1223
|
-
).length;
|
|
1224
|
-
const failed = completed - succeeded;
|
|
1206
|
+
const { succeeded, failed } = countSubagentOutcomes(results);
|
|
1207
|
+
const completed = succeeded + failed;
|
|
1225
1208
|
|
|
1226
1209
|
const partialDetails: ParallelDetails = {
|
|
1227
1210
|
mode: "parallel",
|
|
@@ -1320,8 +1303,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1320
1303
|
(s, r) => s + r.usage.turns,
|
|
1321
1304
|
0,
|
|
1322
1305
|
);
|
|
1323
|
-
const succeeded = finalResults
|
|
1324
|
-
const failed = finalResults.length - succeeded;
|
|
1306
|
+
const { succeeded, failed } = countSubagentOutcomes(finalResults);
|
|
1325
1307
|
|
|
1326
1308
|
const parallelDetails: ParallelDetails = {
|
|
1327
1309
|
mode: "parallel",
|
|
@@ -1824,9 +1806,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1824
1806
|
const totalCost = allResults.reduce((s, r) => s + r.usage.cost, 0);
|
|
1825
1807
|
const totalTurns = allResults.reduce((s, r) => s + r.usage.turns, 0);
|
|
1826
1808
|
|
|
1827
|
-
chainSuccess = allResults.
|
|
1828
|
-
(r) => r.exitCode === 0 && r.stopReason !== "error" && r.stopReason !== "aborted",
|
|
1829
|
-
);
|
|
1809
|
+
chainSuccess = countSubagentOutcomes(allResults).failed === 0;
|
|
1830
1810
|
|
|
1831
1811
|
const graphDetails: GraphDetails = {
|
|
1832
1812
|
mode: "graph",
|
|
@@ -1932,7 +1912,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
1932
1912
|
preset: () => showPresetManager(ctx, state),
|
|
1933
1913
|
templates: () => showTemplateManager(ctx, state),
|
|
1934
1914
|
retry: () => showRetryMenu(ctx, state),
|
|
1935
|
-
"update-check": () => showUpdateCheckToggle(ctx, state, applyConfig),
|
|
1936
1915
|
sla: () => showSLAConfig(ctx, state, applyConfig),
|
|
1937
1916
|
"sla-stats": () => showSLAStats(ctx, state),
|
|
1938
1917
|
};
|
|
@@ -2335,48 +2314,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
2335
2314
|
}, { deliverAs: "followUp" });
|
|
2336
2315
|
}
|
|
2337
2316
|
|
|
2338
|
-
//
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
if (!hasChain && !hasParallel && !hasGraph) {
|
|
2343
|
-
// task is optional per the schema; single mode requires it. When absent,
|
|
2344
|
-
// feed "" to the sanitizer — same rejection path ("Task must not be
|
|
2345
|
-
// empty.") as when task was a required field.
|
|
2346
|
-
const taskResult = sanitizeTask(params.task ?? "");
|
|
2347
|
-
if (!taskResult.ok) {
|
|
2348
|
-
log.warn("Task rejected by sanitizer", { error: taskResult.error });
|
|
2349
|
-
return {
|
|
2350
|
-
content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
|
|
2351
|
-
details: undefined,
|
|
2352
|
-
isError: true,
|
|
2353
|
-
};
|
|
2354
|
-
}
|
|
2355
|
-
params.task = taskResult.value;
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
// Handle retryRunId
|
|
2359
|
-
if (params.retryRunId) {
|
|
2360
|
-
const runEntry = state.findRunById(ctx, params.retryRunId);
|
|
2361
|
-
if (runEntry) {
|
|
2362
|
-
params = resolveRetryParams(params, runEntry);
|
|
2363
|
-
} else {
|
|
2364
|
-
// Issue #98: a silent no-op here made retries of background runs
|
|
2365
|
-
// (which previously never wrote run entries) start as FRESH runs
|
|
2366
|
-
// with no signal to the caller. Fail loudly instead.
|
|
2367
|
-
log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
|
|
2368
|
-
return {
|
|
2369
|
-
content: [{
|
|
2370
|
-
type: "text" as const,
|
|
2371
|
-
text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix (issue #98). Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
|
|
2372
|
-
}],
|
|
2373
|
-
details: undefined,
|
|
2374
|
-
isError: true,
|
|
2375
|
-
};
|
|
2376
|
-
}
|
|
2377
|
-
}
|
|
2378
|
-
|
|
2379
|
-
// Handle template resolution
|
|
2317
|
+
// Handle template resolution — MUST run before the single-mode sanitize
|
|
2318
|
+
// block below: a template ASSIGNS params.task, so resolving first lets the
|
|
2319
|
+
// sanitizer validate the real resolved body (a template-only call has no
|
|
2320
|
+
// task yet) and lets mode detection see a non-empty task (issue #175).
|
|
2380
2321
|
if (params.template) {
|
|
2381
2322
|
const templateEntry = state.config.templates.find((t) => t.name === params.template);
|
|
2382
2323
|
if (!templateEntry) {
|
|
@@ -2408,7 +2349,34 @@ export default function (pi: ExtensionAPI) {
|
|
|
2408
2349
|
}
|
|
2409
2350
|
|
|
2410
2351
|
const tv = resolved.value;
|
|
2411
|
-
//
|
|
2352
|
+
// Issue #175 review (fix 4): template-wins silently replaces a
|
|
2353
|
+
// supplied `task`. If that task would itself have failed sanitization
|
|
2354
|
+
// (e.g. over-length), the caller gets no signal — pre-#175 `dev`
|
|
2355
|
+
// rejected it. Warn (never reject — #99/#110 precedent, same
|
|
2356
|
+
// pi.sendMessage channel as the unknown-param warning) so both the LLM
|
|
2357
|
+
// and the human see the ignored task. Emitted ONLY when the supplied
|
|
2358
|
+
// task actually fails sanitization: a valid task alongside a template
|
|
2359
|
+
// is the intended template-wins override (issue #57), not noise.
|
|
2360
|
+
if (params.task !== undefined) {
|
|
2361
|
+
const suppliedTask = sanitizeTask(params.task);
|
|
2362
|
+
if (!suppliedTask.ok) {
|
|
2363
|
+
log.warn("delegate_task: supplied task ignored because a template was given", {
|
|
2364
|
+
template: params.template,
|
|
2365
|
+
error: suppliedTask.error,
|
|
2366
|
+
});
|
|
2367
|
+
pi.sendMessage({
|
|
2368
|
+
customType: "delegate-notification",
|
|
2369
|
+
content: `delegate_task: the supplied 'task' was ignored because a template ('${params.template}') was given. The task would otherwise have been rejected: ${suppliedTask.error}`,
|
|
2370
|
+
display: true,
|
|
2371
|
+
details: { template: params.template, rejectedTask: suppliedTask.error },
|
|
2372
|
+
}, { deliverAs: "followUp" });
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
// Issue #175 review (fix direction item 4): this assignment is
|
|
2376
|
+
// UNCONDITIONAL — the resolved template task replaces any supplied
|
|
2377
|
+
// `task` (template-wins, issue #57); it is NOT a default the caller can
|
|
2378
|
+
// override. The fields below, by contrast, ARE defaults: an explicitly
|
|
2379
|
+
// provided param wins over the template's value.
|
|
2412
2380
|
params.task = tv.task;
|
|
2413
2381
|
if (tv.preset && !params.preset) params.preset = tv.preset;
|
|
2414
2382
|
if (tv.thinkingLevel && !params.thinkingLevel) params.thinkingLevel = tv.thinkingLevel;
|
|
@@ -2420,6 +2388,53 @@ export default function (pi: ExtensionAPI) {
|
|
|
2420
2388
|
if (tv.inheritSystemPrompt !== undefined && params.inheritSystemPrompt === undefined) params.inheritSystemPrompt = tv.inheritSystemPrompt;
|
|
2421
2389
|
}
|
|
2422
2390
|
|
|
2391
|
+
// Handle retryRunId — MUST run after template resolution and before the
|
|
2392
|
+
// single-mode sanitize block. resolveRetryParams assigns params.task from
|
|
2393
|
+
// the recorded run, so the sanitizer must see the retried body (a
|
|
2394
|
+
// retry-only call has no task yet). It MUST run AFTER template resolution:
|
|
2395
|
+
// resolveRetryParams returns a fresh object that DROPS `template`, so
|
|
2396
|
+
// resolving templates first keeps a combined template + retryRunId call
|
|
2397
|
+
// from silently disabling template resolution (issue #175 review).
|
|
2398
|
+
if (params.retryRunId) {
|
|
2399
|
+
const runEntry = state.findRunById(ctx, params.retryRunId);
|
|
2400
|
+
if (runEntry) {
|
|
2401
|
+
params = resolveRetryParams(params, runEntry);
|
|
2402
|
+
} else {
|
|
2403
|
+
// Issue #98: a silent no-op here made retries of background runs
|
|
2404
|
+
// (which previously never wrote run entries) start as FRESH runs
|
|
2405
|
+
// with no signal to the caller. Fail loudly instead.
|
|
2406
|
+
log.warn("Retry run ID not found", { retryRunId: params.retryRunId });
|
|
2407
|
+
return {
|
|
2408
|
+
content: [{
|
|
2409
|
+
type: "text" as const,
|
|
2410
|
+
text: `Retry run ID not found: ${params.retryRunId}. The run may have been pruned, or it was a background run created before the run-entry fix. Pass the run's agent ID — for background runs the agent ID and run ID are now the same.`,
|
|
2411
|
+
}],
|
|
2412
|
+
details: undefined,
|
|
2413
|
+
isError: true,
|
|
2414
|
+
};
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
// F1: Sanitize task input — skip for chain/parallel/graph modes
|
|
2419
|
+
const hasChain = params.chain && params.chain.length > 0;
|
|
2420
|
+
const hasParallel = params.tasks && params.tasks.length > 0;
|
|
2421
|
+
const hasGraph = params.graph && params.graph.length > 0;
|
|
2422
|
+
if (!hasChain && !hasParallel && !hasGraph) {
|
|
2423
|
+
// task is optional per the schema; single mode requires it. When absent,
|
|
2424
|
+
// feed "" to the sanitizer — same rejection path ("Task must not be
|
|
2425
|
+
// empty.") as when task was a required field.
|
|
2426
|
+
const taskResult = sanitizeTask(params.task ?? "");
|
|
2427
|
+
if (!taskResult.ok) {
|
|
2428
|
+
log.warn("Task rejected by sanitizer", { error: taskResult.error });
|
|
2429
|
+
return {
|
|
2430
|
+
content: [{ type: "text" as const, text: `Invalid task: ${taskResult.error}` }],
|
|
2431
|
+
details: undefined,
|
|
2432
|
+
isError: true,
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
params.task = taskResult.value;
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2423
2438
|
// P1+P2+P10: Mode detection — graph > chain > parallel > single
|
|
2424
2439
|
const isChain = hasChain;
|
|
2425
2440
|
const isParallel = hasParallel;
|
|
@@ -3696,6 +3711,9 @@ export default function (pi: ExtensionAPI) {
|
|
|
3696
3711
|
pi.on("session_start", async (_event, ctx) => {
|
|
3697
3712
|
// Issue #147: capture the session context for the completion-push subscriber.
|
|
3698
3713
|
sessionCtx = ctx;
|
|
3714
|
+
// Issue #179 (D6): createLogger ran at module load with no cwd — point
|
|
3715
|
+
// the shared file sink at this session's cwd now that it exists.
|
|
3716
|
+
setLogCwd(ctx.cwd);
|
|
3699
3717
|
// Load built-in presets
|
|
3700
3718
|
const presetsDir = pkgPath("presets");
|
|
3701
3719
|
state.builtinPresets = loadBuiltinPresets(presetsDir, log);
|
|
@@ -3740,23 +3758,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
3740
3758
|
// F5/F9: Safe state restoration with type guards
|
|
3741
3759
|
state.restoreFromSession(ctx);
|
|
3742
3760
|
|
|
3743
|
-
// Check for updates (non-blocking, once per 24h)
|
|
3744
|
-
if (state.config.updateCheckEnabled) {
|
|
3745
|
-
const now = Date.now();
|
|
3746
|
-
if (now - state.config.lastUpdateCheck > UPDATE_CHECK_INTERVAL_MS) {
|
|
3747
|
-
state.config.lastUpdateCheck = now;
|
|
3748
|
-
checkForUpdates(currentVersion, log).then((result) => {
|
|
3749
|
-
if (result?.available) {
|
|
3750
|
-
ctx.ui.notify(
|
|
3751
|
-
"brl-subagent " + result.version + " available (current: " + currentVersion + "). Visit " + result.url + " to update. /brl-subagent update-check to disable.",
|
|
3752
|
-
"info"
|
|
3753
|
-
);
|
|
3754
|
-
log.info("Update available", { current: currentVersion, latest: result.version });
|
|
3755
|
-
}
|
|
3756
|
-
}).catch(() => {}); // silently ignore errors
|
|
3757
|
-
}
|
|
3758
|
-
}
|
|
3759
|
-
|
|
3760
3761
|
updateStatus(state, ctx);
|
|
3761
3762
|
});
|
|
3762
3763
|
|
|
@@ -3819,7 +3820,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
3819
3820
|
const knob = state.config.completionNotify ?? "all";
|
|
3820
3821
|
const run = resolveRunEntry(state.getRunEntries(ctx), id);
|
|
3821
3822
|
const message = buildCompletionMessage(agent, run);
|
|
3822
|
-
|
|
3823
|
+
// Issue #179 (D2): resolve delivery from the CLASSIFIED notification
|
|
3824
|
+
// status, not the raw agent status — otherwise a run whose completion
|
|
3825
|
+
// the builder downgraded to failed would still be delivered as a success.
|
|
3826
|
+
const delivery = resolveDelivery(message.details.status, knob);
|
|
3823
3827
|
sendCompletionNotification(pi, message, delivery);
|
|
3824
3828
|
} catch (err) {
|
|
3825
3829
|
log.warn(`completion-push handler failed for ${id}`, {
|
package/src/logging.ts
CHANGED
|
@@ -8,6 +8,9 @@
|
|
|
8
8
|
* const log = createLogger("runner", cwd);
|
|
9
9
|
* log.info("Subagent started", { model, thinkingLevel });
|
|
10
10
|
* log.error("Subagent failed", { error: err.message });
|
|
11
|
+
*
|
|
12
|
+
* Module-load callers pass no cwd; they call setLogCwd(ctx.cwd) once the
|
|
13
|
+
* session starts and all loggers share <cwd>/.pi/subagent-logs/brl-subagent.log.
|
|
11
14
|
*/
|
|
12
15
|
|
|
13
16
|
import * as fs from "node:fs";
|
|
@@ -24,6 +27,12 @@ const MAX_LOG_SIZE = 5 * 1024 * 1024;
|
|
|
24
27
|
/** Maximum number of rotated log files to keep */
|
|
25
28
|
const MAX_LOG_FILES = 5;
|
|
26
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The single log file every logger writes to (issue #179). The JSON entry body
|
|
32
|
+
* already carries its `prefix`, so one shared file stays readable.
|
|
33
|
+
*/
|
|
34
|
+
const LOG_FILE_NAME = "brl-subagent.log";
|
|
35
|
+
|
|
27
36
|
/** Log levels in order of verbosity */
|
|
28
37
|
const LOG_LEVELS: Record<LogLevel, number> = {
|
|
29
38
|
debug: 0,
|
|
@@ -50,23 +59,33 @@ export function setLogLevel(level: LogLevel): void {
|
|
|
50
59
|
minLevel = level;
|
|
51
60
|
}
|
|
52
61
|
|
|
62
|
+
// Issue #179 (D6): createLogger() runs at MODULE LOAD (index.ts,
|
|
63
|
+
// session-manager.ts) where the session cwd does not exist yet. The cwd is set
|
|
64
|
+
// later from the session_start hook (ctx.cwd). All logger instances share this
|
|
65
|
+
// module-level value so every entry lands in ONE
|
|
66
|
+
// `<cwd>/.pi/subagent-logs/brl-subagent.log`.
|
|
67
|
+
let logCwd: string | undefined;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Point file logging at a session cwd (or clear it with undefined). Called from
|
|
71
|
+
* the session-start hook so module-load loggers can write once a cwd exists.
|
|
72
|
+
*/
|
|
73
|
+
export function setLogCwd(cwd: string | undefined): void {
|
|
74
|
+
logCwd = cwd;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function resolveLogDir(): string | undefined {
|
|
78
|
+
return logCwd ? path.join(logCwd, ".pi", "subagent-logs") : undefined;
|
|
79
|
+
}
|
|
80
|
+
|
|
53
81
|
/**
|
|
54
82
|
* Create a logger for a specific module.
|
|
55
|
-
* Logs are written to
|
|
83
|
+
* Logs are written to `<cwd>/.pi/subagent-logs/brl-subagent.log` relative to the
|
|
84
|
+
* session cwd (set via setLogCwd or the optional `cwd` arg).
|
|
56
85
|
*/
|
|
57
86
|
export function createLogger(prefix: string, cwd?: string): Logger {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
// Ensure log directory exists — owner-only (0o700) so other local users
|
|
61
|
-
// cannot list log files (F6 / issue #29). File writes below already use
|
|
62
|
-
// 0o600.
|
|
63
|
-
if (logDir) {
|
|
64
|
-
try {
|
|
65
|
-
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
66
|
-
} catch {
|
|
67
|
-
// Can't create log dir — fall back to console-only logging
|
|
68
|
-
}
|
|
69
|
-
}
|
|
87
|
+
// Compat: an explicit cwd argument still sets the shared location eagerly.
|
|
88
|
+
if (cwd) logCwd = cwd;
|
|
70
89
|
|
|
71
90
|
function log(level: LogLevel, message: string, data?: Record<string, unknown>): void {
|
|
72
91
|
if (LOG_LEVELS[level] < LOG_LEVELS[minLevel]) return;
|
|
@@ -86,10 +105,15 @@ export function createLogger(prefix: string, cwd?: string): Logger {
|
|
|
86
105
|
const consoleMethod = level === "error" ? console.error : level === "warn" ? console.warn : console.log;
|
|
87
106
|
consoleMethod(`[${prefix}] ${level.toUpperCase()}: ${message}`);
|
|
88
107
|
|
|
89
|
-
// File output
|
|
108
|
+
// File output — resolved on each call because the cwd may be set after
|
|
109
|
+
// createLogger ran (module load) but before the first entry.
|
|
110
|
+
const logDir = resolveLogDir();
|
|
90
111
|
if (logDir) {
|
|
91
112
|
try {
|
|
92
|
-
|
|
113
|
+
// Owner-only (0o700) so other local users cannot list log files
|
|
114
|
+
// (F6 / issue #29); the file writes below already use 0o600.
|
|
115
|
+
fs.mkdirSync(logDir, { recursive: true, mode: 0o700 });
|
|
116
|
+
const logFile = path.join(logDir, LOG_FILE_NAME);
|
|
93
117
|
rotateIfNeeded(logFile);
|
|
94
118
|
fs.appendFileSync(logFile, line + "\n", { encoding: "utf-8", mode: 0o600 });
|
|
95
119
|
} catch {
|
package/src/notify-completion.ts
CHANGED
|
@@ -25,6 +25,7 @@ import type {
|
|
|
25
25
|
} from "./types";
|
|
26
26
|
import { truncateTail } from "./transcript-tail";
|
|
27
27
|
import { formatRunDuration } from "./history";
|
|
28
|
+
import { classifyTerminalOutcome } from "./types";
|
|
28
29
|
|
|
29
30
|
/** Terminal statuses the completion message can carry. */
|
|
30
31
|
export type CompletionStatus = "completed" | "failed" | "stopped";
|
|
@@ -35,6 +36,8 @@ export interface CompletionMessageDetails {
|
|
|
35
36
|
status: CompletionStatus;
|
|
36
37
|
errorCategory: string;
|
|
37
38
|
errorMessage?: string;
|
|
39
|
+
/** Issue #179 (D2): the terminal stopReason behind the classified status. */
|
|
40
|
+
stopReason?: string;
|
|
38
41
|
costUsd?: number;
|
|
39
42
|
tokensIn?: number;
|
|
40
43
|
tokensOut?: number;
|
|
@@ -59,12 +62,12 @@ const DEDUPE_CAP = 200;
|
|
|
59
62
|
|
|
60
63
|
/**
|
|
61
64
|
* Soft directive appended to the content (D3): the conductor should process
|
|
62
|
-
* the completion silently unless action is needed
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
+
* the completion silently unless action is needed. If the run failed, it
|
|
66
|
+
* should investigate the cause before re-dispatching rather than re-issuing an
|
|
67
|
+
* identical task — any re-issue is confirmed with the user first.
|
|
65
68
|
*/
|
|
66
69
|
const COMPLETION_DIRECTIVE =
|
|
67
|
-
"Process this completion silently unless action is needed. If the run failed,
|
|
70
|
+
"Process this completion silently unless action is needed. If the run failed, investigate before re-dispatching — do not re-issue an identical task without confirming the cause with the user.";
|
|
68
71
|
|
|
69
72
|
/**
|
|
70
73
|
* Normalize a BackgroundAgent status to a terminal status word. The subscriber
|
|
@@ -128,23 +131,50 @@ export function buildCompletionMessage(
|
|
|
128
131
|
agent: BackgroundAgent,
|
|
129
132
|
run: SubagentRun | undefined,
|
|
130
133
|
): CompletionMessagePayload {
|
|
131
|
-
|
|
134
|
+
let status = normalizeCompletionStatus(agent.status);
|
|
135
|
+
// Issue #179 (D2): the notification category must NEVER default to success
|
|
136
|
+
// merely because `agent.status === "completed"`. The SDK resolves a mid-run
|
|
137
|
+
// provider death, so the run entry is the authoritative classified reason —
|
|
138
|
+
// downgrade a stale "completed" when the run says otherwise.
|
|
139
|
+
if (status === "completed" && run) {
|
|
140
|
+
const outcome = classifyTerminalOutcome(run.stopReason, run.errorMessage);
|
|
141
|
+
if (outcome.status !== "completed") {
|
|
142
|
+
status = outcome.status;
|
|
143
|
+
} else if (run.status === "failed") {
|
|
144
|
+
status = "failed";
|
|
145
|
+
}
|
|
146
|
+
}
|
|
132
147
|
// BackgroundAgent has no `label` field — the caller's label is `description`
|
|
133
148
|
// (set from params.description at spawn). Fall back to the id.
|
|
134
149
|
const label = agent.description || agent.id;
|
|
135
150
|
// completed → "success"; failed/stopped → classified category from the run
|
|
136
151
|
// record (finalizeRunEntry stamps it), falling back to "unknown".
|
|
137
152
|
const errorCategory =
|
|
138
|
-
status === "completed"
|
|
153
|
+
status === "completed"
|
|
154
|
+
? "success"
|
|
155
|
+
: (run?.errorCategory ?? run?.originalParams?.errorCategory ?? "unknown");
|
|
139
156
|
const durationMs = run?.durationMs;
|
|
140
157
|
const costUsd = run?.cost;
|
|
141
158
|
const tokensIn = run?.tokensIn;
|
|
142
159
|
const tokensOut = run?.tokensOut;
|
|
160
|
+
const stopReason = run?.stopReason;
|
|
143
161
|
const errorMessage = status === "completed" ? undefined : agent.error ?? run?.errorMessage;
|
|
144
162
|
|
|
145
163
|
const summary = buildSummaryLine(label, agent.id, status, durationMs, costUsd, errorCategory);
|
|
146
|
-
|
|
164
|
+
// Issue #179 (C1 fix): truncateTail keeps the LAST 15 lines, but the failure
|
|
165
|
+
// headline is PREPENDED to the stored output — long earlier-turn prose would
|
|
166
|
+
// otherwise push the failure reason out of the notified body. Peel it off
|
|
167
|
+
// before tailing and re-add it as its own part so the reason is never
|
|
168
|
+
// truncated away.
|
|
169
|
+
const stored = agent.finalOutput ?? run?.fullOutput ?? "";
|
|
170
|
+
const headline = status === "completed" ? undefined : errorMessage;
|
|
171
|
+
const body =
|
|
172
|
+
headline && stored.startsWith(headline)
|
|
173
|
+
? stored.slice(headline.length).replace(/^\n+/, "")
|
|
174
|
+
: stored;
|
|
175
|
+
const tail = truncateTail(body, 15);
|
|
147
176
|
const parts = [summary];
|
|
177
|
+
if (headline) parts.push(headline);
|
|
148
178
|
if (tail) parts.push(tail);
|
|
149
179
|
parts.push(COMPLETION_DIRECTIVE);
|
|
150
180
|
const content = parts.join("\n\n");
|
|
@@ -154,6 +184,7 @@ export function buildCompletionMessage(
|
|
|
154
184
|
status,
|
|
155
185
|
errorCategory,
|
|
156
186
|
...(errorMessage ? { errorMessage } : {}),
|
|
187
|
+
...(stopReason ? { stopReason } : {}),
|
|
157
188
|
...(costUsd !== undefined ? { costUsd } : {}),
|
|
158
189
|
...(tokensIn !== undefined ? { tokensIn } : {}),
|
|
159
190
|
...(tokensOut !== undefined ? { tokensOut } : {}),
|
package/src/runner.ts
CHANGED
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
SIGKILL_GRACE_MS,
|
|
28
28
|
TEMP_FILE_MODE,
|
|
29
29
|
MAX_TEMP_DIR_AGE_MS,
|
|
30
|
+
isProviderError,
|
|
30
31
|
classifyError,
|
|
31
32
|
SUBAGENT_ABORTED_MESSAGE,
|
|
32
33
|
} from "./types";
|
|
@@ -666,7 +667,7 @@ export function parseSubagentLine(
|
|
|
666
667
|
function countModelErrorTurns(messages: Array<Record<string, unknown>>): number {
|
|
667
668
|
let count = 0;
|
|
668
669
|
for (const msg of messages) {
|
|
669
|
-
if (msg.role === "assistant" && msg.stopReason
|
|
670
|
+
if (msg.role === "assistant" && isProviderError(msg.stopReason as string | undefined)) count++;
|
|
670
671
|
}
|
|
671
672
|
return count;
|
|
672
673
|
}
|