@yusukeshib/pi-babysit 0.6.26 → 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/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, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
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";
@@ -2530,16 +2528,14 @@ async function spawnSubagent(
2530
2528
  }
2531
2529
 
2532
2530
  // ---------------------------------------------------------------------------
2533
- // widget (live counts and browsable sessions)
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(sessions.map((session) => [session.id, startedAt(session.id)]));
2539
- const newestFirst = (a: BsSession, b: BsSession) =>
2540
- (started.get(b.id) ?? 0) - (started.get(a.id) ?? 0) || b.id.localeCompare(a.id);
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
- tail: string[];
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
- * Compact session list. Item presses toggle the session's inline viewer.
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
- const lines: string[] = [];
2642
- targetByLine = [];
2643
- const push = (text: string, target?: Target, ellipsis = "") => {
2644
- lines.push(truncateToWidth(text, Math.max(0, width), ellipsis));
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
- render(width: number) {
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 attachCmd(id: string): string {
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)} attach -s ${shq(meta.remoteId!)}`;
3172
- return `BABYSIT_DIR=${shq(ROOT)} ${shq(BABYSIT_BIN)} attach -s ${shq(id)}`;
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, all: ordered } = widgetSessionGroups(all, (id) => readMeta(id)?.startedAt ?? 0);
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
- await Promise.all(ordered.filter((session) => session.host && (session.state === "running" || !remoteTerminalTails.has(session.id))).map(async (session) => {
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 description = meta?.description?.trim()
4101
- ? theme.fg("warning", theme.bold(escapeCommandForDisplay(meta.description.trim())))
4102
- : undefined;
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
- id,
4127
- isSubagent ? "agent" : "process",
4128
- session.state === "exited" && session.exit_code === 0 ? "finished" : "failed",
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
- if (ctx.mode === "tui") {
4147
- if (!sessionWidget) {
4148
- ctx.ui.setWidget("pi-babysit", (tui) => {
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 view opens its log viewer; /babysit gc [days] removes old terminal roots",
5094
+ description: "Pick a session to inspect; /babysit gc [days] removes old terminal roots",
5681
5095
  handler: async (args, ctx) => {
5682
- const view = args.trim() === "view";
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, /babysit view, or /babysit gc [days]", "error");
5131
+ ctx.ui.notify("Usage: /babysit or /babysit gc [days]", "error");
5719
5132
  return;
5720
5133
  }
5721
- if (!(await babysitAvailable())) {
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}` : "";