codecartographer-pi 0.19.5 → 0.20.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.
Files changed (46) hide show
  1. package/.codecarto/GUIDE.md +1 -1
  2. package/.codecarto/broadside/SKILL.md +14 -0
  3. package/.codecarto/broadside/config.yaml +18 -0
  4. package/.codecarto/templates/gitignore +55 -0
  5. package/.codecarto/workflow/VALIDATE.md +2 -1
  6. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  7. package/README.md +10 -6
  8. package/dist/core/amendment.js +28 -23
  9. package/dist/core/broadside.d.ts +72 -2
  10. package/dist/core/broadside.js +351 -68
  11. package/dist/core/completion.js +95 -26
  12. package/dist/core/dashboard-writer.d.ts +8 -0
  13. package/dist/core/dashboard-writer.js +159 -0
  14. package/dist/core/index.d.ts +2 -0
  15. package/dist/core/index.js +2 -0
  16. package/dist/core/library.js +115 -107
  17. package/dist/core/orchestrator-config.d.ts +32 -7
  18. package/dist/core/orchestrator-config.js +124 -44
  19. package/dist/core/pipeline.d.ts +37 -0
  20. package/dist/core/pipeline.js +80 -10
  21. package/dist/core/prompts.d.ts +20 -0
  22. package/dist/core/prompts.js +43 -10
  23. package/dist/core/secrets.d.ts +16 -0
  24. package/dist/core/secrets.js +98 -0
  25. package/dist/core/status.d.ts +30 -2
  26. package/dist/core/status.js +54 -8
  27. package/dist/core/synthesis.js +5 -2
  28. package/dist/core/usage.d.ts +8 -0
  29. package/dist/core/usage.js +35 -7
  30. package/dist/core/utils.d.ts +32 -5
  31. package/dist/core/utils.js +81 -19
  32. package/dist/core/workspace.d.ts +99 -18
  33. package/dist/core/workspace.js +275 -36
  34. package/dist/core/yaml.js +173 -14
  35. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  36. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  37. package/dist/extensions/codecarto/agent-runner.js +27 -9
  38. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  39. package/dist/extensions/codecarto/auto-runner.js +10 -6
  40. package/dist/extensions/codecarto/dashboard-narrator.js +12 -8
  41. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  42. package/dist/extensions/codecarto/dashboard-writer.js +5 -157
  43. package/dist/extensions/codecarto/index.js +73 -21
  44. package/dist/extensions/codecarto/phase-compaction.js +7 -7
  45. package/dist/mcp-server/server.js +111 -50
  46. package/package.json +3 -2
package/dist/core/yaml.js CHANGED
@@ -71,6 +71,55 @@ function findKeySeparator(text) {
71
71
  }
72
72
  return -1;
73
73
  }
