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 CHANGED
@@ -1,7 +1,13 @@
1
- # AGENT.md — brl-subagent capability reference (v2.3.4+)
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.4 · **Author:** Azmeen Afandi / [Beeroo Labs](https://beeroolabs.com) · **License:** MIT
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.5",
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.85.1",
39
- "@earendil-works/pi-ai": "^0.85.1",
40
- "@earendil-works/pi-coding-agent": "^0.85.1",
41
- "@earendil-works/pi-tui": "^0.85.1",
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": "^4.1.11"
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.every((r) => r.exitCode === 0);
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 completed = results.filter(Boolean).length;
1221
- const succeeded = results.filter(
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.filter((r) => r.exitCode === 0).length;
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.every(
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
- // F1: Sanitize task input — skip for chain/parallel modes
2339
- const hasChain = params.chain && params.chain.length > 0;
2340
- const hasParallel = params.tasks && params.tasks.length > 0;
2341
- const hasGraph = params.graph && params.graph.length > 0;
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
- // Use resolved template fields as defaults, explicitly provided params override
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
- const delivery = resolveDelivery(normalizeCompletionStatus(agent.status), knob);
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 `.pi/subagent-logs/<prefix>.log` relative to `cwd`.
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
- const logDir = cwd ? path.join(cwd, ".pi", "subagent-logs") : undefined;
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
- const logFile = path.join(logDir, `${prefix}.log`);
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 {
@@ -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; on failure apply the retry
63
- * taxonomy (re-dispatch ≠ retry; Rule 18 governs terminations — ask the user
64
- * before terminating anything).
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, apply the retry taxonomy: a re-dispatch is not a retry, and Rule 18 governs terminations — ask the user before terminating anything.";
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
- const status = normalizeCompletionStatus(agent.status);
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" ? "success" : (run?.originalParams?.errorCategory ?? "unknown");
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
- const tail = truncateTail(agent.finalOutput ?? run?.fullOutput ?? "", 15);
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 === "error") count++;
670
+ if (msg.role === "assistant" && isProviderError(msg.stopReason as string | undefined)) count++;
670
671
  }
671
672
  return count;
672
673
  }