codecartographer-pi 0.19.6 → 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 (39) 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/workflow/VALIDATE.md +2 -1
  5. package/.codecarto/workflow/scaffold-version.yaml +1 -1
  6. package/README.md +10 -6
  7. package/dist/core/broadside.d.ts +72 -2
  8. package/dist/core/broadside.js +348 -63
  9. package/dist/core/completion.js +81 -22
  10. package/dist/core/dashboard-writer.d.ts +8 -0
  11. package/dist/core/dashboard-writer.js +159 -0
  12. package/dist/core/index.d.ts +2 -0
  13. package/dist/core/index.js +2 -0
  14. package/dist/core/library.js +4 -1
  15. package/dist/core/orchestrator-config.d.ts +32 -7
  16. package/dist/core/orchestrator-config.js +124 -44
  17. package/dist/core/pipeline.d.ts +37 -0
  18. package/dist/core/pipeline.js +80 -10
  19. package/dist/core/prompts.d.ts +20 -0
  20. package/dist/core/prompts.js +43 -10
  21. package/dist/core/secrets.d.ts +16 -0
  22. package/dist/core/secrets.js +98 -0
  23. package/dist/core/status.d.ts +8 -0
  24. package/dist/core/status.js +8 -3
  25. package/dist/core/synthesis.js +5 -2
  26. package/dist/core/workspace.d.ts +55 -8
  27. package/dist/core/workspace.js +115 -7
  28. package/dist/core/yaml.js +173 -14
  29. package/dist/extensions/codecarto/agent-rewriter.js +21 -14
  30. package/dist/extensions/codecarto/agent-runner.d.ts +6 -2
  31. package/dist/extensions/codecarto/agent-runner.js +27 -9
  32. package/dist/extensions/codecarto/agent-state.d.ts +0 -2
  33. package/dist/extensions/codecarto/auto-runner.js +10 -6
  34. package/dist/extensions/codecarto/dashboard-narrator.js +9 -2
  35. package/dist/extensions/codecarto/dashboard-writer.d.ts +1 -8
  36. package/dist/extensions/codecarto/dashboard-writer.js +5 -154
  37. package/dist/extensions/codecarto/index.js +65 -18
  38. package/dist/mcp-server/server.js +107 -49
  39. package/package.json +3 -2
@@ -3,10 +3,11 @@
3
3
  // + normalizes the per-project workspace state from disk, and provides the
4
4
  // atomic status-update primitive used by /codecarto-complete.
5
5
  import { existsSync, readFileSync } from "node:fs";
6
- import { appendFile, copyFile, cp, mkdir, readFile, readdir } from "node:fs/promises";
6
+ import { appendFile, copyFile, cp, mkdir, readFile, readdir, rename } from "node:fs/promises";
7
7
  import { basename, dirname, join, relative } from "node:path";
8
8
  import { fileURLToPath } from "node:url";
9
- import { acquireLock, applyHandoff, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
9
+ import { getPipelineLabel, recomputeCursor } from "./pipeline.js";
10
+ import { acquireLock, applyHandoff, autoAssignIds, createEmptyStatus, normalizeStatus, parseHandoff } from "./status.js";
10
11
  import { atomicWriteFile, compareDottedVersions, newlineIfUnterminated, pathExists } from "./utils.js";
11
12
  import { loadYamlFile, stringifySimpleYaml } from "./yaml.js";
12
13
  // Walk up from the current file to find the package root. Needed because the
@@ -253,6 +254,44 @@ export async function copyPackagedWorkspace(targetWorkspaceDir, sourceWorkspaceD
253
254
  }
254
255
  await ensureWorkspaceGitignore(targetWorkspaceDir);
255
256
  }
