brl-subagent 2.3.9 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,9 @@
1
+ // Purpose: Background execution: SDK sessions (`createAgentSession`), agent records, timeouts, steering, and settle paths.
1
2
  import { randomUUID } from 'crypto';
2
3
  import { join } from 'path';
3
- import { mkdirSync, writeFileSync, readFileSync, existsSync, readdirSync } from 'fs';
4
+ import { mkdirSync, writeFileSync, readFileSync, readdirSync, existsSync } from 'fs';
4
5
  import type { BackgroundAgent, AgentStatus, GitMode, SubagentResult, SubagentRun, ThinkingLevel, SubagentToolOptions, UsageStats, ErrorCategory } from './types';
5
- import { EMPTY_USAGE, CUSTOM_ENTRY_TYPES, classifyError, classifyTerminalOutcome, isSubagentError, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
6
+ import { EMPTY_USAGE, classifyError, classifyTerminalOutcome, isProviderError, coherentFailureReason, SUBAGENT_ABORTED_MESSAGE } from './types';
6
7
  import { accumulateUsage } from './runner';
7
8
  import * as eventBus from './event-bus';
8
9
  import * as transcript from './transcript';
@@ -10,7 +11,11 @@ import { createEvent } from './event-bus';
10
11
  import { assertSafeAgentId, sanitizeErrorMessage } from './sanitize';
11
12
  import { wrapTask } from './prompt';
12
13
  import { createLogger } from './logging';
13
- import { getCurrentBranch, createWorkBranch, captureDiff, switchToBranch, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
14
+ import { getCurrentBranch, getHeadState, restoreHead, createWorkBranch, captureDiff, deleteBranch, hasUncommittedChanges, getRepoRoot, commitAll, captureWorkingDiff } from './git';
15
+ import type { HeadState } from './git';
16
+ import { normalizeTimeout, DEFAULT_BACKGROUND_DEADLINE_MS } from './validate';
17
+ import { currentProcessOwner } from './recovery';
18
+ import { persistRunRecord } from './run-registry';
14
19
 
15
20
  const log = createLogger('brl-subagent');
16
21
  import type { ExtensionAPI, ExtensionContext } from '@earendil-works/pi-coding-agent';
@@ -83,7 +88,7 @@ function persistAgent(agent: BackgroundAgent): void {
83
88
  try {
84
89
  assertSafeAgentId(agent.id);
85
90
  } catch {
86
- console.error(`[brl-subagent] Refusing to persist agent with invalid id: ${agent.id}`);
91
+ log.error(`Refusing to persist agent with invalid id: ${agent.id}`);
87
92
  return;
88
93
  }
89
94
  ensureStorageDir();
@@ -100,7 +105,7 @@ function persistAgent(agent: BackgroundAgent): void {
100
105
  writeFileSync(filePath, JSON.stringify(persistable, null, 2), { encoding: 'utf-8', mode: 0o600 });
101
106
  } catch (err) {
102
107
  // Log but never throw — persistence must not break execution
103
- console.error(`[brl-subagent] Failed to persist agent ${agent.id}:`, err);
108
+ log.error(`Failed to persist agent ${agent.id}`, { error: err instanceof Error ? err.message : String(err) });
104
109
  }
105
110
  }
106
111
 
@@ -117,49 +122,6 @@ function loadAgent(id: string): BackgroundAgent | null {
117
122
  }
118
123
  }
119
124
 
120
- /**
121
- * Create a new background agent session
122
- *
123
- * NOTE: In v2.0.3, this creates a record but does NOT actually spawn a pi session.
124
- * The actual session spawning will be implemented when pi's ExtensionAPI supports it.
125
- * For now, this is a placeholder that creates the agent record.
126
- */
127
- export function createSession(params: {
128
- task: string;
129
- type?: string;
130
- description?: string;
131
- model?: string;
132
- thinkingLevel?: ThinkingLevel;
133
- systemPrompt?: string;
134
- }): BackgroundAgent {
135
- const id = generateUUID();
136
- const agent: BackgroundAgent = {
137
- id,
138
- sessionId: `session-${id}`,
139
- type: params.type || 'general-purpose',
140
- description: params.description || params.task.slice(0, 50),
141
- status: 'pending',
142
- startedAt: Date.now(),
143
- task: params.task,
144
- model: params.model || 'unknown',
145
- thinkingLevel: params.thinkingLevel || 'medium',
146
- };
147
-
148
- agents.set(id, agent);
149
- persistAgent(agent);
150
-
151
- // Start transcript for this agent
152
- transcript.startTranscript(agent.id, params.task);
153
-
154
- eventBus.emit(eventBus.createEvent('subagent:created', agent.id, {
155
- type: agent.type,
156
- description: agent.description,
157
- task: agent.task,
158
- }));
159
-
160
- return agent;
161
- }
162
-
163
125
  /**
164
126
  * Get agent record by ID
165
127
  */
@@ -178,20 +140,51 @@ export function getAgent(id: string): BackgroundAgent | null {
178
140
  }
179
141
 
180
142
  /**
181
- * List all background agents
143
+ * Option B U1: every agent record persisted on disk (cross-session — the
144
+ * `.pi/subagents/` store survives a process death; the conductor's session run
145
+ * entries do not). Boot recovery does NOT use this: it sources candidates from
146
+ * the durable run registry and reads background records ONE at a time by id
147
+ * (`recoverInflightRuns`'s `readAgentRecord`), precisely to avoid parsing the
148
+ * whole store; production has no caller for this sweep. Unreadable/corrupt
149
+ * files and unsafe ids are skipped, never thrown.
182
150
  */
183
- export function listAgents(): BackgroundAgent[] {
184
- ensureStorageDir();
185
- const files = readdirSync(STORAGE_DIR).filter(f => f.endsWith('.json'));
186
- const result: BackgroundAgent[] = [];
187
-
151
+ export function listPersistedAgents(): BackgroundAgent[] {
152
+ let files: string[];
153
+ try {
154
+ if (!existsSync(STORAGE_DIR)) return [];
155
+ files = readdirSync(STORAGE_DIR);
156
+ } catch {
157
+ return [];
158
+ }
159
+ const records: BackgroundAgent[] = [];
188
160
  for (const file of files) {
189
- const id = file.replace('.json', '');
190
- const agent = agents.get(id) || loadAgent(id);
191
- if (agent) result.push(agent);
161
+ if (!file.endsWith('.json')) continue;
162
+ const id = file.slice(0, -'.json'.length);
163
+ try {
164
+ assertSafeAgentId(id);
165
+ } catch {
166
+ continue;
167
+ }
168
+ const record = loadAgent(id);
169
+ if (record) records.push(record);
192
170
  }
193
-
194
- return result.sort((a, b) => b.startedAt - a.startedAt);
171
+ return records;
172
+ }
173
+
174
+ /**
175
+ * Option B U1 (D3): read-modify-write the interrupted mark onto a persisted
176
+ * agent record. Idempotent: an already-marked record is returned unchanged.
177
+ * Returns null when the record does not exist.
178
+ */
179
+ export function markAgentInterrupted(id: string, interruptedAt: string): BackgroundAgent | null {
180
+ const agent = getAgent(id);
181
+ if (!agent) return null;
182
+ if (!agent.interruptedAt) {
183
+ agent.interruptedAt = interruptedAt;
184
+ agents.set(id, agent);
185
+ persistAgent(agent);
186
+ }
187
+ return agent;
195
188
  }
196
189
 
197
190
  /**
@@ -225,25 +218,6 @@ export function updateAgentStatus(id: string, status: AgentStatus, error?: strin
225
218
  return agent;
226
219
  }
227
220
 
228
- /**
229
- * Set agent result
230
- */
231
- export function setAgentResult(id: string, result: SubagentResult): BackgroundAgent | null {
232
- const agent = getAgent(id);
233
- if (!agent) return null;
234
-
235
- agent.result = result;
236
- // Issue #179 (D1): honor the full terminal reason (stopReason error/aborted/
237
- // length/incomplete), not exitCode alone — a mid-run provider death resolves
238
- // with exitCode 0 and was previously recorded as 'completed'.
239
- agent.status = isSubagentError(result) ? 'failed' : 'completed';
240
- agent.completedAt = Date.now();
241
-
242
- agents.set(id, agent);
243
- persistAgent(agent);
244
- return agent;
245
- }
246
-
247
221
  /**
248
222
  * Issue #179 (D4): when a run ends in a failure, the prose extracted by
249
223
  * extractFinalOutput may come from an EARLIER turn — the failing turn carries
@@ -281,7 +255,7 @@ function defaultTerminalMessage(stopReason: string | undefined): string {
281
255
  * Extract the final assistant text from a session's messages.
282
256
  * Falls back to the LAST assistant message with non-empty text content — the
283
257
  * final message is often a tool-call-only turn (agent stopped mid-turn, failed,
284
- * or hit the hard cap), and that turn carries no text of its own.
258
+ * or hit the deadline), and that turn carries no text of its own.
285
259
  */
286
260
  export function extractFinalOutput(session: { messages: Array<{ role: string; content?: Array<{ type: string; text?: string }> | string | null }> }): string {
287
261
  const assistants = [...session.messages].reverse().filter(m => m.role === 'assistant');
@@ -351,24 +325,45 @@ export async function stopAgent(id: string): Promise<BackgroundAgent | null> {
351
325
  }
352
326
 
353
327
  /**
354
- * Steer a running agent by injecting a message
355
- *
356
- * NOTE: In v2.0.3, this is a placeholder.
357
- * Actual message injection will be implemented when pi's ExtensionAPI supports it.
328
+ * Steer a running agent by injecting a message into its live session.
329
+ *
330
+ * Issue #241: steering DELIVERS via the SDK's `AgentSession.steer()` — the
331
+ * message is queued while the agent is running and delivered after the current
332
+ * assistant turn finishes its tool calls, before the next model call.
333
+ *
334
+ * Steering is NOT a state transition: the agent stays 'running' (no status
335
+ * flip, no persistAgent) so REPEAT steers keep working. Delivery failures
336
+ * propagate to the caller — a false success would tell the conductor a steer
337
+ * landed when the agent never saw it. The transcript line is an audit record
338
+ * written AFTER successful delivery, best-effort: a landed steer must never be
339
+ * reported as failed because the audit write failed.
358
340
  */
359
- export function steerAgent(id: string, message: string): BackgroundAgent | null {
341
+ export async function steerAgent(id: string, message: string): Promise<BackgroundAgent | null> {
360
342
  const agent = getAgent(id);
361
343
  if (!agent) return null;
362
344
  if (agent.status !== 'running') {
363
345
  throw new Error(`Cannot steer agent ${id}: status is ${agent.status}, not running`);
364
346
  }
365
347
 
366
- // Record steering in transcript
367
- transcript.appendEntry(id, 'user', `Steering: ${message}`);
348
+ const session = agent._sessionRef;
349
+ if (!session) {
350
+ throw new Error(`Cannot steer agent ${id}: no live session (the run may be settling)`);
351
+ }
352
+ if (typeof session.steer !== 'function') {
353
+ throw new Error('installed pi SDK has no AgentSession.steer — steering unsupported');
354
+ }
368
355
 
369
- agent.status = 'steered';
370
- agents.set(id, agent);
371
- persistAgent(agent);
356
+ // Deliver FIRST — a rejection here must surface as a real tool error, never
357
+ // a recorded-but-undelivered steer.
358
+ await session.steer(message);
359
+
360
+ // Audit line — best-effort. The steer already landed; an append failure is
361
+ // logged, not propagated.
362
+ try {
363
+ transcript.appendEntry(id, 'user', `Steering: ${message}`);
364
+ } catch (err) {
365
+ log.warn(`steerAgent: transcript append failed for ${id}`, { error: (err as Error).message });
366
+ }
372
367
 
373
368
  eventBus.emit(eventBus.createEvent('subagent:steered', agent.id, { message }));
374
369
 
@@ -376,15 +371,13 @@ export function steerAgent(id: string, message: string): BackgroundAgent | null
376
371
  }
377
372
 
378
373
  /**
379
- * Get transcript path for an agent
374
+ * Get transcript path for an agent.
375
+ *
376
+ * Issue #276: the format lives in `transcript-path.ts` (the single source).
377
+ * This re-export preserves the historical `session-manager.getTranscriptPath`
378
+ * surface for `index.ts`'s dynamic import and the existing tests.
380
379
  */
381
- export function getTranscriptPath(id: string): string {
382
- // F24: reachable with LLM-controlled ids (get_agent_result). Throw on invalid
383
- // ids — the tool caller surfaces it as an error. (Defense in depth: getAgent
384
- // already validated before this is reached with a live agent.)
385
- assertSafeAgentId(id);
386
- return join('.pi', 'output', `agent-${id}.jsonl`);
387
- }
380
+ export { transcriptPath as getTranscriptPath } from './transcript-path';
388
381
 
389
382
  /**
390
383
  * Spawn a background session using pi's session API
@@ -415,6 +408,11 @@ export async function spawnBackgroundSession(
415
408
  /** Issue #114: per-unit priority — recorded on the run entry + agent record
416
409
  * so the drill-in header and history carry it (absent → no segment). */
417
410
  priority?: string;
411
+ /** Option B U1: durability identity. A retry inherits the original's
412
+ * dispatchId and records resumeOf/attempt; a fresh dispatch omits them. */
413
+ dispatchId?: string;
414
+ resumeOf?: string;
415
+ attempt?: number;
418
416
  }
419
417
  ): Promise<BackgroundAgent> {
420
418
  // Serialize access to pi API to prevent concurrent import races
@@ -525,6 +523,13 @@ export async function spawnBackgroundSession(
525
523
  // Set session name
526
524
  session.setSessionName(`background-${id.slice(0, 8)}`);
527
525
 
526
+ // Option B U1: durability identity. Background runs have no child process
527
+ // (the session is in-process), so there is no childMarker — only the owner
528
+ // identity that a boot scan uses to tell a live conductor from a dead one.
529
+ const dispatchId = params.dispatchId ?? generateUUID();
530
+ const attempt = params.attempt ?? 1;
531
+ const owner = currentProcessOwner();
532
+
528
533
  // Create agent record
529
534
  const agent: BackgroundAgent = {
530
535
  id,
@@ -537,6 +542,10 @@ export async function spawnBackgroundSession(
537
542
  model: params.model || 'unknown',
538
543
  thinkingLevel: params.thinkingLevel || 'medium',
539
544
  priority: params.priority,
545
+ dispatchId,
546
+ resumeOf: params.resumeOf,
547
+ attempt,
548
+ owner,
540
549
  };
541
550
 
542
551
  agents.set(id, agent);
@@ -562,8 +571,12 @@ export async function spawnBackgroundSession(
562
571
  priority: params.priority,
563
572
  startedAt: new Date().toISOString(),
564
573
  originalParams: params.originalParams,
574
+ dispatchId,
575
+ resumeOf: params.resumeOf,
576
+ attempt,
577
+ owner,
565
578
  };
566
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
579
+ persistRunRecord(pi, run, "background");
567
580
 
568
581
  // Issue #98: keep the session run entry in lockstep with the agent record —
569
582
  // every terminal branch that flips agent.status also finalizes the run entry
@@ -632,7 +645,7 @@ export async function spawnBackgroundSession(
632
645
  ? { ...(run.originalParams ?? {}), errorCategory }
633
646
  : run.originalParams,
634
647
  };
635
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, entry);
648
+ persistRunRecord(pi, entry);
636
649
  // Issue #179 (D6): the explicit settle line — the log's job is to record the
637
650
  // CLASSIFIED OUTCOME, which cwd alone can never convey.
638
651
  log.info("Background run settled", {
@@ -717,9 +730,14 @@ export async function spawnBackgroundSession(
717
730
  // hard failure — never spawn an unisolated background agent that was asked
718
731
  // for isolation (fail-loud, same principle as foreground's fallback but
719
732
  // background cannot warn-and-continue safely).
720
- let originalBranch: string | undefined;
733
+ let headState: HeadState | undefined;
721
734
  let workBranchName: string | undefined;
722
735
  let releaseGitLock: (() => void) | undefined;
736
+ // Idempotency guard for cleanupWorkBranch: it must be safe to call from both
737
+ // the normal settle path and the catch-all that runs when a settle step
738
+ // throws FIRST (before its own cleanup call). Without this the second call
739
+ // would re-restore/re-delete and log spurious warnings.
740
+ let gitCleanupDone = false;
723
741
  const gitCwd = params.cwd ?? ctx.cwd;
724
742
  if (params.gitMode === 'branch') {
725
743
  // C2: serialize the FULL branch lifecycle per repo. The spawnQueue only
@@ -752,13 +770,13 @@ export async function spawnBackgroundSession(
752
770
  'requesting background gitMode=branch isolation'
753
771
  );
754
772
  }
755
- originalBranch = getCurrentBranch(gitCwd);
756
- const branchResult = createWorkBranch(gitCwd, originalBranch);
773
+ headState = getHeadState(gitCwd);
774
+ const branchResult = createWorkBranch(gitCwd, headState.sha);
757
775
  if (branchResult.ok) {
758
776
  workBranchName = branchResult.branch;
759
777
  log.info('Created work branch for background agent', {
760
778
  branch: workBranchName,
761
- base: originalBranch,
779
+ base: headState.branch,
762
780
  agentId: id,
763
781
  });
764
782
  } else {
@@ -796,7 +814,8 @@ export async function spawnBackgroundSession(
796
814
  // uncommitted edits would leak into the base working tree on switch —
797
815
  // commitAll first makes the diff real and the switch clean.
798
816
  const cleanupWorkBranch = (): { gitBranch?: string; gitDiff?: string } => {
799
- if (!workBranchName || !originalBranch) return {};
817
+ if (gitCleanupDone || !workBranchName || !headState) return {};
818
+ gitCleanupDone = true;
800
819
  const info: { gitBranch?: string; gitDiff?: string } = { gitBranch: workBranchName };
801
820
  // The lock MUST be released on every exit path — early returns included.
802
821
  try {
@@ -834,7 +853,7 @@ export async function spawnBackgroundSession(
834
853
  if (workingDiff) info.gitDiff = workingDiff;
835
854
  }
836
855
  }
837
- const diffResult = captureDiff(gitCwd, originalBranch);
856
+ const diffResult = captureDiff(gitCwd, headState.sha);
838
857
  if (diffResult.ok && diffResult.diff.trim()) {
839
858
  // Merge committed + working diffs — either can exist alone.
840
859
  info.gitDiff = info.gitDiff
@@ -845,9 +864,12 @@ export async function spawnBackgroundSession(
845
864
  log.warn(`captureDiff failed for background agent ${id}`, { error: (err as Error).message });
846
865
  }
847
866
  try {
848
- switchToBranch(gitCwd, originalBranch);
867
+ const restoreResult = restoreHead(gitCwd, headState);
868
+ if (!restoreResult.ok) {
869
+ log.warn(`restoreHead failed for background agent ${id}`, { error: restoreResult.error });
870
+ }
849
871
  } catch (err) {
850
- log.warn(`switchToBranch failed for background agent ${id}`, { error: (err as Error).message });
872
+ log.warn(`restoreHead failed for background agent ${id}`, { error: (err as Error).message });
851
873
  }
852
874
  try {
853
875
  deleteBranch(gitCwd, workBranchName);
@@ -868,6 +890,11 @@ export async function spawnBackgroundSession(
868
890
  // terminal so no zombie 'running' record survives; never rethrow.
869
891
  const markTerminalBestEffort = (fallback: AgentStatus): void => {
870
892
  try {
893
+ // Issue #53 / review C: this catch-all fires when a settle handler step
894
+ // threw — possibly BEFORE the branch's own cleanupWorkBranch() call. Run
895
+ // it here too (idempotent) so a throw can never bypass branch teardown
896
+ // and strand the work branch in the repo working tree.
897
+ cleanupWorkBranch();
871
898
  // Issue #31 (PR #76 review): this catch-all fires when a settle handler
872
899
  // threw — either BEFORE the branch's own capture (ref still live: capture
873
900
  // now, it is the last chance to record the session's output) or AFTER it
@@ -944,9 +971,13 @@ export async function spawnBackgroundSession(
944
971
  // Armed immediately after the prompt call so in-prompt preflight (auth
945
972
  // check, model resolution) counts toward the deadline; the abort lands in
946
973
  // the .then below as an aborted run (probe contract). We pre-set the status
947
- // so the .then keeps 'stopped' with the timeout reason recorded. The value
948
- // is normalized upstream (normalizeTimeout) but clamp again here — a raw
949
- // >=2^31 or Infinity would make Node fire the timer at ~1ms (instant kill).
974
+ // so the .then keeps 'stopped' with the timeout reason recorded. The raw
975
+ // value is normalized here (issue #240): a raw >=2^31 or Infinity would make
976
+ // Node fire the timer at ~1ms (instant kill), so normalizeTimeout maps it to
977
+ // undefined. An explicit timeout is HONORED verbatim (no ceiling); only the
978
+ // no-timeout fallback uses the 30m background default. The `params.timeout`
979
+ // guard below keeps this timer unarmed on the no-timeout path, where the
980
+ // index.ts deadline timer owns the no-timeout default (no redundant timer).
950
981
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
951
982
  if (params.timeout && params.timeout > 0) {
952
983
  timeoutHandle = setTimeout(() => {
@@ -966,7 +997,7 @@ export async function spawnBackgroundSession(
966
997
  error: (err as Error).message,
967
998
  });
968
999
  }
969
- }, Math.min(params.timeout, 30 * 60 * 1000));
1000
+ }, normalizeTimeout(params.timeout) ?? DEFAULT_BACKGROUND_DEADLINE_MS);
970
1001
  }
971
1002
 
972
1003
  runPromise.then(() => {
package/src/state.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Session-bound state container with versioned validation and migration for persisted settings.
1
2
  /**
2
3
  * brl-subagent — State Management (F5, F7, F9)
3
4
  *
@@ -34,6 +35,7 @@ import {
34
35
  CIRCUIT_DEGRADED_THINKING,
35
36
  } from "./types";
36
37
  import { cleanupRuns } from "./history";
38
+ import { persistRunRecord, type InflightRunKind } from "./run-registry";
37
39
  import type { Logger } from "./logging";
38
40
 
39
41
  // ---------------------------------------------------------------------------
@@ -68,14 +70,21 @@ export const STALE_FINALIZE_GRACE_MS = POLLER_TICK_MS + 1000;
68
70
 
69
71
  /**
70
72
  * Terminal-preferring run-entry lookup — the ONE implementation of the
71
- * preference rule.
73
+ * preference rule. Recovery's `dedupeRunEntriesById` delegates here, so the
74
+ * boot scan and every display/retry lookup agree by construction.
72
75
  *
73
76
  * Each run writes TWO custom entries sharing its id, in append order: a spawn
74
77
  * entry (status "running", carrying `originalParams`) FIRST, then the final
75
78
  * entry (status "done"/"failed", stamped with fullOutput/cost/duration) at
76
- * settle. Prefer the terminal entry; when none is terminal yet, fall back to
77
- * the first match (the pre-finalize spawn shape). Returns undefined when no
78
- * entry matches.
79
+ * settle. Preference, in order:
80
+ *
81
+ * 1. a TERMINAL entry (status !== "running") — the run is resolved;
82
+ * 2. among non-terminal entries, one carrying the D3 `interruptedAt` mark —
83
+ * append-only stores keep the original spawn entry forever, so the marked
84
+ * clone is the authoritative RECOVERED state U3's shared lookup reads;
85
+ * 3. otherwise the first match (the pre-finalize spawn shape).
86
+ *
87
+ * Returns undefined when no entry matches.
79
88
  */
80
89
  export function resolveTerminalRunEntry(
81
90
  entries: SubagentRun[],
@@ -83,7 +92,37 @@ export function resolveTerminalRunEntry(
83
92
  ): SubagentRun | undefined {
84
93
  const matching = entries.filter((r) => r.id === id);
85
94
  if (matching.length === 0) return undefined;
86
- return matching.find((r) => r.status !== "running") ?? matching[0];
95
+ return (
96
+ matching.find((r) => r.status !== "running") ??
97
+ matching.find((r) => r.interruptedAt) ??
98
+ matching[0]
99
+ );
100
+ }
101
+
102
+ /**
103
+ * Run-history display projection (issue #259): ONE record per SETTLED run.
104
+ *
105
+ * Each run appends two entries sharing its id — a spawn entry (status
106
+ * "running") first, then the terminal entry at settle — so a raw entry list
107
+ * renders every run twice and the spawn row shows a stale "running" forever.
108
+ * This collapses each id to its terminal-preferred entry via the ONE shared
109
+ * rule (`resolveTerminalRunEntry`). Runs still in flight (spawn entry, no
110
+ * terminal entry yet) are omitted: live state belongs to the monitor.
111
+ *
112
+ * Ordering is preserved from `entries`; callers pass `getRunEntries` output
113
+ * (newest-first via `cleanupRuns`), which stays the single ordering authority —
114
+ * no re-sort is performed here.
115
+ */
116
+ export function collapseRunsForHistory(entries: SubagentRun[]): SubagentRun[] {
117
+ const display: SubagentRun[] = [];
118
+ const seen = new Set<string>();
119
+ for (const entry of entries) {
120
+ if (seen.has(entry.id)) continue;
121
+ seen.add(entry.id);
122
+ const resolved = resolveTerminalRunEntry(entries, entry.id);
123
+ if (resolved && resolved.status !== "running") display.push(resolved);
124
+ }
125
+ return display;
87
126
  }
88
127
 
89
128
  // ---------------------------------------------------------------------------
@@ -193,8 +232,14 @@ export class SessionState {
193
232
  });
194
233
  }
195
234
 
196
- persistRun(pi: ExtensionAPI, run: SubagentRun): void {
197
- pi.appendEntry(CUSTOM_ENTRY_TYPES.run, run);
235
+ /**
236
+ * Persist a run record through the ONE choke point (`persistRunRecord`),
237
+ * which appends the session entry AND mirrors the record into the durable
238
+ * in-flight registry. `kind` is supplied at dispatch; terminal writes clear
239
+ * the registry entry.
240
+ */
241
+ persistRun(pi: ExtensionAPI, run: SubagentRun, kind?: InflightRunKind): void {
242
+ persistRunRecord(pi, run, kind);
198
243
  }
199
244
 
200
245
  // -------------------------------------------------------------------
package/src/templates.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Task-template resolution: `${param}` substitution over the file-backed template tiers.
1
2
  /**
2
3
  * brl-subagent — Task Templates
3
4
  *
@@ -0,0 +1,41 @@
1
+ // Purpose: The single declaration of the transcript path format — filesystem (`join`) and display (POSIX) forms.
2
+ import { join } from "node:path";
3
+ import { assertSafeAgentId } from "./sanitize";
4
+
5
+ /**
6
+ * The transcript directory, relative to the project root. This is the ONE
7
+ * declaration of the format; every other module derives its path from here
8
+ * rather than re-inlining `.pi/output` in a template string (issue #276).
9
+ */
10
+ export const TRANSCRIPT_DIR = ".pi/output";
11
+
12
+ /**
13
+ * Filesystem path to an agent's transcript: `<dir>/agent-<id>.jsonl`.
14
+ *
15
+ * SECURITY (F24): the id is validated through `assertSafeAgentId` BEFORE it
16
+ * reaches `path.join` — join does NOT sanitize `../` or absolute segments, so
17
+ * a traversal id would otherwise escape `dir`. Keep this chokepoint for every
18
+ * filesystem caller (`transcript.ts`, the `get_agent_result` re-export).
19
+ *
20
+ * `dir` is a parameter so `transcript.ts` can forward its mutable test-only
21
+ * `OUTPUT_DIR`; production callers take the `TRANSCRIPT_DIR` default.
22
+ */
23
+ export function transcriptPath(agentId: string, dir: string = TRANSCRIPT_DIR): string {
24
+ assertSafeAgentId(agentId);
25
+ return join(dir, `agent-${agentId}.jsonl`);
26
+ }
27
+
28
+ /**
29
+ * Display form of the transcript path: `<TRANSCRIPT_DIR>/agent-<id>.jsonl`,
30
+ * always with forward slashes so the rendered panel is identical on every
31
+ * platform.
32
+ *
33
+ * This is deliberately format-only: it does NOT call `assertSafeAgentId` and
34
+ * must never throw. It is called during TUI rendering (`tui.ts` →
35
+ * `buildOutputHonestyLines`) from a stored run record, where a throw would
36
+ * break the panel. Display text is POSIX while filesystem access uses `join` —
37
+ * the two forms intentionally differ (settled in issue #276).
38
+ */
39
+ export function transcriptDisplayPath(agentId: string): string {
40
+ return `${TRANSCRIPT_DIR}/agent-${agentId}.jsonl`;
41
+ }
@@ -1,3 +1,4 @@
1
+ // Purpose: Pure line-planning for the drill-in transcript overlay (no TUI imports).
1
2
  /**
2
3
  * brl-subagent — Pure transcript-tail rendering for the drill-in overlay
3
4
  *
package/src/transcript.ts CHANGED
@@ -1,11 +1,17 @@
1
- import { join } from 'path';
1
+ // Purpose: JSONL transcript recording for every agent run (`.pi/output/agent-<id>.jsonl`).
2
2
  import { mkdirSync, appendFileSync, readFileSync, existsSync } from 'fs';
3
3
  import type { TranscriptEntry, TranscriptEntryType } from './types';
4
- import { assertSafeAgentId } from './sanitize';
4
+ import { transcriptPath, TRANSCRIPT_DIR } from './transcript-path';
5
+ import { createLogger } from './logging';
6
+
7
+ // Issue #265: route the settle-path warning through the logger (file-only by
8
+ // default) instead of a raw console.warn that corrupts the pi TUI.
9
+ const log = createLogger('brl-subagent');
5
10
 
6
11
  // Output directory — overridable for tests (issue #52): unit tests must NOT
7
- // write into the real repo .pi/ dir; the setter is test-only.
8
- let OUTPUT_DIR = '.pi/output';
12
+ // write into the real repo .pi/ dir; the setter is test-only. Defaults to the
13
+ // single-sourced TRANSCRIPT_DIR (issue #276).
14
+ let OUTPUT_DIR = TRANSCRIPT_DIR;
9
15
 
10
16
  /**
11
17
  * TEST-ONLY: point the transcript output at an alternate directory.
@@ -29,13 +35,9 @@ function ensureOutputDir(): void {
29
35
  * Get transcript file path for an agent
30
36
  */
31
37
  export function getTranscriptPath(agentId: string): string {
32
- // F24: single chokepoint for every transcript file path. All internal callers
33
- // (startTranscript/appendEntry/getTranscript/completeTranscript) pass
34
- // generateUUID() ids, but get_agent_result feeds LLM-controlled ids in via
35
- // getTranscript. path.join does not sanitize "../" or absolute segments, so
36
- // throw on anything that is not a UUID before it reaches the filesystem.
37
- assertSafeAgentId(agentId);
38
- return join(OUTPUT_DIR, `agent-${agentId}.jsonl`);
38
+ // Issue #276: the format and the F24 `assertSafeAgentId` chokepoint live in
39
+ // transcript-path.ts; here we only forward the mutable test-only OUTPUT_DIR.
40
+ return transcriptPath(agentId, OUTPUT_DIR);
39
41
  }
40
42
 
41
43
  /**
@@ -117,7 +119,7 @@ export function completeTranscript(agentId: string, status: string): void {
117
119
  // appending — that invariant is load-bearing for caller validation).
118
120
  const path = getTranscriptPath(agentId);
119
121
  if (!existsSync(path)) {
120
- console.warn(`[brl-subagent] completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
122
+ log.warn(`completeTranscript: transcript missing for agent ${agentId} — skipping completion entry`);
121
123
  return;
122
124
  }
123
125
  appendEntry(agentId, 'system', `Transcript completed: ${status}`);
package/src/tui-format.ts CHANGED
@@ -1,3 +1,4 @@
1
+ // Purpose: Pure TUI row formatting shared by the monitor and dashboard (no TUI imports).
1
2
  /**
2
3
  * brl-subagent — Pure TUI formatting helpers
3
4
  *