@dadado/agent-kit-cli 4.8.4 → 4.8.9

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.
@@ -452,6 +452,15 @@ export function serializeFlightLogLedger(ledger) {
452
452
  * @param {string | null | undefined} liveGaps
453
453
  * @param {{ nowMs?: number, sourcePath?: string, pastCap?: number, flightKey?: string | null }} [opts]
454
454
  */
455
+ /**
456
+ * Compose one copy-only Flight Log action per the locked composed-command
457
+ * spec: dynamic label, a paste-ready command (action prompt, then the
458
+ * document path on its own line), and the referenced document path.
459
+ */
460
+ function flightLogCopyAction(label, prompt, path) {
461
+ return { label, command: `${prompt}\n${path}`, sourcePath: path };
462
+ }
463
+
455
464
  export function observeFlightLog(ledger, liveGaps, opts = {}) {
456
465
  const nowMs =
457
466
  typeof opts.nowMs === "number" && Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now();
@@ -487,6 +496,11 @@ export function observeFlightLog(ledger, liveGaps, opts = {}) {
487
496
  const pastWithKind = past.map((entry) => ({
488
497
  ...entry,
489
498
  kind: classifyFlightLogMessageKind(entry.text),
499
+ action: flightLogCopyAction(
500
+ "Copy fix prompt",
501
+ `Act on these earlier residuals:\n${entry.text}`,
502
+ entry.sourcePath || ".cursor/HANDOFF.md",
503
+ ),
490
504
  }));
491
505
  return {
492
506
  ledger: nextLedger,
@@ -495,6 +509,13 @@ export function observeFlightLog(ledger, liveGaps, opts = {}) {
495
509
  currentKind: classifyFlightLogMessageKind(current),
496
510
  past: pastWithKind,
497
511
  sourcePath,
512
+ currentAction: current
513
+ ? flightLogCopyAction(
514
+ "Copy fix prompt",
515
+ `Act on these open residuals:\n${current}`,
516
+ sourcePath,
517
+ )
518
+ : null,
498
519
  },
499
520
  };
500
521
  }
@@ -517,7 +538,7 @@ export const FLIGHT_LOG_QUIET_OPEN_TRIAGES_CAP = 5;
517
538
  * WARNING cards, Review/Resolve CTAs, and Field Report attention kinds.
518
539
  * @param {object|null|undefined} handoff - parseHandoffMarkdown result
519
540
  * @param {{ cap?: number }} [opts]
520
- * @returns {{ id: string, kind: 'api_limit'|'orchestrator_heads_up', severity: 'warning', title: string, text: string, sourcePath: string }[]}
541
+ * @returns {{ id: string, kind: 'api_limit'|'orchestrator_heads_up', severity: 'warning', title: string, text: string, sourcePath: string, action: { label: string, command: string, sourcePath: string } }[]}
521
542
  */
522
543
  export function buildFlightLogWarnings(handoff, opts = {}) {
523
544
  const cap =
@@ -545,6 +566,11 @@ export function buildFlightLogWarnings(handoff, opts = {}) {
545
566
  title: "Quota pause",
546
567
  text: truncateStr(text.trim(), MAX_SEMANTIC_LABEL),
547
568
  sourcePath,
569
+ action: flightLogCopyAction(
570
+ "Copy recovery prompt",
571
+ `Resume after this quota pause:\n${truncateStr(text.trim(), MAX_SEMANTIC_LABEL)}`,
572
+ sourcePath,
573
+ ),
548
574
  });
549
575
  }
550
576
 
@@ -562,6 +588,11 @@ export function buildFlightLogWarnings(handoff, opts = {}) {
562
588
  title: "Heads up",
563
589
  text: truncateStr(text.trim(), MAX_SEMANTIC_LABEL),
564
590
  sourcePath,
591
+ action: flightLogCopyAction(
592
+ "Copy follow-up prompt",
593
+ `Act on this heads-up:\n${truncateStr(text.trim(), MAX_SEMANTIC_LABEL)}`,
594
+ sourcePath,
595
+ ),
565
596
  });