257
+ /**
258
+ * Move a workspace's session state out into `backupDir`, keeping relative
259
+ * paths: status, the usage log, every declared phase output, handoffs and
260
+ * checkpoints, closeouts, the dashboard, the orchestrator files, Broad-Side
261
+ * runs, and any lock or temp file — everything {@link isTemplatePath} says a
262
+ * session wrote rather than the framework shipped. Framework-owned files stay
263
+ * where they are, as do the directories, so the workspace keeps its shape.
264
+ *
265
+ * This is what a forced re-init does when `.codecarto/` is the packaged
266
+ * template itself (#245). A checkout's `.codecarto/` is the template and a
267
+ * live workspace at once, so the ordinary force — rename the directory away,
268
+ * copy the template in — would move the very files it copies from. Before
269
+ * this, that case skipped the backup entirely and reset status in place.
270
+ *
271
+ * @returns the workspace-relative paths moved, sorted.
272
+ */
273
+ export async function backupWorkspaceState(workspaceDir, backupDir) {
274
+ const declaredOutputs = await listDeclaredOutputs(workspaceDir);
275
+ const moved = [];
276
+ const walk = async (dir, segments) => {
277
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
278
+ const entrySegments = [...segments, entry.name];
279
+ const source = join(dir, entry.name);
280
+ if (entry.isDirectory()) {
281
+ await walk(source, entrySegments);
282
+ continue;
283
+ }
284
+ if (isTemplatePath(entrySegments, declaredOutputs))
285
+ continue;
286
+ const destination = join(backupDir, ...entrySegments);
287
+ await mkdir(dirname(destination), { recursive: true });
288
+ await rename(source, destination);
289
+ moved.push(entrySegments.join("/"));
290
+ }
291
+ };
292
+ await walk(workspaceDir, []);
293
+ return moved.sort();
294
+ }
256
295
  /**
257
296
  * Give the workspace its ignore rules when it has none. npm never packs a file
258
297
  * named `.gitignore`, so an npm-installed template carried no rules and the
@@ -492,13 +531,31 @@ export async function updateStatusAtomically(cwd, updater) {
492
531
  await lock.release();
493
532
  }
494
533
  }
534
+ /**
535
+ * The lines both surfaces print for the carry-forwards a switch moved to
536
+ * post_pipeline: one summary, then one line per entry naming where it was
537
+ * going and how to close it now. Empty when nothing moved.
538
+ */
539
+ export function describeDanglingCarryForward(dangling) {
540
+ if (dangling.length === 0)
541
+ return [];
542
+ const noun = dangling.length === 1 ? "carry-forward item" : "carry-forward items";
543
+ const lines = [`${dangling.length} ${noun} targeted a dropped phase and moved to post_pipeline (close with an amendment's post_pipeline_closures):`];
544
+ for (const entry of dangling) {
545
+ const label = entry.description ? `: ${entry.description}` : "";
546
+ lines.push(` - ${entry.id} (${entry.source_phase} → ${entry.target_phase})${label}`);
547
+ }
548
+ return lines;
549
+ }
495
550
  /**
496
551
  * Switch the active pipeline in-place without deleting findings, handoffs,
497
552
  * usage data, closeouts, or checkpoints. Phases that exist in both the old
498
553
  * and new pipelines preserve their completion status, owner notes, open
499
- * questions, and carry-forward entries. Phases unique to the new pipeline
500
- * start as pending. Phases unique to the old pipeline are dropped from
501
- * status.yaml (but their findings remain on disk under findings/).
554
+ * questions, and carry-forward entries; the cursor is then recomputed from
555
+ * those carried completions (#236). Phases unique to the new pipeline start
556
+ * as pending. Phases unique to the old pipeline are dropped from status.yaml
557
+ * (but their findings remain on disk under findings/), and any carry-forward
558
+ * that targeted one of them moves to post_pipeline (#237).
502
559
  */