74
+ /**
75
+ * Whether a line's content is a mapping entry in YAML's sense: a key followed
76
+ * by `:` and then a space or the end of the line. `findKeySeparator` alone
77
+ * finds any bare colon, which would read a wrapped value such as
78
+ * `see https://example.com` as a key.
79
+ */
80
+ function looksLikeMappingEntry(text) {
81
+ const separator = findKeySeparator(text);
82
+ if (separator === -1)
83
+ return false;
84
+ const next = text[separator + 1];
85
+ return next === undefined || next === " " || next === "\t";
86
+ }
87
+ function looksLikeSequenceItem(text) {
88
+ return text.startsWith("- ") || text === "-";
89
+ }
90
+ /**
91
+ * Whether the scalar text is plain: unquoted, and not a block indicator. Only
92
+ * a plain scalar can continue onto more-indented lines.
93
+ */
94
+ function isPlainScalarText(text) {
95
+ const trimmed = text.trim();
96
+ if (trimmed === "")
97
+ return false;
98
+ if (trimmed.startsWith('"') || trimmed.startsWith("'"))
99
+ return false;
100
+ if (trimmed.startsWith("[") || trimmed.startsWith("{"))
101
+ return false;
102
+ return true;
103
+ }
104
+ /**
105
+ * Fold a plain scalar's first line with its continuation lines: a single line
106
+ * break becomes a space and a run of k blank lines becomes k newlines, as for
107
+ * a folded block scalar.
108
+ */
109
+ function foldPlainScalar(first, continuation) {
110
+ let result = first;
111
+ let pendingBreaks = 0;
112
+ for (const line of continuation) {
113
+ if (line === "") {
114
+ pendingBreaks++;
115
+ continue;
116
+ }
117
+ result += pendingBreaks > 0 ? "\n".repeat(pendingBreaks) : " ";
118
+ pendingBreaks = 0;
119
+ result += line;
120
+ }
121
+ return result;
122
+ }
74
123
  export function parseYamlScalar(rawValue) {
75
124
  const trimmed = stripYamlComment(rawValue).trim();
76
125
  if (trimmed === "")
@@ -163,10 +212,70 @@ function applyBlockScalar(blockLines, header) {
163
212
  export function parseSimpleYaml(raw) {
164
213
  const lines = raw.split(/\r?\n/);
165
214
  let index = 0;
215
+ /**
216
+ * Every parse error names the line it failed on, quotes it, and says which
217
+ * construct was expected: a model writing a handoff has to be able to tell
218
+ * a parser limitation from its own mistake (#246).
219
+ */
220
+ const fail = (at, message) => {
221
+ throw new Error(`YAML line ${at + 1}: ${message} — "${(lines[at] ?? "").trim()}"`);
222
+ };
223
+ // YAML indents with spaces only. A tab was counted as two columns and then
224
+ // sliced as one character, so `\tkey: v` parsed as the key "" — garbage
225
+ // rather than an error.
226
+ for (let at = 0; at < lines.length; at++) {
227
+ if (/^ *\t/.test(lines[at] ?? "") && !isBlankOrComment(lines[at] ?? "")) {
228
+ fail(at, "tabs are not allowed in YAML indentation; use spaces");
229
+ }
230
+ }
166
231
  const skipBlank = () => {
167
232
  while (index < lines.length && isBlankOrComment(lines[index] ?? ""))
168
233
  index++;
169
234
  };
235
+ /**
236
+ * Consume the continuation lines of a plain scalar that began on the line
237
+ * just consumed: lines indented deeper than `baseIndent` that are neither
238
+ * a mapping entry nor a sequence item nor a comment. A wrapped
239
+ * `closeout_summary:` is exactly this shape, and it used to fail the
240
+ * indentation check with a message that blamed whitespace (#246, probe D).
241
+ * Blank lines inside the run are kept (they fold to newlines); trailing
242
+ * blank lines are left for the caller.
243
+ */
244
+ const collectPlainContinuation = (baseIndent) => {
245
+ const collected = [];
246
+ let pendingBlank = 0;
247
+ let cursor = index;
248
+ while (cursor < lines.length) {
249
+ const candidate = lines[cursor] ?? "";
250
+ if (candidate.trim() === "") {
251
+ pendingBlank++;
252
+ cursor++;
253
+ continue;
254
+ }
255
+ const candidateIndent = countIndent(candidate);
256
+ if (candidateIndent <= baseIndent)
257
+ break;
258
+ const text = candidate.slice(candidateIndent);
259
+ if (text.startsWith("#") || looksLikeSequenceItem(text) || looksLikeMappingEntry(text))
260
+ break;
261
+ for (let i = 0; i < pendingBlank; i++)
262
+ collected.push("");
263
+ pendingBlank = 0;
264
+ collected.push(stripYamlComment(text).trim());
265
+ cursor++;
266
+ index = cursor;
267
+ }
268
+ return collected;
269
+ };
270
+ /** A scalar value read from `rawValue`, folded with any continuation lines. */
271
+ const parseScalarWithContinuation = (rawValue, baseIndent) => {
272
+ if (!isPlainScalarText(rawValue))
273
+ return parseYamlScalar(rawValue);
274
+ const continuation = collectPlainContinuation(baseIndent);
275
+ if (continuation.length === 0)
276
+ return parseYamlScalar(rawValue);
277
+ return foldPlainScalar(stripYamlComment(rawValue).trim(), continuation);
278
+ };
170
279
  /**
171
280
  * Read the body of a block scalar that opened on the line just consumed.
172
281
  * Shared by mapping values (`key: >-`) and sequence items (`- >-`): when only
@@ -228,34 +337,58 @@ export function parseSimpleYaml(raw) {
228
337
  if (lineIndent < indent)
229
338
  break;
230
339
  if (lineIndent > indent) {
231
- throw new Error(`Invalid YAML indentation near: ${line.trim()}`);
340
+ fail(index, `this line is indented ${lineIndent} columns but the mapping it belongs to starts at column ${indent}; a sibling key must align with the first key, and a wrapped value must not contain ": "`);
232
341
  }
233
342
  const trimmed = line.slice(indent);
234
- if (trimmed.startsWith("- ") || trimmed === "-")
235
- break;
343
+ if (looksLikeSequenceItem(trimmed)) {
344
+ // A list that is a key's value is consumed by that key's branch
345
+ // below before the loop ever sees it, so a dash here has no key.
346
+ fail(index, "a sequence item where a mapping entry was expected; a list that belongs to the key above must be indented under it, or sit at that key's own column");
347
+ }
236
348
  const separator = findKeySeparator(trimmed);
237
349
  if (separator === -1) {
238
- throw new Error(`Invalid YAML mapping entry: ${trimmed}`);
350
+ fail(index, 'expected a mapping entry ("key: value")');
239
351
  }
240
352
  const key = trimmed.slice(0, separator).trim();
241
353
  const rawValue = trimmed.slice(separator + 1).trim();
242
- index++;
243
354
  if (seen.has(key)) {
244
- throw new Error(`Duplicate YAML key: ${key} near line: ${line.trim()}`);
355
+ fail(index, `Duplicate YAML key: ${key}`);
245
356
  }
246
357
  seen.add(key);
358
+ index++;
247
359
  const blockHeader = parseBlockScalarHeader(rawValue);
248
360
  if (blockHeader) {
249
361
  assign(key, applyBlockScalar(collectBlockScalarLines(indent), blockHeader));
250
362
  continue;
251
363
  }
252
364
  if (rawValue !== "") {
253
- assign(key, parseYamlScalar(rawValue));
365
+ assign(key, parseScalarWithContinuation(rawValue, indent));
254
366
  continue;
255
367
  }
256
368
  skipBlank();
257
- if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
258
- assign(key, parseBlock(countIndent(lines[index] ?? "")));
369
+ if (index >= lines.length) {
370
+ assign(key, null);
371
+ continue;
372
+ }
373
+ const nextLine = lines[index] ?? "";
374
+ const nextIndent = countIndent(nextLine);
375
+ if (nextIndent > indent) {
376
+ const nextText = nextLine.slice(nextIndent);
377
+ if (looksLikeMappingEntry(nextText) || looksLikeSequenceItem(nextText)) {
378
+ assign(key, parseBlock(nextIndent));
379
+ }
380
+ else {
381
+ // A scalar that starts on the line after its key
382
+ // (`summary:` / ` The phase mapped…`), wrapped or not.
383
+ index++;
384
+ assign(key, parseScalarWithContinuation(nextText, indent));
385
+ }
386
+ }
387
+ else if (nextIndent === indent && looksLikeSequenceItem(nextLine.slice(nextIndent))) {
388
+ // YAML lets a block sequence sit at the same indent as the key it
389
+ // belongs to (`items:` / `- a`). This read as `items: null` and
390
+ // then, at the top level, dropped every line after it (#246).
391
+ assign(key, parseSequence(indent));
259
392
  }
260
393
  else {
261
394
  assign(key, null);
@@ -286,8 +419,17 @@ export function parseSimpleYaml(raw) {
286
419
  index++;
287
420
  if (rawItem === "") {
288
421
  skipBlank();
289
- if (index < lines.length && countIndent(lines[index] ?? "") > indent) {
290
- result.push(parseBlock(countIndent(lines[index] ?? "")));
422
+ const nextLine = lines[index] ?? "";
423
+ const nextIndent = countIndent(nextLine);
424
+ if (index < lines.length && nextIndent > indent) {
425
+ const nextText = nextLine.slice(nextIndent);
426
+ if (looksLikeMappingEntry(nextText) || looksLikeSequenceItem(nextText)) {
427
+ result.push(parseBlock(nextIndent));
428
+ }
429
+ else {
430
+ index++;
431
+ result.push(parseScalarWithContinuation(nextText, indent));
432
+ }
291
433
  }
292
434
  else {
293
435
  result.push(null);
@@ -299,12 +441,21 @@ export function parseSimpleYaml(raw) {
299
441
  result.push(applyBlockScalar(collectBlockScalarLines(indent), itemBlockHeader));
300
442
  continue;
301
443
  }
444
+ if (looksLikeSequenceItem(rawItem)) {
445
+ // A sequence item that is itself a sequence (`- - a`). Rewrite the
446
+ // line as the inner item at its own column and parse a block there,
447
+ // so the inner sequence's later items (` - b`) find their first.
448
+ index--;
449
+ lines[index] = `${" ".repeat(itemIndent)}${afterDash.trimStart()}`;
450
+ result.push(parseBlock(itemIndent));
451
+ continue;
452
+ }
302
453
  const separator = findKeySeparator(rawItem);
303
454
  if (separator !== -1) {
304
455
  const key = rawItem.slice(0, separator).trim();
305
456
  const rawValue = rawItem.slice(separator + 1).trim();
306
457
  const item = {};
307
- item[key] = rawValue === "" ? null : parseYamlScalar(rawValue);
458
+ item[key] = rawValue === "" ? null : parseScalarWithContinuation(rawValue, indent);
308
459
  skipBlank();
309
460
  if (rawValue === "" && index < lines.length && countIndent(lines[index] ?? "") > indent + 1) {
310
461
  item[key] = parseBlock(countIndent(lines[index] ?? ""));
@@ -317,14 +468,22 @@ export function parseSimpleYaml(raw) {
317
468
  result.push(item);
318
469
  continue;
319
470
  }
320
- result.push(parseYamlScalar(rawItem));
471
+ result.push(parseScalarWithContinuation(rawItem, indent));
321
472
  }
322
473
  return result;
323
474
  };
324
475
  skipBlank();
325
476
  if (index >= lines.length)
326
477
  return {};
327
- return parseBlock(countIndent(lines[index] ?? ""));
478
+ const document = parseBlock(countIndent(lines[index] ?? ""));
479
+ // A top-level block that ends before the input does used to leave the rest
480
+ // unread and unreported — a mapping followed by a stray `- item` silently
481
+ // lost everything from that line on (#246).
482
+ skipBlank();
483
+ if (index < lines.length) {
484
+ fail(index, "unexpected content after the document's top-level block ended (is this line indented like its neighbours?)");
485
+ }
486
+ return document;
328
487
  }
329
488
  export function formatYamlScalar(value) {
330
489
  if (value === null)
@@ -10,6 +10,7 @@
10
10
  import { readFile, readdir } from "node:fs/promises";
11
11
  import { join } from "node:path";
12
12
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
13
+ import { disposeChildSession } from "./agent-runner.js";
13
14
  import { closeoutFileName, pathExists } from "../../core/index.js";
14
15
  import { createChildModelRuntime } from "./child-model-runtime.js";
15
16
  /** Closeout content over this many bytes is truncated before being passed to
@@ -151,20 +152,26 @@ async function runRewriterOnce(ctx, prompt) {
151
152
  tools: [],
152
153
  resourceLoader: loader,
153
154
  });
154
- await session.prompt(prompt);
155
- for (let i = session.messages.length - 1; i >= 0; i--) {
156
- const msg = session.messages[i];
157
- if (msg.role !== "assistant")
158
- continue;
159
- const blocks = msg.content;
160
- const parts = [];
161
- for (const c of blocks) {
162
- if (c.type === "text" && c.text)
163
- parts.push(c.text);
155
+ try {
156
+ await session.prompt(prompt);
157
+ for (let i = session.messages.length - 1; i >= 0; i--) {
158
+ const msg = session.messages[i];
159
+ if (msg.role !== "assistant")
160
+ continue;
161
+ const blocks = msg.content;
162
+ const parts = [];
163
+ for (const c of blocks) {
164
+ if (c.type === "text" && c.text)
165
+ parts.push(c.text);
166
+ }
167
+ const joined = parts.join("\n").trim();
168
+ if (joined)
169
+ return joined;
164
170
  }
165
- const joined = parts.join("\n").trim();
166
- if (joined)
167
- return joined;
171
+ return "";
172
+ }
173
+ finally {
174
+ // One prompt, one answer; the child has nothing left to do (#256).
175
+ disposeChildSession(session);
168
176
  }
169
- return "";
170
177
  }
@@ -11,7 +11,6 @@ export declare function primaryOutputExists(cwd: string, primaryOutput: string):
11
11
  export declare function buildPhaseContinuationPrompt(compacted: boolean): string;
12
12
  export declare function waitForCompaction(compactionCompleted: Promise<boolean>, timeoutMs?: number): Promise<boolean>;
13
13
  export interface PhaseRunCallbacks {
14
- onSessionCreated?: (session: AgentSession) => void;
15
14
  onToolStart?: (toolCallId: string, toolName: string) => void;
16
15
  onToolEnd?: (toolCallId: string, toolName: string) => void;
17
16
  onTextDelta?: (delta: string, fullText: string) => void;
@@ -37,7 +36,6 @@ export interface PhaseRunOptions {
37
36
  primaryOutput?: string;
38
37
  }
39
38
  export interface PhaseRunResult {
40
- session: AgentSession;
41
39
  responseText: string;
42
40
  toolUses: number;
43
41
  turnCount: number;
@@ -61,3 +59,9 @@ export interface PhaseRunResult {
61
59
  * shows lineage.
62
60
  */
63
61
  export declare function runPhase(ctx: ExtensionContext, prompt: string, callbacks?: PhaseRunCallbacks, options?: PhaseRunOptions, signal?: AbortSignal): Promise<PhaseRunResult>;
62
+ /**
63
+ * Dispose a child session, swallowing whatever dispose throws: the work is
64
+ * done and its result is already in hand, so a failing cleanup hook must not
65
+ * turn a finished phase into an error.
66
+ */
67
+ export declare function disposeChildSession(session: Pick<AgentSession, "dispose">): void;
@@ -113,7 +113,6 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
113
113
  resourceLoader: loader,
114
114
  });
115
115
  await session.bindExtensions({});
116
- callbacks.onSessionCreated?.(session);
117
116
  let toolUses = 0;
118
117
  let turnCount = 0;
119
118
  let currentMessageText = "";
@@ -200,19 +199,38 @@ export async function runPhase(ctx, prompt, callbacks = {}, options = {}, signal
200
199
  if (!aborted)
201
200
  await session.prompt(buildPhaseContinuationPrompt(compacted));
202
201
  }
202
+ return {
203
+ responseText: getLastAssistantText(session) || currentMessageText,
204
+ toolUses,
205
+ turnCount,
206
+ aborted,
207
+ sessionFile: sessionManager.getSessionFile(),
208
+ };
203
209
  }