566
597
  }
567
598
  }
@@ -2142,16 +2173,18 @@ export function formatDeliveryActivity(logLines, { plans = [], limit = MAX_GIT_A
2142
2173
 
2143
2174
  /**
2144
2175
  * Actor segment for Monitor return-brief labels.
2145
- * Kit agent id, else orchestrator for delivery, else plan ref, else system.
2176
+ * Kit agent id, else Engineering Manager for delivery, else Squad when a plan
2177
+ * is present (never the full plan filename), else Platform Engineer.
2178
+ * Default software lexicon display masks (resolution kinds unchanged).
2146
2179
  * @param {string|null|undefined} agent
2147
2180
  * @param {{ kind?: string, plan?: string|null }} [opts]
2148
2181
  */
2149
2182
  export function briefActivityActor(agent, { kind, plan } = {}) {
2150
2183
  const kit = normalizeKitAgentId(agent);
2151
2184
  if (kit) return kit;
2152
- if (kind === "delivery") return "orchestrator";
2153
- if (plan) return String(plan);
2154
- return "system";
2185
+ if (kind === "delivery") return "Engineering Manager";
2186
+ if (plan) return "Squad";
2187
+ return "Platform Engineer";
2155
2188
  }
2156
2189
 
2157
2190
  /**
@@ -2184,28 +2217,30 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) {
2184
2217
  if (now?.status === "executing" && now.currentTodo) {
2185
2218
  const planRef = now.planFile || handoff?.plan || "plan";
2186
2219
  const actor = briefActivityActor(agentFromPlan, { kind: "run_plan", plan: planRef });
2220
+ const fullLabel = `${actor} \u00b7 running \u00b7 ${now.currentTodo.id} \u00b7 ${planRef}`;
2187
2221
  events.push({
2188
2222
  id: activityId("run_plan", [now.planFile, now.currentTodo.id]),
2189
2223
  kind: "run_plan",
2190
2224
  at: now.modifiedAt || null,
2191
2225
  agent: agentFromPlan,
2192
- label: truncateStr(
2193
- `${actor} \u00b7 tick \u00b7 ${planRef} \u00b7 ${now.currentTodo.id}`,
2194
- MAX_SEMANTIC_LABEL,
2195
- ),
2226
+ label: truncateStr(fullLabel, MAX_SEMANTIC_LABEL),
2227
+ labelFull: fullLabel,
2196
2228
  sourcePath: now.planPath || null,
2197
2229
  refs: { plan: now.planFile, todo: now.currentTodo.id },
2198
2230
  });
2199
2231
  } else if (now?.status === "awaiting_user") {
2200
2232
  const planRef = now.planFile || handoff?.plan || "plan";
2201
2233
  const actor = briefActivityActor(agentFromPlan, { kind: "handoff", plan: planRef });
2202
- const gate = now.nextTodo?.id ? `next ${now.nextTodo.id}` : "awaiting user";
2234
+ const gate = now.nextTodo?.id ? `next ${now.nextTodo.id}` : "user input";
2235
+ const visible = `${actor} \u00b7 awaiting \u00b7 ${gate}`;
2236
+ const fullLabel = `${visible} \u00b7 ${planRef}`;
2203
2237
  events.push({
2204
2238
  id: activityId("handoff", [now.planFile, "awaiting"]),
2205
2239
  kind: "handoff",
2206
2240
  at: now.modifiedAt || null,
2207
2241
  agent: agentFromPlan,
2208
- label: truncateStr(`${actor} \u00b7 handoff \u00b7 ${gate}`, MAX_SEMANTIC_LABEL),
2242
+ label: truncateStr(visible, MAX_SEMANTIC_LABEL),
2243
+ labelFull: fullLabel,
2209
2244
  sourcePath: ".cursor/HANDOFF.md",
2210
2245
  refs: { plan: now.planFile },
2211
2246
  });
@@ -2232,15 +2267,15 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) {
2232
2267
  kind: "agent_step",
2233
2268
  plan: planRef,
2234
2269
  });
2270
+ const visible = `${actor} \u00b7 ${row.phase} \u00b7 ${row.todo.id}`;
2271
+ const fullLabel = `${visible} \u00b7 ${planRef}`;
2235
2272
  events.push({
2236
2273
  id: activityId("agent_step", [planRef, row.todo.id, row.phase]),
2237
2274
  kind: "agent_step",
2238
2275
  at: now.modifiedAt || null,
2239
2276
  agent: agentFromPlan,
2240
- label: truncateStr(
2241
- `${actor} \u00b7 step \u00b7 ${row.todo.id} \u00b7 ${row.phase}`,
2242
- MAX_SEMANTIC_LABEL,
2243
- ),
2277
+ label: truncateStr(visible, MAX_SEMANTIC_LABEL),
2278
+ labelFull: fullLabel,
2244
2279
  sourcePath: now.planPath || activePlan.path || null,
2245
2280
  refs: { plan: now.planFile || activePlan.file, todo: row.todo.id, phase: row.phase },
2246
2281
  });
@@ -2264,16 +2299,15 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) {
2264
2299
  const kitAgent = normalizeKitAgentId(plan.agent);
2265
2300
  const planRef = plan.file || plan.id || "plan";
2266
2301
  const actor = briefActivityActor(kitAgent, { kind: "plan_progress", plan: planRef });
2267
- const verb = stillParked ? "parked" : "plan";
2302
+ const verb = stillParked ? "parked" : "done";
2303
+ const fullLabel = `${actor} \u00b7 ${verb} \u00b7 ${stats.completed}/${stats.total} \u00b7 ${planRef}`;
2268
2304
  events.push({
2269
2305
  id: activityId("plan_progress", [plan.file, stillParked ? "parked" : "done"]),
2270
2306
  kind: "plan_progress",
2271
2307
  at: plan.modifiedAt || null,
2272
2308
  agent: kitAgent,
2273
- label: truncateStr(
2274
- `${actor} \u00b7 ${verb} \u00b7 ${planRef} \u00b7 ${stats.completed}/${stats.total}`,
2275
- MAX_SEMANTIC_LABEL,
2276
- ),
2309
+ label: truncateStr(fullLabel, MAX_SEMANTIC_LABEL),
2310
+ labelFull: fullLabel,
2277
2311
  sourcePath: plan.path || null,
2278
2312
  refs: { plan: plan.file },
2279
2313
  });
@@ -2282,6 +2316,13 @@ export function formatPlanHandoffActivity({ now, handoff, plans }) {
2282
2316
  return events;
2283
2317
  }
2284
2318
 
2319
+ /**
2320
+ * Explicit run-plan loop lines in terminal output. Shared detection for the
2321
+ * Crew feed (formatTerminalRunEvidence) and the busy-outside-plan derivation
2322
+ * so both surfaces agree on what counts as run-loop evidence.
2323
+ */
2324
+ const TERMINAL_RUN_EVIDENCE_RE = /\/run-plan|LOOP_TICK_RESULT|Night shift:.*run-plan/i;
2325
+
2285
2326
  /**
2286
2327
  * Narrow execution evidence from terminal lastOutput (explicit run-plan lines only).
2287
2328
  */
@@ -2290,7 +2331,7 @@ export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) {
2290
2331
  for (const t of terminals || []) {
2291
2332
  if (events.length >= limit) break;
2292
2333
  const out = t?.lastOutput || "";
2293
- if (!out || !/\/run-plan|LOOP_TICK_RESULT|Night shift:.*run-plan/i.test(out)) {
2334
+ if (!out || !TERMINAL_RUN_EVIDENCE_RE.test(out)) {
2294
2335
  continue;
2295
2336
  }
2296
2337
  const line =
@@ -2303,7 +2344,7 @@ export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) {
2303
2344
  id: activityId("run_plan", ["term", t.id, line.slice(0, 40)]),
2304
2345
  kind: "run_plan",
2305
2346
  at: null,
2306
- label: truncateStr(`system \u00b7 tick \u00b7 ${line}`, MAX_SEMANTIC_LABEL),
2347
+ label: truncateStr(`system \u00b7 running \u00b7 ${line}`, MAX_SEMANTIC_LABEL),
2307
2348
  sourcePath: null,
2308
2349
  refs: { terminal: t.id },
2309
2350
  });
@@ -2311,6 +2352,35 @@ export function formatTerminalRunEvidence(terminals, { limit = 3 } = {}) {
2311
2352
  return events;
2312
2353
  }
2313
2354
 
2355
+ /** Freshness window for busy-outside-plan evidence (terminal file mtime). */
2356
+ export const BUSY_OUTSIDE_PLAN_FRESH_MS = 10 * 60 * 1000;
2357
+ /** Cap busy-outside-plan evidence rows (terminal ids only; not rendered as feed). */
2358
+ export const MAX_BUSY_OUTSIDE_PLAN_EVIDENCE = 3;
2359
+
2360
+ /**
2361
+ * "Busy outside the plan" live state. True when the Current mission is not
2362
+ * executing (idle, awaiting, or completed) yet at least one terminal shows
2363
+ * fresh run-loop evidence (same detection as the Crew feed) inside the
2364
+ * freshness window. In-plan execution never raises this flag: the normal
2365
+ * executing chrome owns that state. Evidence rows are terminal ids plus the
2366
+ * observed mtime, never terminal output bodies.
2367
+ */
2368
+ export function deriveBusyOutsidePlan({ now, terminals, nowMs = Date.now() } = {}) {
2369
+ const inactive = { active: false, evidence: [] };
2370
+ if (!now || now.status === "executing") return inactive;
2371
+ const evidence = [];
2372
+ for (const t of terminals || []) {
2373
+ if (evidence.length >= MAX_BUSY_OUTSIDE_PLAN_EVIDENCE) break;
2374
+ const out = t?.lastOutput || "";
2375
+ if (!out || !TERMINAL_RUN_EVIDENCE_RE.test(out)) continue;
2376
+ const ms = Date.parse(t?.updatedAt || "");
2377
+ if (!Number.isFinite(ms)) continue;
2378
+ if (!Number.isFinite(nowMs) || nowMs - ms > BUSY_OUTSIDE_PLAN_FRESH_MS) continue;
2379
+ evidence.push({ terminal: t.id || null, at: new Date(ms).toISOString() });
2380
+ }
2381
+ return evidence.length > 0 ? { active: true, evidence } : inactive;
2382
+ }
2383
+
2314
2384
  /**
2315
2385
  * Merge activity streams newest-first, dedupe by id, bound length.
2316
2386
  */
@@ -3438,6 +3508,8 @@ function shapeExternalReportItem(report, group) {
3438
3508
  label: "Copy triage command",
3439
3509
  subject: "triage command",
3440
3510
  pasteDestination: "chatInput",
3511
+ command: `/plan-review-triage ${report.path}`,
3512
+ sourcePath: report.path,
3441
3513
  },
3442
3514
  });