503
560
  export async function switchPipeline(cwd, newPipelinePath) {
504
561
  const workspaceDir = join(cwd, ".codecarto");
@@ -531,14 +588,65 @@ export async function switchPipeline(cwd, newPipelinePath) {
531
588
  const dropped = currentState.pipeline.phase_order.filter((phaseId) => !newPipeline.phase_order.includes(phaseId));
532
589
  const newPhases = newPipeline.phase_order.filter((phaseId) => !currentState.pipeline.phase_order.includes(phaseId));
533
590
  // Preserve post_pipeline entries from the old status.
534
- freshStatus.post_pipeline = currentState.status.post_pipeline;
591
+ freshStatus.post_pipeline = [...currentState.status.post_pipeline];
592
+ // A carried phase may have routed work to a phase the new pipeline does
593
+ // not run. Left in place, that entry would never appear in a phase
594
+ // prompt and nothing but a later handoff could close it (#237). It is
595
+ // post-pipeline work now, by the same rule completion applies to a
596
+ // handoff whose target is not a downstream active phase.
597
+ const dangling = [];
598
+ const activePhases = new Set(newPipeline.phase_order);
599
+ const newLabel = getPipelineLabel(newPipelinePath);
600
+ const postPipelineById = new Map(freshStatus.post_pipeline.filter((entry) => entry.id).map((entry) => [entry.id, entry]));
601
+ for (const [phaseId, phase] of Object.entries(freshStatus.phases)) {
602
+ if (!phase.carry_forward.some((entry) => entry.target_phase && !activePhases.has(entry.target_phase)))
603
+ continue;
604
+ // Handoff-routed entries always carry a `cf-<phase>-N` id; only a
605
+ // hand-edited status can lack one, and post_pipeline keys by id.
606
+ autoAssignIds(phase.carry_forward, "cf", phaseId);
607
+ const kept = [];
608
+ for (const entry of phase.carry_forward) {
609
+ if (!entry.target_phase || activePhases.has(entry.target_phase)) {
610
+ kept.push(entry);
611
+ continue;
612
+ }
613
+ const note = `Routed to ${entry.target_phase}, which the ${newLabel} pipeline does not run.`;
614
+ const moved = {
615
+ id: entry.id,
616
+ ...(entry.kind !== undefined && { kind: entry.kind }),
617
+ ...(entry.description !== undefined && { description: entry.description }),
618
+ deferred_reason: entry.deferred_reason ? `${entry.deferred_reason} ${note}` : note,
619
+ source_phase: phaseId,
620
+ status: "pending",
621
+ };
622
+ if (postPipelineById.has(entry.id)) {
623
+ const index = freshStatus.post_pipeline.findIndex((existing) => existing.id === entry.id);
624
+ freshStatus.post_pipeline[index] = moved;
625
+ }
626
+ else {
627
+ freshStatus.post_pipeline.push(moved);
628
+ postPipelineById.set(entry.id, moved);
629
+ }
630
+ dangling.push({
631
+ id: entry.id,
632
+ source_phase: phaseId,
633
+ target_phase: entry.target_phase,
634
+ ...(entry.description !== undefined && { description: entry.description }),
635
+ });
636
+ }
637
+ phase.carry_forward = kept;
638
+ }
535
639
  freshStatus.last_updated = new Date().toISOString();
640
+ // createEmptyStatus pointed the cursor at phase one; the carried
641
+ // completions may have moved it (#236). Ask the engine, exactly as
642
+ // completion does, so status and next agree from the moment of the switch.
643
+ recomputeCursor({ ...currentState, pipeline: newPipeline, status: freshStatus });
536
644
  assertCanonicalStatus(freshStatus);
537
645
  await atomicWriteFile(statusPath, `${stringifySimpleYaml(freshStatus)}\n`);
538
646
  const state = await getWorkspaceState(cwd);
539
647
  if (!state)
540
648
  throw new Error("Failed to reload workspace state after pipeline switch.");
541
- return { state, carried, dropped, newPhases };
649
+ return { state, carried, dropped, newPhases, dangling };
542
650
  }
543
651
  finally {
544
652
  await lock.release();
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);
@@ -13,6 +13,7 @@
13
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 { disposeChildSession } from "./agent-runner.js";
16
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
@@ -158,8 +159,14 @@ async function runNarratorOnce(ctx, prompt) {
158
159
  tools: [],
159
160
  resourceLoader: loader,
160
161
  });
161
- await session.prompt(prompt);
162
- 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
+ }
163
170
  }
164
171
  function getLastAssistantText(session) {
165
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";