204
210
  finally {
205
211
  unsubscribe();
206
212
  abortCleanup();
213
+ // The child is done, on every path. Dispose aborts whatever it still
214
+ // has in flight, drops its agent subscription and listeners, and runs
215
+ // the per-session resource cleanups extensions registered — a seven
216
+ // phase auto run used to keep all of that for every phase, rewrite,
217
+ // and narration until the process exited (#256). Nothing reads the
218
+ // session after this: the result carries the text and the file path.
219
+ disposeChildSession(session);
220
+ }
221
+ }
222
+ /**
223
+ * Dispose a child session, swallowing whatever dispose throws: the work is
224
+ * done and its result is already in hand, so a failing cleanup hook must not
225
+ * turn a finished phase into an error.
226
+ */
227
+ export function disposeChildSession(session) {
228
+ try {
229
+ session.dispose();
230
+ }
231
+ catch {
232
+ // nothing to do with a cleanup failure but move on
207
233
  }
208
- return {
209
- session,
210
- responseText: getLastAssistantText(session) || currentMessageText,
211
- toolUses,
212
- turnCount,
213
- aborted,
214
- sessionFile: sessionManager.getSessionFile(),
215
- };
216
234
  }
217
235
  /**
218
236
  * Walk session.messages backward to find the last non-empty assistant text.
@@ -1,4 +1,3 @@
1
- import type { AgentSession } from "@earendil-works/pi-coding-agent";
2
1
  import { type CompactionTelemetry } from "../../core/usage.ts";
3
2
  export type PhaseStatus = "running" | "completed" | "error" | "aborted";
4
3
  export interface PhaseActivity {
@@ -20,7 +19,6 @@ export interface PhaseActivity {
20
19
  };
21
20
  /** Compaction outcomes observed during this phase session. */
