@yusukeshib/pi-babysit 0.6.25 → 0.6.27
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 +16 -54
- package/index.ts +81 -703
- package/package.json +1 -3
- package/log-source.ts +0 -232
- package/log-viewer.ts +0 -496
package/index.ts
CHANGED
|
@@ -44,9 +44,7 @@ import type {
|
|
|
44
44
|
ToolDefinition,
|
|
45
45
|
} from "@earendil-works/pi-coding-agent";
|
|
46
46
|
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
47
|
-
import { Box, Markdown, Text, truncateToWidth
|
|
48
|
-
import { createLogViewer } from "./log-viewer";
|
|
49
|
-
import { FileLogSource } from "./log-source";
|
|
47
|
+
import { Box, Markdown, Text, truncateToWidth } from "@earendil-works/pi-tui";
|
|
50
48
|
import { Type, type TSchema } from "typebox";
|
|
51
49
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
52
50
|
import { type AgentConfig, type AgentScope, discoverAgents } from "./agents";
|
|
@@ -2319,7 +2317,7 @@ async function spawnSubagent(
|
|
|
2319
2317
|
// Long-lived RPC worker: the task is NOT passed as argv — it is injected
|
|
2320
2318
|
// below as an RPC `prompt` command, whose response we validate so spawn
|
|
2321
2319
|
// failures (bad model, missing API key) are loud instead of a silent exit=1.
|
|
2322
|
-
const piArgs: string[] = ["--mode", "rpc"
|
|
2320
|
+
const piArgs: string[] = ["--mode", "rpc"];
|
|
2323
2321
|
const model = opts.model ?? opts.agent?.model;
|
|
2324
2322
|
if (model) piArgs.push("--model", model);
|
|
2325
2323
|
const tools = opts.tools ?? opts.agent?.tools;
|
|
@@ -2530,16 +2528,14 @@ async function spawnSubagent(
|
|
|
2530
2528
|
}
|
|
2531
2529
|
|
|
2532
2530
|
// ---------------------------------------------------------------------------
|
|
2533
|
-
// widget (
|
|
2531
|
+
// widget (passive running-session list)
|
|
2534
2532
|
// ---------------------------------------------------------------------------
|
|
2535
2533
|
|
|
2536
2534
|
export function widgetSessionGroups(sessions: BsSession[], startedAt: (id: string) => number) {
|
|
2537
2535
|
const active = sessions.filter((session) => session.state === "running");
|
|
2538
|
-
const started = new Map(
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
const finished = sessions.filter((session) => session.state !== "running").sort(newestFirst);
|
|
2542
|
-
return { active, finished, all: [...sessions].sort(newestFirst) };
|
|
2536
|
+
const started = new Map(active.map((session) => [session.id, startedAt(session.id)]));
|
|
2537
|
+
active.sort((a, b) => (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id));
|
|
2538
|
+
return { active };
|
|
2543
2539
|
}
|
|
2544
2540
|
|
|
2545
2541
|
// A subagent whose task is done stays alive as an idle RPC worker (for
|
|
@@ -2597,568 +2593,25 @@ function renderWidgetSessionHeader(
|
|
|
2597
2593
|
return ` ${icon} ${id} ${kindLabel} ${stateLabel}${elapsed ? theme.fg("dim", ` age ${elapsed}`) : ""}`;
|
|
2598
2594
|
}
|
|
2599
2595
|
|
|
2600
|
-
// How many trailing output lines to show per session in the widget.
|
|
2601
|
-
const WIDGET_COLLAPSED_TAIL_LINES = 1;
|
|
2602
|
-
const WIDGET_SESSION_PAGE_SIZE = 10;
|
|
2603
|
-
const WIDGET_VIEWER_MAX_LINES = 10;
|
|
2604
|
-
|
|
2605
|
-
export interface WidgetSessionListState {
|
|
2606
|
-
mode: "running" | "all";
|
|
2607
|
-
page: number;
|
|
2608
|
-
}
|
|
2609
|
-
|
|
2610
2596
|
export interface WidgetSessionDisplay {
|
|
2611
2597
|
id: string;
|
|
2612
|
-
description?: string;
|
|
2613
2598
|
header: string;
|
|
2614
|
-
|
|
2615
|
-
viewable: boolean;
|
|
2616
|
-
active?: boolean;
|
|
2617
|
-
details?: string[];
|
|
2618
|
-
}
|
|
2619
|
-
|
|
2620
|
-
interface WidgetMouseEvent {
|
|
2621
|
-
type: string;
|
|
2622
|
-
button: string;
|
|
2623
|
-
y: number;
|
|
2624
|
-
x?: number;
|
|
2599
|
+
description?: string;
|
|
2625
2600
|
}
|
|
2626
2601
|
|
|
2627
|
-
/**
|
|
2628
|
-
|
|
2629
|
-
*/
|
|
2630
|
-
export function createWidgetComponent(
|
|
2631
|
-
summaryLines: string[],
|
|
2632
|
-
sessions: WidgetSessionDisplay[],
|
|
2633
|
-
onOpen: (id: string) => void,
|
|
2634
|
-
listState?: WidgetSessionListState,
|
|
2635
|
-
) {
|
|
2636
|
-
type Target = { type: "session"; id: string } | { type: "sessions"; endX: number; prevX?: number; nextX?: number };
|
|
2637
|
-
let targetByLine: Array<Target | undefined> = [];
|
|
2638
|
-
|
|
2602
|
+
/** Passive running-session list; no input handlers or log reads. */
|
|
2603
|
+
export function createWidgetComponent(summaryLines: string[], sessions: WidgetSessionDisplay[]) {
|
|
2639
2604
|
return {
|
|
2640
2605
|
render(width: number): string[] {
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
targetByLine.push(target);
|
|
2646
|
-
};
|
|
2647
|
-
const pushSession = (session: WidgetSessionDisplay) => {
|
|
2648
|
-
const target = session.viewable ? { type: "session" as const, id: session.id } : undefined;
|
|
2649
|
-
const detail = session.details?.[0];
|
|
2650
|
-
const preview = session.description ?? (detail?.startsWith("task: ") ? detail.slice(6) : detail ?? session.tail.at(-1));
|
|
2651
|
-
push(`${session.header}${preview ? ` ${preview}` : ""}`, target, "…");
|
|
2652
|
-
};
|
|
2653
|
-
|
|
2654
|
-
for (const line of summaryLines.slice(0, -1)) push(line);
|
|
2655
|
-
const summary = summaryLines.at(-1);
|
|
2656
|
-
if (listState && sessions.length) {
|
|
2657
|
-
const visible = listState.mode === "running" ? sessions.filter((session) => session.active) : sessions;
|
|
2658
|
-
const pageCount = Math.max(1, Math.ceil(visible.length / WIDGET_SESSION_PAGE_SIZE));
|
|
2659
|
-
listState.page = Math.min(Math.max(0, listState.page), pageCount - 1);
|
|
2660
|
-
const label = ` babysits(${visible.length} ${listState.mode})`;
|
|
2661
|
-
const showArrows = pageCount > 1;
|
|
2662
|
-
const suffix = `${label}${showArrows ? ` < ${listState.page + 1}/${pageCount} >` : ""}`;
|
|
2663
|
-
// Keep the count clickable on narrow terminals by moving it to its own row.
|
|
2664
|
-
const inline = summary !== undefined && visibleWidth(summary) + visibleWidth(suffix) <= width;
|
|
2665
|
-
if (summary !== undefined && !inline) {
|
|
2666
|
-
push(summary, { type: "sessions", endX: Math.min(visibleWidth(summary), Math.max(0, width)) });
|
|
2667
|
-
}
|
|
2668
|
-
const prefix = inline ? summary ?? "" : "";
|
|
2669
|
-
const prefixWidth = visibleWidth(prefix);
|
|
2670
|
-
push(`${prefix}${suffix}`, {
|
|
2671
|
-
type: "sessions",
|
|
2672
|
-
endX: Math.min(prefixWidth + visibleWidth(suffix), Math.max(0, width)),
|
|
2673
|
-
prevX: showArrows ? prefixWidth + suffix.indexOf("<") : undefined,
|
|
2674
|
-
nextX: showArrows ? prefixWidth + suffix.indexOf(">") : undefined,
|
|
2675
|
-
});
|
|
2676
|
-
const start = listState.page * WIDGET_SESSION_PAGE_SIZE;
|
|
2677
|
-
for (const session of visible.slice(start, start + WIDGET_SESSION_PAGE_SIZE)) {
|
|
2678
|
-
pushSession(session);
|
|
2679
|
-
}
|
|
2680
|
-
} else {
|
|
2681
|
-
if (summary !== undefined) push(summary);
|
|
2682
|
-
for (const session of sessions) pushSession(session);
|
|
2683
|
-
}
|
|
2684
|
-
return lines;
|
|
2685
|
-
},
|
|
2686
|
-
getSessionLine(id: string) {
|
|
2687
|
-
return targetByLine.findIndex((target) => target?.type === "session" && target.id === id);
|
|
2688
|
-
},
|
|
2689
|
-
handleMouse(event: WidgetMouseEvent) {
|
|
2690
|
-
if (event.button !== "left") return undefined;
|
|
2691
|
-
const target = targetByLine[event.y];
|
|
2692
|
-
if (!target) return undefined;
|
|
2693
|
-
if (target.type === "sessions" && (event.x === undefined || event.x < 0 || event.x >= target.endX)) return undefined;
|
|
2694
|
-
// Toggle on press so a terminal that omits release still works.
|
|
2695
|
-
if (event.type === "press") {
|
|
2696
|
-
if (target.type === "session") {
|
|
2697
|
-
onOpen(target.id);
|
|
2698
|
-
} else if (listState) {
|
|
2699
|
-
const count = listState.mode === "running" ? sessions.filter((session) => session.active).length : sessions.length;
|
|
2700
|
-
const pageCount = Math.max(1, Math.ceil(count / WIDGET_SESSION_PAGE_SIZE));
|
|
2701
|
-
if (event.x === target.prevX && target.prevX !== undefined) {
|
|
2702
|
-
listState.page = (listState.page - 1 + pageCount) % pageCount;
|
|
2703
|
-
} else if (event.x === target.nextX && target.nextX !== undefined) {
|
|
2704
|
-
listState.page = (listState.page + 1) % pageCount;
|
|
2705
|
-
} else {
|
|
2706
|
-
listState.mode = listState.mode === "running" ? "all" : "running";
|
|
2707
|
-
listState.page = 0;
|
|
2708
|
-
}
|
|
2709
|
-
}
|
|
2710
|
-
return { handled: true, render: true };
|
|
2711
|
-
}
|
|
2712
|
-
// Consume Pi's synthesized click without toggling a second time.
|
|
2713
|
-
if (event.type === "click") return { handled: true, render: false };
|
|
2714
|
-
return undefined;
|
|
2715
|
-
},
|
|
2716
|
-
invalidate() {
|
|
2717
|
-
targetByLine = [];
|
|
2718
|
-
},
|
|
2719
|
-
};
|
|
2720
|
-
}
|
|
2721
|
-
|
|
2722
|
-
export function createSessionWidget(options: {
|
|
2723
|
-
getHeight: () => number;
|
|
2724
|
-
requestRender: () => void;
|
|
2725
|
-
getTheme: () => Theme;
|
|
2726
|
-
readLog: (id: string) => { text?: string; file?: string; isAgent: boolean; clipped?: boolean; lineTimestamps?: (number | undefined)[] };
|
|
2727
|
-
}) {
|
|
2728
|
-
const listState: WidgetSessionListState = { mode: "running", page: 0 };
|
|
2729
|
-
let selectedId: string | undefined;
|
|
2730
|
-
let displays: WidgetSessionDisplay[] = [];
|
|
2731
|
-
let viewer: ReturnType<typeof createLogViewer> | undefined;
|
|
2732
|
-
let source: FileLogSource | undefined;
|
|
2733
|
-
let summaryLines: string[] = [];
|
|
2734
|
-
let listHeight = 0;
|
|
2735
|
-
let viewerRow = -1;
|
|
2736
|
-
let viewerLines = 0;
|
|
2737
|
-
let dragging = false;
|
|
2738
|
-
let list = createWidgetComponent([], [], toggle, listState);
|
|
2739
|
-
function rebuildList() {
|
|
2740
|
-
list = createWidgetComponent(summaryLines, displays, toggle, listState);
|
|
2741
|
-
}
|
|
2742
|
-
function toggle(id: string) {
|
|
2743
|
-
if (selectedId === id) close();
|
|
2744
|
-
else open(id);
|
|
2745
|
-
}
|
|
2746
|
-
function refreshViewer() {
|
|
2747
|
-
if (!selectedId || !viewer) return;
|
|
2748
|
-
const log = options.readLog(selectedId);
|
|
2749
|
-
const session = displays.find((session) => session.id === selectedId);
|
|
2750
|
-
const header = session?.header ?? selectedId;
|
|
2751
|
-
const context = (session?.details ?? [])
|
|
2752
|
-
.map((detail) => detail.startsWith("task: ") ? detail.slice(6) : detail)
|
|
2753
|
-
.join("\n");
|
|
2754
|
-
if (log.file) {
|
|
2755
|
-
if (!source || source.file !== log.file || source.isAgent !== log.isAgent)
|
|
2756
|
-
source = new FileLogSource(log.file, log.isAgent);
|
|
2757
|
-
viewer.updateSource(header, source, context);
|
|
2758
|
-
} else {
|
|
2759
|
-
viewer.update(header, log.text ?? "", log.isAgent, context, log.lineTimestamps);
|
|
2760
|
-
source = undefined;
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
function close() {
|
|
2764
|
-
selectedId = undefined;
|
|
2765
|
-
viewerRow = -1;
|
|
2766
|
-
viewerLines = 0;
|
|
2767
|
-
dragging = false;
|
|
2768
|
-
viewer?.dispose();
|
|
2769
|
-
viewer = undefined;
|
|
2770
|
-
source = undefined;
|
|
2771
|
-
options.requestRender();
|
|
2772
|
-
}
|
|
2773
|
-
function open(id: string) {
|
|
2774
|
-
if (!displays.some((session) => session.id === id)) {
|
|
2775
|
-
displays = [...displays, { id, header: id, tail: [], viewable: true, active: true }];
|
|
2776
|
-
}
|
|
2777
|
-
if (listState.mode === "running" && !displays.find((session) => session.id === id)?.active)
|
|
2778
|
-
listState.mode = "all";
|
|
2779
|
-
const visible = listState.mode === "running" ? displays.filter((session) => session.active) : displays;
|
|
2780
|
-
listState.page = Math.floor(visible.findIndex((session) => session.id === id) / WIDGET_SESSION_PAGE_SIZE);
|
|
2781
|
-
rebuildList();
|
|
2782
|
-
dragging = false;
|
|
2783
|
-
viewer?.dispose();
|
|
2784
|
-
source = undefined;
|
|
2785
|
-
selectedId = id;
|
|
2786
|
-
viewer = createLogViewer({
|
|
2787
|
-
...options,
|
|
2788
|
-
getHeight: () => Math.max(3, Math.min(WIDGET_VIEWER_MAX_LINES, options.getHeight() - listHeight + 1)),
|
|
2789
|
-
onClose: close,
|
|
2790
|
-
});
|
|
2791
|
-
refreshViewer();
|
|
2792
|
-
options.requestRender();
|
|
2793
|
-
}
|
|
2794
|
-
return {
|
|
2795
|
-
open,
|
|
2796
|
-
/** Re-read the open viewer's log source (e.g. after a remote mirror sync). */
|
|
2797
|
-
refresh() {
|
|
2798
|
-
if (!selectedId) return;
|
|
2799
|
-
refreshViewer();
|
|
2800
|
-
options.requestRender();
|
|
2801
|
-
},
|
|
2802
|
-
update(summary: string[], sessions: WidgetSessionDisplay[]) {
|
|
2803
|
-
displays = sessions;
|
|
2804
|
-
summaryLines = summary;
|
|
2805
|
-
rebuildList();
|
|
2806
|
-
if (selectedId && !sessions.some((session) => session.id === selectedId && (listState.mode === "all" || session.active))) close();
|
|
2807
|
-
refreshViewer();
|
|
2808
|
-
options.requestRender();
|
|
2606
|
+
return [
|
|
2607
|
+
...summaryLines,
|
|
2608
|
+
...sessions.map((session) => `${session.header}${session.description ? ` ${session.description}` : ""}`),
|
|
2609
|
+
].map((line) => truncateToWidth(line, Math.max(0, width), "…"));
|
|
2809
2610
|
},
|
|
2810
|
-
|
|
2811
|
-
const lines = list.render(width);
|
|
2812
|
-
listHeight = lines.length;
|
|
2813
|
-
viewerRow = selectedId ? list.getSessionLine(selectedId) : -1;
|
|
2814
|
-
viewerLines = 0;
|
|
2815
|
-
if (viewer && viewerRow >= 0) {
|
|
2816
|
-
// Keep the original styled row as the toggle header.
|
|
2817
|
-
const body = viewer.render(width).slice(1);
|
|
2818
|
-
viewerLines = body.length;
|
|
2819
|
-
lines.splice(viewerRow + 1, 0, ...body);
|
|
2820
|
-
}
|
|
2821
|
-
return lines;
|
|
2822
|
-
},
|
|
2823
|
-
handleMouse(event: TuiMouseEvent) {
|
|
2824
|
-
if (viewer && viewerRow >= 0 && (dragging || (event.y > viewerRow && event.y <= viewerRow + viewerLines))) {
|
|
2825
|
-
const result = viewer.handleMouse({ ...event, y: event.y - viewerRow });
|
|
2826
|
-
if (result && "capture" in result && result.capture) dragging = true;
|
|
2827
|
-
if (event.type === "release") dragging = false;
|
|
2828
|
-
return result;
|
|
2829
|
-
}
|
|
2830
|
-
const mode = listState.mode, page = listState.page;
|
|
2831
|
-
const result = list.handleMouse({ ...event, y: event.y > viewerRow && viewerRow >= 0 ? event.y - viewerLines : event.y });
|
|
2832
|
-
if (mode !== listState.mode || page !== listState.page) close();
|
|
2833
|
-
return result;
|
|
2834
|
-
},
|
|
2835
|
-
handleKey(data: string) { return viewer?.handleKey(data) ?? false; },
|
|
2836
|
-
invalidate() { list.invalidate(); viewer?.invalidate(); },
|
|
2837
|
-
};
|
|
2838
|
-
}
|
|
2839
|
-
|
|
2840
|
-
/** Capture times are optional: old logs and incomplete sidecars remain readable. */
|
|
2841
|
-
function readLineTimestamps(file: string, data: Buffer, offset: number): (number | undefined)[] {
|
|
2842
|
-
const metadata = readViewerLog(path.join(path.dirname(file), "output.timestamps.jsonl")).text;
|
|
2843
|
-
const ranges: { start: number; end: number; time: number }[] = [];
|
|
2844
|
-
for (const line of metadata.split("\n")) {
|
|
2845
|
-
try {
|
|
2846
|
-
const record = JSON.parse(line);
|
|
2847
|
-
if (!Array.isArray(record) || record.length !== 3) continue;
|
|
2848
|
-
const [start, length, time] = record;
|
|
2849
|
-
if (!Number.isSafeInteger(start) || start < 0 ||
|
|
2850
|
-
!Number.isSafeInteger(length) || length <= 0 ||
|
|
2851
|
-
!Number.isSafeInteger(start + length) ||
|
|
2852
|
-
!Number.isSafeInteger(time) || time < 0 || time > 8640000000000000 ||
|
|
2853
|
-
start < (ranges.at(-1)?.end ?? 0)) continue;
|
|
2854
|
-
ranges.push({ start, end: start + length, time });
|
|
2855
|
-
} catch { /* A partial or malformed record has no trustworthy time. */ }
|
|
2856
|
-
}
|
|
2857
|
-
let range = 0;
|
|
2858
|
-
const timestamps: (number | undefined)[] = [];
|
|
2859
|
-
const addLine = (position: number) => {
|
|
2860
|
-
const absolute = offset + position;
|
|
2861
|
-
while (range < ranges.length && ranges[range].end <= absolute) range++;
|
|
2862
|
-
const entry = ranges[range];
|
|
2863
|
-
timestamps.push(position < data.length && entry && entry.start <= absolute ? entry.time : undefined);
|
|
2864
|
-
};
|
|
2865
|
-
addLine(0);
|
|
2866
|
-
for (let i = 0; i < data.length; i++) {
|
|
2867
|
-
if (data[i] !== 10 && data[i] !== 13) continue;
|
|
2868
|
-
if (data[i] === 13 && data[i + 1] === 10) i++;
|
|
2869
|
-
addLine(i + 1);
|
|
2870
|
-
}
|
|
2871
|
-
return timestamps;
|
|
2872
|
-
}
|
|
2873
|
-
|
|
2874
|
-
/** Only the selected session is read; bounded snapshots never affect worker logs. */
|
|
2875
|
-
export function readViewerLog(file: string, withTimestamps = false): {
|
|
2876
|
-
text: string; clipped: boolean; lineTimestamps?: (number | undefined)[];
|
|
2877
|
-
} {
|
|
2878
|
-
try {
|
|
2879
|
-
const fd = fs.openSync(file, "r");
|
|
2880
|
-
try {
|
|
2881
|
-
const size = fs.fstatSync(fd).size;
|
|
2882
|
-
const start = Math.max(0, size - 1024 * 1024);
|
|
2883
|
-
const buffer = Buffer.alloc(size - start);
|
|
2884
|
-
const bytes = fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
2885
|
-
let data = buffer.subarray(0, bytes);
|
|
2886
|
-
let offset = start;
|
|
2887
|
-
if (start > 0) {
|
|
2888
|
-
const skip = data.indexOf(10) + 1;
|
|
2889
|
-
data = data.subarray(skip);
|
|
2890
|
-
offset += skip;
|
|
2891
|
-
}
|
|
2892
|
-
const text = data.toString("utf8");
|
|
2893
|
-
return {
|
|
2894
|
-
text, clipped: start > 0,
|
|
2895
|
-
...(withTimestamps ? { lineTimestamps: readLineTimestamps(file, data, offset) } : {}),
|
|
2896
|
-
};
|
|
2897
|
-
} finally { fs.closeSync(fd); }
|
|
2898
|
-
} catch {
|
|
2899
|
-
return { text: "", clipped: false };
|
|
2900
|
-
}
|
|
2901
|
-
}
|
|
2902
|
-
|
|
2903
|
-
// Strip ANSI/control escapes and clamp width so raw PTY output can't wrap or
|
|
2904
|
-
// corrupt the widget area.
|
|
2905
|
-
function sanitizeTailLine(s: string): string {
|
|
2906
|
-
// PTY progress bars often redraw one logical line with carriage returns.
|
|
2907
|
-
// Keep the latest frame rather than concatenating every historical frame.
|
|
2908
|
-
const terminalFrame = s.split("\r").filter(Boolean).at(-1) ?? "";
|
|
2909
|
-
const clean = terminalFrame
|
|
2910
|
-
// CSI / OSC / other escape sequences
|
|
2911
|
-
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "")
|
|
2912
|
-
.replace(/\x1b[@-Z\\-_]|\x1b\[[0-?]*[ -/]*[@-~]/g, "")
|
|
2913
|
-
// remaining non-printable control chars (keep tab)
|
|
2914
|
-
.replace(/[\x00-\x08\x0b-\x1f\x7f]/g, "");
|
|
2915
|
-
return clean;
|
|
2916
|
-
}
|
|
2917
|
-
|
|
2918
|
-
function readTailLines(file: string, lines: number, maxBytes = 64_000): string[] {
|
|
2919
|
-
try {
|
|
2920
|
-
const size = fs.statSync(file).size;
|
|
2921
|
-
const start = Math.max(0, size - maxBytes);
|
|
2922
|
-
const length = size - start;
|
|
2923
|
-
const bytes = Buffer.allocUnsafe(length);
|
|
2924
|
-
const fd = fs.openSync(file, "r");
|
|
2925
|
-
let read = 0;
|
|
2926
|
-
try {
|
|
2927
|
-
while (read < length) {
|
|
2928
|
-
const count = fs.readSync(fd, bytes, read, length - read, start + read);
|
|
2929
|
-
if (count === 0) break;
|
|
2930
|
-
read += count;
|
|
2931
|
-
}
|
|
2932
|
-
} finally {
|
|
2933
|
-
fs.closeSync(fd);
|
|
2934
|
-
}
|
|
2935
|
-
const parts = bytes.subarray(0, read).toString("utf8").split("\n");
|
|
2936
|
-
if (start > 0) parts.shift(); // first fragment may begin mid-line
|
|
2937
|
-
return parts.slice(-Math.max(1, lines + 1));
|
|
2938
|
-
} catch {
|
|
2939
|
-
return [];
|
|
2940
|
-
}
|
|
2941
|
-
}
|
|
2942
|
-
|
|
2943
|
-
const remoteTails = new Map<string, string[]>();
|
|
2944
|
-
// Running previews are not final. Only a successful post-terminal fetch is reusable.
|
|
2945
|
-
const remoteTerminalTails = new Set<string>();
|
|
2946
|
-
|
|
2947
|
-
// Remote logs stay on the remote host; the widget viewer reads a local,
|
|
2948
|
-
// append-only mirror built from incremental `log --since --raw --json` reads.
|
|
2949
|
-
// The cursor records the remote raw-byte offset and the local mirror size it
|
|
2950
|
-
// corresponds to, so an interrupted append is truncated instead of duplicated.
|
|
2951
|
-
type RemoteLogCursor = { remoteOffset: number; localBytes: number; timestampBytes: number; complete: boolean };
|
|
2952
|
-
type RemoteLogSyncState = { inFlight?: Promise<boolean>; lastSyncAt: number; lastChanged: boolean; error?: string };
|
|
2953
|
-
const remoteLogSyncs = new Map<string, RemoteLogSyncState>();
|
|
2954
|
-
const REMOTE_LOG_SYNC_INTERVAL_MS = 1000;
|
|
2955
|
-
// Hosts whose babysit predates `log --timestamps`; their mirrors have no dates.
|
|
2956
|
-
const remoteLogTimestampUnsupported = new Set<string>();
|
|
2957
|
-
|
|
2958
|
-
export function remoteLogMirrorPath(id: string): string {
|
|
2959
|
-
return path.join(ROOT, "remote-logs", id, "output.log");
|
|
2960
|
-
}
|
|
2961
|
-
|
|
2962
|
-
function remoteLogTimestampsPath(id: string): string {
|
|
2963
|
-
return path.join(path.dirname(remoteLogMirrorPath(id)), "output.timestamps.jsonl");
|
|
2964
|
-
}
|
|
2965
|
-
|
|
2966
|
-
function remoteLogCursorPath(id: string): string {
|
|
2967
|
-
return path.join(path.dirname(remoteLogMirrorPath(id)), "cursor.json");
|
|
2968
|
-
}
|
|
2969
|
-
|
|
2970
|
-
function readRemoteLogCursor(id: string): RemoteLogCursor {
|
|
2971
|
-
const file = remoteLogMirrorPath(id);
|
|
2972
|
-
const sidecar = remoteLogTimestampsPath(id);
|
|
2973
|
-
const empty: RemoteLogCursor = { remoteOffset: 0, localBytes: 0, timestampBytes: 0, complete: false };
|
|
2974
|
-
let cursor = empty;
|
|
2975
|
-
try {
|
|
2976
|
-
const parsed = JSON.parse(fs.readFileSync(remoteLogCursorPath(id), "utf8"));
|
|
2977
|
-
const valid = (value: unknown) => Number.isSafeInteger(value) && (value as number) >= 0;
|
|
2978
|
-
if (valid(parsed.remoteOffset) && valid(parsed.localBytes)) {
|
|
2979
|
-
cursor = { remoteOffset: parsed.remoteOffset, localBytes: parsed.localBytes, timestampBytes: valid(parsed.timestampBytes) ? parsed.timestampBytes : 0, complete: parsed.complete === true };
|
|
2980
|
-
}
|
|
2981
|
-
} catch {}
|
|
2982
|
-
const sizeOf = (target: string) => {
|
|
2983
|
-
try {
|
|
2984
|
-
return fs.statSync(target).size;
|
|
2985
|
-
} catch {
|
|
2986
|
-
return 0;
|
|
2987
|
-
}
|
|
2988
|
-
};
|
|
2989
|
-
const size = sizeOf(file);
|
|
2990
|
-
const sidecarSize = sizeOf(sidecar);
|
|
2991
|
-
if (size < cursor.localBytes || sidecarSize < cursor.timestampBytes) {
|
|
2992
|
-
// Committed data is missing; restart the mirror from scratch.
|
|
2993
|
-
if (size > 0) fs.truncateSync(file, 0);
|
|
2994
|
-
if (sidecarSize > 0) fs.truncateSync(sidecar, 0);
|
|
2995
|
-
return empty;
|
|
2996
|
-
}
|
|
2997
|
-
// Drop an append that was interrupted before its cursor was committed.
|
|
2998
|
-
if (size > cursor.localBytes) fs.truncateSync(file, cursor.localBytes);
|
|
2999
|
-
if (sidecarSize > cursor.timestampBytes) fs.truncateSync(sidecar, cursor.timestampBytes);
|
|
3000
|
-
return cursor;
|
|
3001
|
-
}
|
|
3002
|
-
|
|
3003
|
-
function writeRemoteLogCursor(id: string, cursor: RemoteLogCursor): void {
|
|
3004
|
-
const target = remoteLogCursorPath(id);
|
|
3005
|
-
const temp = `${target}.${process.pid}.tmp`;
|
|
3006
|
-
fs.writeFileSync(temp, JSON.stringify(cursor));
|
|
3007
|
-
fs.renameSync(temp, target);
|
|
3008
|
-
}
|
|
3009
|
-
|
|
3010
|
-
/** Fetch new remote output into the local mirror. Resolves true when it grew or completed. */
|
|
3011
|
-
export async function syncRemoteLog(id: string): Promise<boolean> {
|
|
3012
|
-
const state = remoteLogSyncs.get(id) ?? { lastSyncAt: 0, lastChanged: false };
|
|
3013
|
-
remoteLogSyncs.set(id, state);
|
|
3014
|
-
if (state.inFlight) return state.inFlight;
|
|
3015
|
-
state.inFlight = (async () => {
|
|
3016
|
-
const file = remoteLogMirrorPath(id);
|
|
3017
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
3018
|
-
if (!fs.existsSync(file)) fs.writeFileSync(file, "");
|
|
3019
|
-
const cursor = readRemoteLogCursor(id);
|
|
3020
|
-
if (cursor.complete) return false;
|
|
3021
|
-
const host = readMeta(id)?.host ?? "";
|
|
3022
|
-
const readArgs = ["log", "-s", id, "--since", String(cursor.remoteOffset), "--raw", "--json"];
|
|
3023
|
-
let withTimestamps = !remoteLogTimestampUnsupported.has(host);
|
|
3024
|
-
let result = await bs(withTimestamps ? [...readArgs, "--timestamps"] : readArgs, { unboundedStdout: true });
|
|
3025
|
-
if (withTimestamps && result.code !== 0 && /--timestamps/.test(result.stderr)) {
|
|
3026
|
-
// Older remote babysit: keep mirroring text, without capture dates.
|
|
3027
|
-
remoteLogTimestampUnsupported.add(host);
|
|
3028
|
-
withTimestamps = false;
|
|
3029
|
-
result = await bs(readArgs, { unboundedStdout: true });
|
|
3030
|
-
}
|
|
3031
|
-
if (result.code !== 0) {
|
|
3032
|
-
const error = clip(result.stderr.trim() || result.stdout.trim() || `babysit log exited ${result.code}`, 500);
|
|
3033
|
-
const changed = state.error !== error;
|
|
3034
|
-
state.error = error;
|
|
3035
|
-
return changed;
|
|
3036
|
-
}
|
|
3037
|
-
let response: { text?: unknown; offset?: unknown; done?: unknown; timestamps?: unknown };
|
|
3038
|
-
try {
|
|
3039
|
-
response = JSON.parse(result.stdout);
|
|
3040
|
-
} catch {
|
|
3041
|
-
state.error = `invalid remote log response: ${clip(result.stdout, 200)}`;
|
|
3042
|
-
return true;
|
|
3043
|
-
}
|
|
3044
|
-
if (typeof response.text !== "string" || !Number.isSafeInteger(response.offset) || (response.offset as number) < 0) {
|
|
3045
|
-
state.error = `invalid remote log response: ${clip(result.stdout, 200)}`;
|
|
3046
|
-
return true;
|
|
3047
|
-
}
|
|
3048
|
-
if ((response.offset as number) < cursor.remoteOffset) {
|
|
3049
|
-
// The remote log was replaced or truncated (e.g. restart); rebuild the mirror.
|
|
3050
|
-
fs.truncateSync(file, 0);
|
|
3051
|
-
fs.rmSync(remoteLogTimestampsPath(id), { force: true });
|
|
3052
|
-
writeRemoteLogCursor(id, { remoteOffset: 0, localBytes: 0, timestampBytes: 0, complete: false });
|
|
3053
|
-
return true;
|
|
3054
|
-
}
|
|
3055
|
-
const hadError = state.error !== undefined;
|
|
3056
|
-
state.error = undefined;
|
|
3057
|
-
const bytes = Buffer.from(response.text, "utf8");
|
|
3058
|
-
if (bytes.length > 0) fs.appendFileSync(file, bytes);
|
|
3059
|
-
const remoteStart = cursor.remoteOffset;
|
|
3060
|
-
const remoteEnd = response.offset as number;
|
|
3061
|
-
let timestampBytes = cursor.timestampBytes;
|
|
3062
|
-
// Remote rows use remote raw offsets. They map 1:1 onto the mirror only
|
|
3063
|
-
// when the chunk arrived byte-for-byte; a lossy UTF-8 replacement shifts
|
|
3064
|
-
// offsets, so that chunk is left undated rather than mis-dated.
|
|
3065
|
-
if (withTimestamps && Array.isArray(response.timestamps) && bytes.length === remoteEnd - remoteStart) {
|
|
3066
|
-
const rows = response.timestamps.flatMap((row) => {
|
|
3067
|
-
if (!Array.isArray(row) || row.length !== 3 || !row.every((value) => Number.isSafeInteger(value) && value >= 0)) return [];
|
|
3068
|
-
const [start, length, time] = row as number[];
|
|
3069
|
-
const begin = Math.max(start, remoteStart);
|
|
3070
|
-
const end = Math.min(start + length, remoteEnd);
|
|
3071
|
-
return end > begin ? [`[${cursor.localBytes + begin - remoteStart},${end - begin},${time}]\n`] : [];
|
|
3072
|
-
}).join("");
|
|
3073
|
-
if (rows) {
|
|
3074
|
-
fs.appendFileSync(remoteLogTimestampsPath(id), rows);
|
|
3075
|
-
timestampBytes += Buffer.byteLength(rows);
|
|
3076
|
-
}
|
|
3077
|
-
}
|
|
3078
|
-
const advanced = (response.offset as number) > cursor.remoteOffset || bytes.length > 0;
|
|
3079
|
-
// `done` is reported before the worker's final flush, so completion
|
|
3080
|
-
// requires a terminal read that returned nothing new.
|
|
3081
|
-
const complete = response.done === true && !advanced;
|
|
3082
|
-
writeRemoteLogCursor(id, { remoteOffset: remoteEnd, localBytes: cursor.localBytes + bytes.length, timestampBytes, complete });
|
|
3083
|
-
return advanced || complete || hadError;
|
|
3084
|
-
})().then((changed) => {
|
|
3085
|
-
state.lastChanged = changed;
|
|
3086
|
-
return changed;
|
|
3087
|
-
}, (error) => {
|
|
3088
|
-
state.error = error instanceof Error ? error.message : String(error);
|
|
3089
|
-
state.lastChanged = true;
|
|
3090
|
-
return true;
|
|
3091
|
-
}).finally(() => {
|
|
3092
|
-
state.lastSyncAt = Date.now();
|
|
3093
|
-
state.inFlight = undefined;
|
|
3094
|
-
});
|
|
3095
|
-
return state.inFlight;
|
|
3096
|
-
}
|
|
3097
|
-
|
|
3098
|
-
/**
|
|
3099
|
-
* Schedule a background mirror sync for an open viewer. Catch-up continues
|
|
3100
|
-
* immediately while output keeps arriving; otherwise reads are throttled.
|
|
3101
|
-
*/
|
|
3102
|
-
function scheduleRemoteLogSync(id: string, onChange: () => void): void {
|
|
3103
|
-
const state = remoteLogSyncs.get(id);
|
|
3104
|
-
if (state?.inFlight) return;
|
|
3105
|
-
if (state && !state.lastChanged && Date.now() - state.lastSyncAt < REMOTE_LOG_SYNC_INTERVAL_MS) return;
|
|
3106
|
-
if (state && !state.error && readRemoteLogCursor(id).complete) return;
|
|
3107
|
-
// Always re-render: the first read may replace a loading placeholder even
|
|
3108
|
-
// when the remote log is still empty. Re-entry is throttled above.
|
|
3109
|
-
void syncRemoteLog(id).then(() => onChange());
|
|
3110
|
-
}
|
|
3111
|
-
|
|
3112
|
-
function remoteLogViewerSource(id: string, onChange: () => void): { text?: string; file?: string; isAgent: boolean } {
|
|
3113
|
-
const file = remoteLogMirrorPath(id);
|
|
3114
|
-
scheduleRemoteLogSync(id, onChange);
|
|
3115
|
-
const error = remoteLogSyncs.get(id)?.error;
|
|
3116
|
-
let size = 0;
|
|
3117
|
-
try {
|
|
3118
|
-
size = fs.statSync(file).size;
|
|
3119
|
-
} catch {}
|
|
3120
|
-
if (size > 0) return { file, isAgent: false };
|
|
3121
|
-
const meta = readMeta(id);
|
|
3122
|
-
return {
|
|
3123
|
-
text: error
|
|
3124
|
-
? `Remote output unavailable on ${meta?.host}: ${error}\nLog: ${logPath(id)}\nTake over: ${attachCmd(id)}`
|
|
3125
|
-
: remoteLogSyncs.get(id)?.inFlight ? `Loading remote output from ${meta?.host}…` : "",
|
|
3126
|
-
isAgent: false,
|
|
2611
|
+
invalidate() {},
|
|
3127
2612
|
};
|
|
3128
2613
|
}
|
|
3129
2614
|
|
|
3130
|
-
// Trailing lines to show for a session (sanitized, unprefixed).
|
|
3131
|
-
// Process tails are read directly from the bounded end of output.log, avoiding
|
|
3132
|
-
// one `babysit log` subprocess per visible process.
|
|
3133
|
-
export function widgetTail(
|
|
3134
|
-
id: string,
|
|
3135
|
-
isSub: boolean,
|
|
3136
|
-
subagentProgress?: Progress,
|
|
3137
|
-
lines = WIDGET_COLLAPSED_TAIL_LINES,
|
|
3138
|
-
): string[] {
|
|
3139
|
-
let raw: string[];
|
|
3140
|
-
if (readMeta(id)?.host) return remoteTails.get(id) ?? ["Remote output unavailable; use babysit_check."];
|
|
3141
|
-
if (!isSub) {
|
|
3142
|
-
raw = readTailLines(logPath(id), lines);
|
|
3143
|
-
} else {
|
|
3144
|
-
const progress = subagentProgress ?? taskProgressOf(id).progress;
|
|
3145
|
-
if (progress.logText) {
|
|
3146
|
-
raw = progress.logText.split("\n");
|
|
3147
|
-
} else if (progress.streamingText.trim()) {
|
|
3148
|
-
raw = progress.streamingText.split("\n");
|
|
3149
|
-
} else if (progress.finalText.trim()) {
|
|
3150
|
-
raw = progress.finalText.split("\n");
|
|
3151
|
-
} else if (progress.toolCalls.length > 0) {
|
|
3152
|
-
raw = progress.toolCalls.map((tool) => tool.summary);
|
|
3153
|
-
} else {
|
|
3154
|
-
raw = progress.errorMsg ? [`⚠ ${progress.errorMsg}`] : [];
|
|
3155
|
-
}
|
|
3156
|
-
}
|
|
3157
|
-
return raw
|
|
3158
|
-
.map(sanitizeTailLine)
|
|
3159
|
-
.slice(-lines);
|
|
3160
|
-
}
|
|
3161
|
-
|
|
3162
2615
|
// ---------------------------------------------------------------------------
|
|
3163
2616
|
// human take-over hint (no tmux dependency)
|
|
3164
2617
|
// ---------------------------------------------------------------------------
|
|
@@ -3166,10 +2619,14 @@ export function widgetTail(
|
|
|
3166
2619
|
// The command a human can run in their OWN terminal/pane to take over a live
|
|
3167
2620
|
// process interactively (detach with Ctrl-\ Ctrl-\). `/babysit` shows this as a
|
|
3168
2621
|
// hint alongside an inline snapshot instead of spawning a tmux window itself.
|
|
3169
|
-
function
|
|
2622
|
+
function sessionCliCommand(id: string, command: "log" | "attach"): string {
|
|
3170
2623
|
const meta = readMeta(id);
|
|
3171
|
-
if (meta?.host) return `${shq(BABYSIT_BIN)} --host ${shq(meta.host)}
|
|
3172
|
-
return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)}
|
|
2624
|
+
if (meta?.host) return `${shq(BABYSIT_BIN)} --host ${shq(meta.host)} ${command} -s ${shq(meta.remoteId!)}`;
|
|
2625
|
+
return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} ${command} -s ${shq(id)}`;
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
function attachCmd(id: string): string {
|
|
2629
|
+
return sessionCliCommand(id, "attach");
|
|
3173
2630
|
}
|
|
3174
2631
|
|
|
3175
2632
|
// ---------------------------------------------------------------------------
|
|
@@ -4054,13 +3511,10 @@ export default function (pi: ExtensionAPI) {
|
|
|
4054
3511
|
}
|
|
4055
3512
|
}
|
|
4056
3513
|
|
|
4057
|
-
let sessionWidget: ReturnType<typeof createSessionWidget> | undefined;
|
|
4058
|
-
let unsubscribeWidgetInput: (() => void) | undefined;
|
|
4059
|
-
|
|
4060
3514
|
const refreshWidget = async (ctx: ExtensionContext, snapshot?: BsSession[]) => {
|
|
4061
3515
|
if (!ctx.hasUI) return;
|
|
4062
3516
|
const all = snapshot ?? (await listSessions()).sessions;
|
|
4063
|
-
const { active
|
|
3517
|
+
const { active } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
|
|
4064
3518
|
const subs = active.filter((session) => kindOf(session.id) === "subagent");
|
|
4065
3519
|
const procs = active.length - subs.length;
|
|
4066
3520
|
const progressById = new Map<string, Progress>();
|
|
@@ -4075,101 +3529,25 @@ export default function (pi: ExtensionAPI) {
|
|
|
4075
3529
|
|
|
4076
3530
|
const theme = ctx.ui.theme;
|
|
4077
3531
|
const summaryLines = renderWidgetLines(procs, subs.length - idle, idle, theme);
|
|
4078
|
-
|
|
4079
|
-
remoteTerminalTails.delete(session.id);
|
|
4080
|
-
const result = await bs(["log", "-s", session.id, "--tail", String(WIDGET_COLLAPSED_TAIL_LINES)]);
|
|
4081
|
-
remoteTails.set(session.id, result.code === 0 ? clip(result.stdout, TAIL_MAX_BYTES).trimEnd().split("\n").map(sanitizeTailLine) : [`Remote output unavailable on ${session.host}: ${clip(result.stderr, 200)}`]);
|
|
4082
|
-
if (result.code === 0 && !session.transportError && (isConfirmedTerminalState(session.state) || session.state === "dead")) remoteTerminalTails.add(session.id);
|
|
4083
|
-
}));
|
|
4084
|
-
const tails = await Promise.all(
|
|
4085
|
-
active.map((session) => {
|
|
4086
|
-
const isSubagent = kindOf(session.id) === "subagent";
|
|
4087
|
-
return widgetTail(
|
|
4088
|
-
session.id,
|
|
4089
|
-
isSubagent,
|
|
4090
|
-
isSubagent ? progressById.get(session.id) : undefined,
|
|
4091
|
-
WIDGET_COLLAPSED_TAIL_LINES,
|
|
4092
|
-
);
|
|
4093
|
-
}),
|
|
4094
|
-
);
|
|
4095
|
-
const activeTails = new Map(active.map((session, index) => [session.id, tails[index]]));
|
|
4096
|
-
const displays = ordered.map((session): WidgetSessionDisplay => {
|
|
3532
|
+
const displays = active.map((session): WidgetSessionDisplay => {
|
|
4097
3533
|
const id = session.id;
|
|
4098
3534
|
const meta = readMeta(id);
|
|
4099
3535
|
const isSubagent = meta?.kind === "subagent";
|
|
4100
|
-
const
|
|
4101
|
-
|
|
4102
|
-
|
|
4103
|
-
const details = isSubagent
|
|
4104
|
-
? [
|
|
4105
|
-
...(meta?.task ? [theme.fg("muted", `task: ${escapeCommandForDisplay(meta.task)}`)] : []),
|
|
4106
|
-
...(meta?.launchCommand ? [theme.fg("muted", escapeCommandForDisplay(readableLaunchCommand(meta.launchCommand)))] : []),
|
|
4107
|
-
]
|
|
4108
|
-
: meta?.command ? [theme.fg("muted", escapeCommandForDisplay(meta.command))] : [];
|
|
4109
|
-
if (description) details.unshift(description);
|
|
4110
|
-
if (meta?.host) details.unshift(`host: ${meta.host}${session.transportError ? " (status unknown)" : ""} · ${attachCmd(id)}`);
|
|
4111
|
-
if (session.state === "running") {
|
|
4112
|
-
const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
|
|
4113
|
-
return {
|
|
4114
|
-
id,
|
|
4115
|
-
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme) + (session.transportError ? " · status unknown" : ""),
|
|
4116
|
-
tail: activeTails.get(id) ?? [],
|
|
4117
|
-
description,
|
|
4118
|
-
viewable: true,
|
|
4119
|
-
active: true,
|
|
4120
|
-
details,
|
|
4121
|
-
};
|
|
4122
|
-
}
|
|
3536
|
+
const state: WidgetSessionState = isSubagent && progressById.get(id)?.done ? "idle" : "running";
|
|
3537
|
+
const description = meta?.description?.trim() || (isSubagent ? meta?.task : meta?.command);
|
|
3538
|
+
const host = session.host ?? meta?.host;
|
|
4123
3539
|
return {
|
|
4124
3540
|
id,
|
|
4125
|
-
header: renderWidgetSessionHeader(
|
|
4126
|
-
|
|
4127
|
-
|
|
4128
|
-
|
|
4129
|
-
"",
|
|
4130
|
-
theme,
|
|
4131
|
-
),
|
|
4132
|
-
details,
|
|
4133
|
-
description,
|
|
4134
|
-
// Only read/parse visible sessions on demand.
|
|
4135
|
-
get tail() {
|
|
4136
|
-
try {
|
|
4137
|
-
return widgetTail(id, isSubagent, undefined, WIDGET_COLLAPSED_TAIL_LINES);
|
|
4138
|
-
} catch {
|
|
4139
|
-
return [];
|
|
4140
|
-
}
|
|
4141
|
-
},
|
|
4142
|
-
viewable: true,
|
|
3541
|
+
header: renderWidgetSessionHeader(id, isSubagent ? "agent" : "process", state, elapsedOf(id), theme)
|
|
3542
|
+
+ (host ? ` · host: ${host}` : "")
|
|
3543
|
+
+ (session.transportError ? " · status unknown" : ""),
|
|
3544
|
+
description: description ? theme.fg("warning", theme.bold(escapeCommandForDisplay(description))) : undefined,
|
|
4143
3545
|
};
|
|
4144
3546
|
});
|
|
4145
|
-
|
|
4146
|
-
|
|
4147
|
-
|
|
4148
|
-
|
|
4149
|
-
sessionWidget = createSessionWidget({
|
|
4150
|
-
// Keep the viewer bounded while leaving room for editor/status/footer.
|
|
4151
|
-
getHeight: () => Math.max(3, tui.terminal.rows - 8),
|
|
4152
|
-
requestRender: () => tui.requestRender(),
|
|
4153
|
-
getTheme: () => ctx.ui.theme,
|
|
4154
|
-
readLog: (id) => {
|
|
4155
|
-
if (readMeta(id)?.host) return remoteLogViewerSource(id, () => sessionWidget?.refresh());
|
|
4156
|
-
const isAgent = kindOf(id) === "subagent";
|
|
4157
|
-
return { file: logPath(id), isAgent };
|
|
4158
|
-
},
|
|
4159
|
-
});
|
|
4160
|
-
return sessionWidget;
|
|
4161
|
-
}, { placement: "belowEditor" });
|
|
4162
|
-
unsubscribeWidgetInput = ctx.ui.onTerminalInput((data) =>
|
|
4163
|
-
sessionWidget?.handleKey(data) ? { consume: true } : undefined);
|
|
4164
|
-
}
|
|
4165
|
-
sessionWidget?.update(summaryLines, displays);
|
|
4166
|
-
} else {
|
|
4167
|
-
const activeIds = new Set(active.map((session) => session.id));
|
|
4168
|
-
const component = createWidgetComponent(summaryLines, displays.filter((session) => activeIds.has(session.id)), () => {});
|
|
4169
|
-
ctx.ui.setWidget("pi-babysit", component.render(10_000), {
|
|
4170
|
-
placement: "belowEditor",
|
|
4171
|
-
});
|
|
4172
|
-
}
|
|
3547
|
+
const component = createWidgetComponent(summaryLines, displays);
|
|
3548
|
+
ctx.ui.setWidget("pi-babysit", ctx.mode === "tui" ? () => component : component.render(10_000), {
|
|
3549
|
+
placement: "belowEditor",
|
|
3550
|
+
});
|
|
4173
3551
|
};
|
|
4174
3552
|
|
|
4175
3553
|
type DisplayStatus = "started" | "running" | "idle" | "success" | "failed" | "terminated";
|
|
@@ -4287,10 +3665,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
4287
3665
|
}
|
|
4288
3666
|
taskProgressCache.clear();
|
|
4289
3667
|
searchLogCache.clear();
|
|
4290
|
-
remoteTails.clear();
|
|
4291
|
-
remoteTerminalTails.clear();
|
|
4292
|
-
remoteLogSyncs.clear();
|
|
4293
|
-
remoteLogTimestampUnsupported.clear();
|
|
4294
3668
|
pollNeeded = true;
|
|
4295
3669
|
const retentionDays = Number(process.env.PI_BABYSIT_RETENTION_DAYS ?? "3");
|
|
4296
3670
|
if (
|
|
@@ -4353,9 +3727,6 @@ export default function (pi: ExtensionAPI) {
|
|
|
4353
3727
|
});
|
|
4354
3728
|
|
|
4355
3729
|
pi.on("session_shutdown", async (event) => {
|
|
4356
|
-
unsubscribeWidgetInput?.();
|
|
4357
|
-
unsubscribeWidgetInput = undefined;
|
|
4358
|
-
sessionWidget = undefined;
|
|
4359
3730
|
if (pollTimer) clearInterval(pollTimer);
|
|
4360
3731
|
pollTimer = undefined;
|
|
4361
3732
|
// Pi also emits session_shutdown while hot-reloading and switching sessions.
|
|
@@ -5671,16 +5042,58 @@ export default function (pi: ExtensionAPI) {
|
|
|
5671
5042
|
},
|
|
5672
5043
|
});
|
|
5673
5044
|
|
|
5045
|
+
const pickSession = async (ctx: ExtensionContext): Promise<BsSession | undefined> => {
|
|
5046
|
+
if (!(await babysitAvailable())) {
|
|
5047
|
+
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
|
|
5048
|
+
return;
|
|
5049
|
+
}
|
|
5050
|
+
const sessions = (await listSessions()).sessions.sort((a, b) =>
|
|
5051
|
+
a.state === b.state ? 0 : a.state === "running" ? -1 : 1,
|
|
5052
|
+
);
|
|
5053
|
+
if (sessions.length === 0) {
|
|
5054
|
+
ctx.ui.notify("No babysit sessions.", "info");
|
|
5055
|
+
return;
|
|
5056
|
+
}
|
|
5057
|
+
const labels = sessions.map((s) => {
|
|
5058
|
+
const meta = readMeta(s.id);
|
|
5059
|
+
const kind = kindOf(s.id);
|
|
5060
|
+
const icon = s.state === "running" ? "⏳" : s.exit_code === 0 ? "✓" : "✗";
|
|
5061
|
+
const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
|
|
5062
|
+
const flag = s.note ? " ⚑" : "";
|
|
5063
|
+
const what = escapeCommandForDisplay(sessionPreview(meta).replace(/\s+/g, " ").trim());
|
|
5064
|
+
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
5065
|
+
return `${icon} ${s.id}${flag} [${kind}] ${s.state}${ec}${preview ? ` — ${preview}` : ""}`;
|
|
5066
|
+
});
|
|
5067
|
+
const choice = await ctx.ui.select("Babysit sessions:", labels);
|
|
5068
|
+
if (!choice) return;
|
|
5069
|
+
return sessions[labels.indexOf(choice)];
|
|
5070
|
+
};
|
|
5071
|
+
|
|
5072
|
+
pi.registerCommand("babysit-log", {
|
|
5073
|
+
description: "Pick a session and put its babysit log command in the input buffer (does not execute it)",
|
|
5074
|
+
handler: async (args, ctx) => {
|
|
5075
|
+
if (args.trim()) {
|
|
5076
|
+
ctx.ui.notify("Usage: /babysit-log", "error");
|
|
5077
|
+
return;
|
|
5078
|
+
}
|
|
5079
|
+
if (!ctx.hasUI) {
|
|
5080
|
+
ctx.ui.notify("/babysit-log requires an interactive UI.", "error");
|
|
5081
|
+
return;
|
|
5082
|
+
}
|
|
5083
|
+
const picked = await pickSession(ctx);
|
|
5084
|
+
if (picked) ctx.ui.setEditorText(sessionCliCommand(picked.id, "log"));
|
|
5085
|
+
},
|
|
5086
|
+
});
|
|
5087
|
+
|
|
5674
5088
|
// ----- /babysit -----------------------------------------------------------
|
|
5675
5089
|
// Arrow up/down picker over all sessions (like /stash). Renders an INLINE
|
|
5676
5090
|
// snapshot (no tmux): running process → current rendered screen + recent
|
|
5677
5091
|
// output + a copy-paste `babysit attach` take-over hint; running subagent →
|
|
5678
5092
|
// read-only progress; finished → summary. Re-run to refresh.
|
|
5679
5093
|
pi.registerCommand("babysit", {
|
|
5680
|
-
description: "Pick a session to inspect; /babysit
|
|
5094
|
+
description: "Pick a session to inspect; /babysit gc [days] removes old terminal roots",
|
|
5681
5095
|
handler: async (args, ctx) => {
|
|
5682
|
-
const
|
|
5683
|
-
const command = view ? "" : args.trim();
|
|
5096
|
+
const command = args.trim();
|
|
5684
5097
|
if (command === "gc" || command.startsWith("gc ")) {
|
|
5685
5098
|
const daysText = command.slice(2).trim();
|
|
5686
5099
|
const days = daysText ? Number(daysText) : 14;
|
|
@@ -5715,46 +5128,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
5715
5128
|
return;
|
|
5716
5129
|
}
|
|
5717
5130
|
if (command) {
|
|
5718
|
-
ctx.ui.notify("Usage: /babysit
|
|
5131
|
+
ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
|
|
5719
5132
|
return;
|
|
5720
5133
|
}
|
|
5721
|
-
|
|
5722
|
-
ctx.ui.notify(babysitPreflightError ?? INSTALL_HINT, "error");
|
|
5723
|
-
return;
|
|
5724
|
-
}
|
|
5725
|
-
const sessions = (await listSessions()).sessions.sort((a, b) =>
|
|
5726
|
-
a.state === b.state ? 0 : a.state === "running" ? -1 : 1,
|
|
5727
|
-
);
|
|
5728
|
-
if (sessions.length === 0) {
|
|
5729
|
-
ctx.ui.notify("No babysit sessions.", "info");
|
|
5730
|
-
return;
|
|
5731
|
-
}
|
|
5732
|
-
|
|
5733
|
-
const whatOf = (s: BsSession): string => {
|
|
5734
|
-
const meta = readMeta(s.id);
|
|
5735
|
-
return escapeCommandForDisplay(sessionPreview(meta).replace(/\s+/g, " ").trim());
|
|
5736
|
-
};
|
|
5737
|
-
|
|
5738
|
-
// Labels must be unique for index mapping; the id makes them unique.
|
|
5739
|
-
const labels = sessions.map((s) => {
|
|
5740
|
-
const kind = kindOf(s.id);
|
|
5741
|
-
const icon = s.state === "running" ? "⏳" : s.exit_code === 0 ? "✓" : "✗";
|
|
5742
|
-
const ec = s.exit_code != null ? ` exit=${s.exit_code}` : "";
|
|
5743
|
-
const flag = s.note ? " ⚑" : "";
|
|
5744
|
-
const what = whatOf(s);
|
|
5745
|
-
const preview = what.length > 60 ? `${what.slice(0, 57)}…` : what;
|
|
5746
|
-
return `${icon} ${s.id}${flag} [${kind}] ${s.state}${ec}${preview ? ` — ${preview}` : ""}`;
|
|
5747
|
-
});
|
|
5748
|
-
|
|
5749
|
-
const choice = await ctx.ui.select("Babysit sessions:", labels);
|
|
5750
|
-
if (!choice) return;
|
|
5751
|
-
const picked = sessions[labels.indexOf(choice)];
|
|
5134
|
+
const picked = await pickSession(ctx);
|
|
5752
5135
|
if (!picked) return;
|
|
5753
|
-
if (view && ctx.mode === "tui") {
|
|
5754
|
-
await refreshWidget(ctx);
|
|
5755
|
-
sessionWidget?.open(picked.id);
|
|
5756
|
-
return;
|
|
5757
|
-
}
|
|
5758
5136
|
const kind = kindOf(picked.id);
|
|
5759
5137
|
const elapsed = picked.state === "running" ? elapsedOf(picked.id) : null;
|
|
5760
5138
|
const elapsedSuffix = elapsed ? ` ${elapsed}` : "";
|