@dadado/agent-kit-cli 4.8.4 → 4.8.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +59 -0
- package/dashboard/dashboard-data.mjs +235 -77
- package/dashboard/dashboard.html +1505 -468
- package/dashboard/lib/semantic-model.mjs +173 -22
- package/dashboard/lib/terminal-snapshot.mjs +127 -0
- package/dist/index.js +1190 -333
- package/package.json +1 -1
|
@@ -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
|
|
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 "
|
|
2153
|
-
if (plan) return
|
|
2154
|
-
return "
|
|
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
|
-
|
|
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}` : "
|
|
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(
|
|
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
|
-
|
|
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" : "
|
|
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
|
-
|
|
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 ||
|
|
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
|
|
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
|
+
}
|