22
21
  compactions: CompactionTelemetry;
23
- session?: AgentSession;
24
22
  error?: string;
25
23
  }
26
24
  export declare function getPhaseActivity(phaseId: string): PhaseActivity | undefined;
@@ -17,8 +17,7 @@ import { clearPhase, finishPhase, getPhaseActivity, startPhase } from "./agent-s
17
17
  import { buildSteeringMessage, rewritePhasePrompt } from "./agent-rewriter.js";
18
18
  import { buildPhaseSummary } from "./agent-summary.js";
19
19
  import { getAgentsWidget } from "./agent-widget.js";
20
- import { writeDashboard } from "./dashboard-writer.js";
21
- import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValidatedPhase, formatMillis, formatTokenCount, getNextEligiblePhase, getWorkspaceState, loadCodecartoConfig, PACKAGE_VERSION, PhasePreflightError, runPhasePreflight, validatePhaseOutput, } from "../../core/index.js";
20
+ import { appendUsageRun, buildPhasePrompt, buildValidationSummary, completeValidatedPhase, formatMillis, formatTokenCount, getNextEligiblePhase, getWorkspaceState, describeConfigProblems, loadCodecartoConfig, PACKAGE_VERSION, PhasePreflightError, runPhasePreflight, validatePhaseOutput, writeDashboard, } from "../../core/index.js";
22
21
  /**
23
22
  * Run one phase end to end: optional LLM-steered rewrite, spawn the sub-agent,
24
23
  * wait for it, then emit the side effects the historical /codecarto-next chain
@@ -60,7 +59,6 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
60
59
  getAgentsWidget().attach(ctx.ui);
61
60
  try {
62
61
  const result = await runPhase(ctx, prompt, {
63
- onSessionCreated: (session) => { activity.session = session; },
64
62
  onToolStart: (id, name) => { activity.activeTools.set(id, name); activity.toolUses++; },
65
63
  onToolEnd: (id) => { activity.activeTools.delete(id); },
66
64
  onTextDelta: (_delta, fullText) => { activity.responseText = fullText; },
@@ -101,7 +99,9 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
101
99
  display: true,
102
100
  });
103
101
  void recordUsage(state.workspaceDir, phase.id, status, activity, result.sessionFile);
104
- void writeDashboard(ctx.cwd, PACKAGE_VERSION);
102
+ // The phase's sub-agent replaced the session, so reading ctx.cwd here
103
+ // would throw (#201); the state captured before the run has the root.
104
+ void writeDashboard(state.cwd, PACKAGE_VERSION);
105
105
  return {
106
106
  status: result.aborted ? "aborted" : "completed",
107
107
  activity,
@@ -128,7 +128,7 @@ export async function runSinglePhase(ctx, pi, state, phase, options) {
128
128
  display: true,
129
129
  });
130
130
  void recordUsage(state.workspaceDir, phase.id, "error", activity);
131
- void writeDashboard(ctx.cwd, PACKAGE_VERSION);
131
+ void writeDashboard(state.cwd, PACKAGE_VERSION);
132
132
  return { status: "error", activity, error: message };
133
133
  }
134
134
  finally {
@@ -190,6 +190,10 @@ export async function runAuto(ctx, pi, initialState, options) {
190
190
  const totalTokens = { input: 0, output: 0, cacheWrite: 0 };
191
191
  const totalPhases = initialState.pipeline.phase_order.length;
192
192
  const config = await loadCodecartoConfig(initialState.workspaceDir);
193
+ // Once, before the loop: a dropped config file changes what this run does
194
+ // (the steer toggle lives there) and nothing else in the loop reads it.
195
+ if (config.problems.length > 0)
196
+ notifyCtx(ctx, describeConfigProblems(config).join("\n"), "warning");
193
197
  const llmSteerEnabled = options.llmSteerOverride ?? config.orchestrator.llm_steer_next_phase;
194
198
  let state = initialState;
195
199
  while (true) {
@@ -241,7 +245,7 @@ export async function runAuto(ctx, pi, initialState, options) {
241
245
  if (phaseResult.status === "completed") {
242
246
  // State must be refreshed because the sub-agent may have written
243
247
  // findings to disk that the validator reads.
244
- const stateForValidation = (await getWorkspaceState(ctx.cwd)) ?? state;
248
+ const stateForValidation = (await getWorkspaceState(autoCwd)) ?? state;
245
249
  validation = await validatePhaseOutput(stateForValidation, phase.id);
246
250
  }
247
251
  const decision = decideAfterPhase(phaseResult.status, phaseResult.error, validation, options.strict);
@@ -10,10 +10,11 @@
10
10
  //
11
11
  // Same one-shot pattern as agent-rewriter.ts:runRewriterOnce — different
12
12
  // system prompt, different output target. Never throws.
13
- import { readFile, readdir, rename, writeFile } from "node:fs/promises";
13
+ import { readFile, readdir } from "node:fs/promises";
14
14
  import { join } from "node:path";
15
15
  import { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager, SettingsManager, } from "@earendil-works/pi-coding-agent";
16
- import { computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
16
+ import { disposeChildSession } from "./agent-runner.js";
17
+ import { atomicWriteFile, computeTotals, NARRATION_CACHE_RELATIVE_PATH, loadUsage, pathExists, stringifySimpleYaml, } from "../../core/index.js";
17
18
  import { createChildModelRuntime } from "./child-model-runtime.js";
18
19
  // Per-closeout byte budget when stuffing the narrator's input. Three
19
20
  // closeouts × 4 KB each ≈ 12 KB of prompt context, which is well under any
@@ -133,10 +134,7 @@ async function writeNarrationCache(workspaceDir, content, phaseCount) {
133
134
  const generatedAt = new Date().toISOString();
134
135
  const frontmatter = stringifySimpleYaml({ generatedAt, phaseCountAtGeneration: phaseCount });
135
136
  const body = `---\n${frontmatter}\n---\n${content}\n`;
136
- const path = join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH);
137
- const tempPath = `${path}.${process.pid}.${Date.now()}.tmp`;
138
- await writeFile(tempPath, body, "utf8");
139
- await rename(tempPath, path);
137
+ await atomicWriteFile(join(workspaceDir, NARRATION_CACHE_RELATIVE_PATH), body);
140
138
  }
141
139
  async function runNarratorOnce(ctx, prompt) {
142
140
  const cwd = ctx.cwd;
@@ -161,8 +159,14 @@ async function runNarratorOnce(ctx, prompt) {
161
159
  tools: [],
162
160
  resourceLoader: loader,
163
161
  });
164
- await session.prompt(prompt);
165
- return getLastAssistantText(session);
162
+ try {
163
+ await session.prompt(prompt);
164
+ return getLastAssistantText(session);
165
+ }
166
+ finally {
167
+ // One prompt, one answer; the child has nothing left to do (#256).
168
+ disposeChildSession(session);
169
+ }
166
170
  }
167
171
  function getLastAssistantText(session) {
168
172
  for (let i = session.messages.length - 1; i >= 0; i--) {
@@ -1,8 +1 @@
1
- /**
2
- * Render and atomically replace `.codecarto/dashboard.html`.
3
- * @returns true when a fresh dashboard landed on disk; false when the
4
- * workspace is missing or any gather/render/write step failed (swallowed —
5
- * lifecycle callers must never fail on a dashboard problem, but they may
6
- * report truthfully whether a refresh happened).
7
- */
8
- export declare function writeDashboard(cwd: string, packageVersion: string): Promise<boolean>;
1
+ export { writeDashboard } from "../../core/dashboard-writer.ts";