3443
3515
  }
@@ -3511,6 +3583,10 @@ export function buildMissionControlView({
3511
3583
  { nowMs, todoItems: activeForTiming?.todos?.items || [] },
3512
3584
  );
3513
3585
  const now = withMissionTiming(nowBase, timing);
3586
+ // Live "busy outside the plan" flag: fresh run-loop terminal evidence while
3587
+ // the mission is not executing. Attached to the now slice so the existing
3588
+ // now fingerprint drives SSE re-render on change.
3589
+ now.busyOutsidePlan = deriveBusyOutsidePlan({ now, terminals, nowMs });
3514
3590
  const { ledger: nextFlightLogLedger, flightLog } = observeFlightLog(
3515
3591
  parseFlightLogLedger(flightLogLedger),
3516
3592
  now.gaps,
@@ -3594,3 +3670,78 @@ export function buildMissionControlView({
3594
3670
  flightLogLedger: nextFlightLogLedger,
3595
3671
  };
3596
3672
  }
3673
+
3674
+ /** Cap for the generated per-process narration line. */
3675
+ export const MAX_PROCESS_DESCRIPTION = 160;
3676
+
3677
+ const PROCESS_PORT_RE = /(?:--port[=\s]|PORT=|:)(\d{4,5})\b/;
3678
+ const PROCESS_GIT_SUB_RE = /\bgit\s+([a-z][a-z-]*)/i;
3679
+ const PROCESS_NODE_SCRIPT_RE = /(?:^|\s)(?:\S*\/)*([\w.-]+\.(?:mjs|cjs|js|ts))(?:\s|$)/;
3680
+ const PROCESS_PKG_RUN_RE = /^(npm|pnpm|yarn|bun|npx)\s+([\w:.-]+)/;
3681
+
3682
+ const PROCESS_GIT_ACTIONS = Object.freeze({
3683
+ add: "Staging changes",
3684
+ checkout: "Switching branches",
3685
+ clone: "Cloning a repository",
3686
+ commit: "Recording a commit",
3687
+ diff: "Comparing changes",
3688
+ fetch: "Fetching updates from the remote",
3689
+ log: "Reading the commit history",
3690
+ merge: "Merging branches",
3691
+ pull: "Pulling updates from the remote",
3692
+ push: "Pushing commits to the remote",
3693
+ rebase: "Rebasing commits",
3694
+ status: "Checking the working tree status",
3695
+ switch: "Switching branches",
3696
+ });
3697
+
3698
+ function describeProcessBase(label, command) {
3699
+ if (label === "dashboard-server" || /serve\.mjs|node dashboard/.test(command)) {
3700
+ const port = command.match(PROCESS_PORT_RE);
3701
+ return port
3702
+ ? `Serving the Mission Control dashboard on port ${port[1]}`
3703
+ : "Serving the Mission Control dashboard";
3704
+ }
3705
+ const gitSub = command.match(PROCESS_GIT_SUB_RE);
3706
+ if (label === "git" || gitSub) {
3707
+ const sub = gitSub ? gitSub[1].toLowerCase() : null;
3708
+ if (sub && PROCESS_GIT_ACTIONS[sub]) return PROCESS_GIT_ACTIONS[sub];
3709
+ if (sub) return `Running git ${sub}`;
3710
+ return "Running a git operation";
3711
+ }
3712
+ if (label === "node" || /\bnode\b/.test(command)) {
3713
+ const script = command.match(PROCESS_NODE_SCRIPT_RE);
3714
+ if (script) return `Running the ${script[1]} Node script`;
3715
+ return "Running a Node.js process";
3716
+ }
3717
+ const pkgRun = command.match(PROCESS_PKG_RUN_RE);
3718
+ if (pkgRun) return `Running ${pkgRun[1]} ${pkgRun[2]}`;
3719
+ const bin = (command.split(/\s+/)[0] || "").split("/").pop();
3720
+ if (bin) return `Running ${bin}`;
3721
+ return "Running an unrecognized process";
3722
+ }
3723
+
3724
+ /**
3725
+ * Deterministic per-process narration for the Processes tab ("what is it
3726
+ * doing right now"). Pure heuristics over the ps snapshot fields (label,
3727
+ * command, cpu, etime); no external calls. One short sentence.
3728
+ *
3729
+ * Design choice (accepted): heuristics replace LLM narration for the local
3730
+ * dashboard (no latency, no API cost, deterministic tests). README/CHANGELOG
3731
+ * "narrated" / "generated" language means this function, not an AI call.
3732
+ */
3733
+ export function describeProcess(proc) {
3734
+ const command = String(proc?.command || "").trim();
3735
+ const label = String(proc?.label || "other");
3736
+ const base = describeProcessBase(label, command);
3737
+ const cpuRaw = String(proc?.cpu ?? "").trim();
3738
+ const cpuNum = Number.parseFloat(cpuRaw);
3739
+ const hasCpu = cpuRaw !== "" && Number.isFinite(cpuNum);
3740
+ const signal = !hasCpu ? null : cpuNum >= 50 ? "busy" : cpuNum >= 10 ? "active" : "idle";
3741
+ const etime = String(proc?.etime || "").trim();
3742
+ const detail = [signal, hasCpu ? `${cpuRaw}% CPU` : null, etime ? `up ${etime}` : null]
3743
+ .filter(Boolean)
3744
+ .join(", ");
3745
+ const text = detail ? `${base} (${detail}).` : `${base}.`;
3746
+ return truncateStr(text, MAX_PROCESS_DESCRIPTION);
3747
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * Terminal file parsing for Mission Control snapshots.
3
+ * Header meta always comes from the file head; body/output may be tail-capped.
4
+ *
5
+ * Contract (U5): over-cap files parse meta from the first `TERMINAL_HEAD_META_BYTES`
6
+ * (4096) only. If the second `---` falls past that window, `splitTerminalHeader`
7
+ * falls back to `min(10, lines)` on the head slice — keep headers compact.
8
+ */
9
+
10
+ export const MAX_TERMINAL_BYTES = 64 * 1024;
11
+ export const MAX_LAST_OUTPUT_LINES = 15;
12
+ export const MAX_LAST_OUTPUT_CHARS = 1200;
13
+ /** Enough bytes to cover the YAML-ish header even on noisy files. */
14
+ export const TERMINAL_HEAD_META_BYTES = 4096;
15
+
16
+ /**
17
+ * Split a Cursor terminal dump into header lines (file start) and body lines.
18
+ * Header ends after the second `---` line, or after 10 lines if missing.
19
+ */
20
+ export function splitTerminalHeader(raw) {
21
+ const text = String(raw ?? "");
22
+ const lines = text.split("\n");
23
+ let headerEnd = 0;
24
+ let dashCount = 0;
25
+ const scanLimit = Math.min(lines.length, 40);
26
+ for (let i = 0; i < scanLimit; i++) {
27
+ if (lines[i].trim() === "---") {
28
+ dashCount++;
29
+ if (dashCount === 2) {
30
+ headerEnd = i + 1;
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ if (headerEnd === 0) headerEnd = Math.min(10, lines.length);
36
+ return {
37
+ headerLines: lines.slice(0, headerEnd),
38
+ bodyLines: lines.slice(headerEnd),
39
+ headerEnd,
40
+ };
41
+ }
42
+
43
+ /** Parse pid/cwd/command/exit from header lines (or first 15 of a head slice). */
44
+ export function parseTerminalMeta(headerLines) {
45
+ const meta = {};
46
+ const lines = Array.isArray(headerLines) ? headerLines : String(headerLines ?? "").split("\n");
47
+ for (const line of lines.slice(0, 15)) {
48
+ if (line.startsWith("pid:")) meta.pid = line.slice(4).trim();
49
+ if (line.startsWith("cwd:")) meta.cwd = line.slice(4).trim();
50
+ if (line.startsWith("command:")) meta.lastCommand = line.slice(8).trim();
51
+ if (line.startsWith("last_command:")) meta.lastCommand = line.slice(13).trim();
52
+ if (line.startsWith("last_exit_code:")) meta.lastExitCode = line.slice(15).trim();
53
+ }
54
+ return meta;
55
+ }
56
+
57
+ /**
58
+ * Tail-cap body lines by approximate UTF-16 byte budget so lastOutput stays fresh
59
+ * without dropping the file-head meta.
60
+ */
61
+ export function tailCapBodyLines(bodyLines, maxBytes = MAX_TERMINAL_BYTES) {
62
+ const lines = Array.isArray(bodyLines) ? bodyLines : [];
63
+ if (lines.length === 0) return [];
64
+ const joined = lines.join("\n");
65
+ if (joined.length <= maxBytes) return lines;
66
+ const tail = joined.slice(-maxBytes);
67
+ // Drop a partial first line after the byte cut.
68
+ const cut = tail.indexOf("\n");
69
+ const cleaned = cut >= 0 ? tail.slice(cut + 1) : tail;
70
+ return cleaned.split("\n");
71
+ }
72
+
73
+ /**
74
+ * Last N non-empty body lines, char-capped. Caller supplies already-capped body lines
75
+ * and an optional redact(text) → text.
76
+ */
77
+ export function extractLastOutputFromBody(bodyLines, options = {}) {
78
+ const maxLines = options.maxLines ?? MAX_LAST_OUTPUT_LINES;
79
+ const maxChars = options.maxChars ?? MAX_LAST_OUTPUT_CHARS;
80
+ const redact = typeof options.redact === "function" ? options.redact : (t) => t;
81
+ const truncate =
82
+ typeof options.truncate === "function" ? options.truncate : (t, n) => String(t).slice(0, n);
83
+
84
+ const filtered = bodyLines.filter((l) => l.trim() && !l.startsWith("---"));
85
+ if (filtered.length === 0) return null;
86
+
87
+ const tail = filtered.slice(-maxLines);
88
+ let text = redact(tail.join("\n"));
89
+ text = truncate(text, maxChars);
90
+ return text?.trim() ? text : null;
91
+ }
92
+
93
+ /**
94
+ * Build terminal snapshot fields from a raw terminal file.
95
+ * Meta always from head; lastOutput/outputLines from tail-capped body.
96
+ * Large files use a head window + tail window before any split/join so peak
97
+ * memory stays ~head+tail instead of a full-file line array (T7/T8).
98
+ */
99
+ export function buildTerminalSnapshotFields(raw, options = {}) {
100
+ const maxBytes = options.maxBytes ?? MAX_TERMINAL_BYTES;
101
+ const headBytes = options.headMetaBytes ?? TERMINAL_HEAD_META_BYTES;
102
+ const text = String(raw ?? "");
103
+
104
+ let headerLines;
105
+ let cappedBody;
106
+
107
+ if (text.length <= headBytes + maxBytes) {
108
+ const split = splitTerminalHeader(text);
109
+ headerLines = split.headerLines;
110
+ cappedBody = tailCapBodyLines(split.bodyLines, maxBytes);
111
+ } else {
112
+ const head = text.slice(0, headBytes);
113
+ const split = splitTerminalHeader(head);
114
+ headerLines = split.headerLines;
115
+ // Windowed path: tail is exactly maxBytes, so tailCapBodyLines' `<= maxBytes`
116
+ // short-circuit would skip the partial-first-line trim. Drop it here first (U4).
117
+ const tail = text.slice(-maxBytes);
118
+ const cut = tail.indexOf("\n");
119
+ const cleaned = cut >= 0 ? tail.slice(cut + 1) : tail;
120
+ cappedBody = cleaned.split("\n");
121
+ }
122
+
123
+ const meta = parseTerminalMeta(headerLines);
124
+ const outputLines = cappedBody.filter((l) => l.trim() && !l.startsWith("---")).length;
125
+ const lastOutput = extractLastOutputFromBody(cappedBody, options);
126
+ return { meta, outputLines, lastOutput, headerLines, bodyLines: cappedBody };
127
+ }