;
+ streams: ReadonlyArray
;
+}>;
+
+const state = vi.hoisted(() => ({ atom: undefined as unknown }));
+vi.mock("../../state/scriptProgress", () => ({
+ scriptProgressEnvironment: { watch: () => state.atom },
+}));
+
+let root: Root;
+let container: HTMLDivElement;
+let atom: Atom.Writable;
+const descriptor: ScriptProgressStream = {
+ id: "script",
+ pid: 42,
+ label: "render.ts",
+ status: "connected",
+ summary: [{ text: "Rendering", value: 4, total: 10, detail: "4/10" }],
+};
+
+beforeEach(async () => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ const initial: ProgressResult = AsyncResult.success({
+ events: [{ type: "snapshot", streams: [] }],
+ streams: [],
+ });
+ atom = Atom.make(initial);
+ state.atom = atom;
+ container = document.createElement("div");
+ document.body.append(container);
+ root = createRoot(container);
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+});
+
+afterEach(async () => {
+ await act(async () => root.unmount());
+ container.remove();
+ vi.unstubAllGlobals();
+});
+
+async function emit(event: ScriptProgressEvent) {
+ await act(async () => {
+ appAtomRegistry.set(atom, AsyncResult.success({ events: [event], streams: [] }));
+ });
+}
+
+function progressbar() {
+ const tree = container.querySelector("[data-progress-motion]");
+ return tree?.shadowRoot?.querySelector('[role="progressbar"]');
+}
+
+it("renders actual progress values and resets to zero without resetting the tree", async () => {
+ expect(container.querySelector("section")?.hidden).toBe(true);
+ await emit({ type: "snapshot", streams: [descriptor] });
+ await emit({
+ type: "progress",
+ id: descriptor.id,
+ event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
+ summary: descriptor.summary,
+ });
+ await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
+ expect(progressbar()?.getAttribute("aria-valuemax")).toBe("10");
+ expect(container.querySelector("section")?.hidden).toBe(false);
+
+ await emit({
+ type: "progress",
+ id: descriptor.id,
+ event: [0, { k: 1, v: 0 }],
+ summary: [{ text: "Rendering", value: 0, total: 10, detail: "0/10" }],
+ });
+ await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("0"));
+});
+
+it("removes the old chat's decoder and DOM before displaying another chat", async () => {
+ await emit({ type: "snapshot", streams: [descriptor] });
+ await emit({
+ type: "progress",
+ id: descriptor.id,
+ event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
+ summary: descriptor.summary,
+ });
+ await vi.waitFor(() => expect(progressbar()).toBeTruthy());
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ expect(progressbar()).toBeUndefined();
+ expect(container.querySelector("section")?.hidden).toBe(true);
+});
+
+it("requests a fresh tree when returning to a chat whose live atom cached a delta", async () => {
+ let connections = 0;
+ const snapshot: ScriptProgressEvent = { type: "snapshot", streams: [descriptor] };
+ const update: ScriptProgressEvent = {
+ type: "progress",
+ id: descriptor.id,
+ event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
+ summary: descriptor.summary,
+ };
+ state.atom = Atom.make(
+ Stream.suspend(() => {
+ connections++;
+ return Stream.concat(
+ Stream.fromArray([{ events: [snapshot, update], streams: [descriptor] }]),
+ Stream.never,
+ );
+ }),
+ ).pipe(Atom.keepAlive);
+
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+ await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
+ const initialConnections = connections;
+ await act(async () => root.unmount());
+ root = createRoot(container);
+ await act(async () => {
+ root.render(
+ ,
+ );
+ });
+
+ await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
+ expect(connections).toBeGreaterThan(initialConnections);
+});
+
+it("hides the panel when a passive grouping node has no visible tasks left", async () => {
+ await emit({ type: "snapshot", streams: [descriptor] });
+ await emit({
+ type: "progress",
+ id: descriptor.id,
+ event: [-1, { k: 1, t: "Group", p: true, c: [2] }, { k: 2, t: "Compiling", v: 1, e: 4 }, [1]],
+ summary: descriptor.summary,
+ });
+ await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(false));
+ await emit({
+ type: "progress",
+ id: descriptor.id,
+ event: [1, 2, { k: 1, c: [] }],
+ summary: [],
+ });
+ await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(true));
+});
+
+it("hides an empty disconnected script while another script is running", async () => {
+ const empty = { ...descriptor, id: "empty", status: "disconnected" as const, summary: [] };
+ await emit({ type: "snapshot", streams: [empty, descriptor] });
+ await emit({ type: "progress", id: empty.id, event: [-1, []], summary: [] });
+ await emit({
+ type: "progress",
+ id: descriptor.id,
+ event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
+ summary: descriptor.summary,
+ });
+
+ const section = container.querySelector("section");
+ await vi.waitFor(() => expect(section?.hidden).toBe(false));
+ expect(section?.children[0]?.getAttribute("hidden")).toBe("");
+ expect(section?.children[1]?.hasAttribute("hidden")).toBe(false);
+});
diff --git a/apps/web/src/components/chat/ScriptProgress.tsx b/apps/web/src/components/chat/ScriptProgress.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..3c8e60e42a0ec766b0d429bea5c84240665a39c8
--- /dev/null
+++ b/apps/web/src/components/chat/ScriptProgress.tsx
@@ -0,0 +1,162 @@
+import * as progress from "@clo/lib/progress";
+import { attachToDocument } from "@clo/lib/progress/browser";
+import type {
+ EnvironmentId,
+ ScriptProgressEvent,
+ ScriptProgressStream,
+ ThreadId,
+} from "@t3tools/contracts";
+import { AsyncResult } from "effect/reactivity";
+import { useEffect, useRef } from "react";
+
+import { appAtomRegistry } from "../../rpc/atomRegistry";
+import { scriptProgressEnvironment } from "../../state/scriptProgress";
+
+export function ScriptProgress({
+ environmentId,
+ threadId,
+}: {
+ environmentId: EnvironmentId;
+ threadId: ThreadId;
+}) {
+ const container = useRef(null);
+
+ useEffect(() => {
+ const host = container.current;
+ if (!host) return;
+
+ const streams = new Map<
+ string,
+ {
+ root: progress.Root;
+ input: ReadableStreamDefaultController;
+ element: HTMLElement;
+ status: HTMLElement;
+ dispose: () => void;
+ }
+ >();
+
+ const updateVisibility = () => {
+ let visible = false;
+ for (const stream of streams.values()) {
+ stream.element.hidden = !stream.root.active.some(progress.isVisible);
+ visible ||= !stream.element.hidden;
+ }
+ host.hidden = !visible;
+ };
+
+ const remove = (id: string) => {
+ const stream = streams.get(id);
+ if (!stream) return;
+ streams.delete(id);
+ stream.dispose();
+ stream.element.remove();
+ updateVisibility();
+ };
+
+ const upsert = (descriptor: ScriptProgressStream) => {
+ let stream = streams.get(descriptor.id);
+ if (!stream) {
+ const root = new progress.Root();
+ const element = document.createElement("div");
+ element.hidden = true;
+ const status = document.createElement("div");
+ status.className = "text-xs text-muted-foreground";
+ const tree = document.createElement("div");
+ tree.dataset.progressMotion = "static";
+ element.append(status, tree);
+ host.append(element);
+
+ const detach = attachToDocument(root, tree);
+ const off = root.on("change", updateVisibility);
+ let input: ReadableStreamDefaultController | undefined;
+ const readable = new ReadableStream({
+ start(controller) {
+ input = controller;
+ },
+ });
+ if (!input) throw new Error("Progress stream did not initialize.");
+
+ const decoder = progress.decodeEventStream(readable, root);
+ void decoder.catch(() => {
+ status.textContent = "Progress unavailable";
+ status.hidden = false;
+ updateVisibility();
+ });
+ stream = {
+ root,
+ input,
+ element,
+ status,
+ dispose: () => {
+ off();
+ decoder.cancel();
+ detach();
+ },
+ };
+ streams.set(descriptor.id, stream);
+ }
+
+ stream.status.textContent =
+ descriptor.status === "disconnected" ? `${descriptor.label} · Disconnected` : "";
+ stream.status.hidden = descriptor.status === "connected";
+ return stream;
+ };
+
+ const subscription = scriptProgressEnvironment.watch({
+ environmentId,
+ input: { threadId },
+ });
+ appAtomRegistry.refresh(subscription);
+ let previousEvents: ReadonlyArray | undefined;
+ const unsubscribe = appAtomRegistry.subscribe(
+ subscription,
+ (result) => {
+ if (!AsyncResult.isSuccess(result)) {
+ previousEvents = undefined;
+ for (const id of streams.keys()) remove(id);
+ return;
+ }
+
+ const { events } = result.value;
+ if (events === previousEvents) return;
+ previousEvents = events;
+ for (const event of events) {
+ switch (event.type) {
+ case "snapshot":
+ for (const id of streams.keys()) remove(id);
+ for (const descriptor of event.streams) upsert(descriptor);
+ break;
+ case "upsert":
+ upsert(event.stream);
+ break;
+ case "progress": {
+ const stream = streams.get(event.id);
+ // The shared decoder owns the stable wire format; RPC validates JSON at its boundary.
+ stream?.input.enqueue(event.event as progress.StreamEvent);
+ break;
+ }
+ case "remove":
+ remove(event.id);
+ break;
+ }
+ }
+ },
+ { immediate: true },
+ );
+
+ return () => {
+ unsubscribe();
+ for (const id of streams.keys()) remove(id);
+ };
+ }, [environmentId, threadId]);
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/state/scriptProgress.ts b/apps/web/src/state/scriptProgress.ts
new file mode 100644
index 0000000000000000000000000000000000000000..3624982b9138ebf66ee373ca930fd923b3f6237f
--- /dev/null
+++ b/apps/web/src/state/scriptProgress.ts
@@ -0,0 +1,6 @@
+import { createScriptProgressEnvironmentAtoms } from "@t3tools/client-runtime/state/script-progress";
+
+import { connectionAtomRuntime } from "../connection/runtime";
+
+export const scriptProgressEnvironment =
+ createScriptProgressEnvironmentAtoms(connectionAtomRuntime);
diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json
index a49328545a94d7fdf8cc47f82a0f2888fe53c96e..a6a879f86be677b1cccd665677e65e43ff7f7e78 100644
--- a/apps/web/tsconfig.json
+++ b/apps/web/tsconfig.json
@@ -7,7 +7,7 @@
"erasableSyntaxOnly": false,
"verbatimModuleSyntax": false,
"jsx": "react-jsx",
- "lib": ["ES2023", "DOM", "DOM.Iterable"],
+ "lib": ["ES2025", "DOM", "DOM.Iterable"],
"paths": {
"~/*": ["./src/*"]
},
diff --git a/docs/user/activity-log.md b/docs/user/activity-log.md
index 24b28b61ea9502bacd273730916f3badf101e478..390369d82779e6f3bd3eeec3a049531f3ff981aa 100644
--- a/docs/user/activity-log.md
+++ b/docs/user/activity-log.md
@@ -16,3 +16,17 @@ Failed calls do not count as successful messages or creations. Waiting on a thre
it finished, and an interrupt or cancellation request does not mean the thread stopped. When tool
details are unavailable, summaries use a broader description instead of guessing how many threads
were affected.
+
+## Script progress
+
+Node scripts using `@clo/lib/progress` show their live tasks above the composer
+when launched by Codex, Claude, Pi, Muse, an ACP agent, or the chat's terminal.
+Web and desktop show
+the nested task tree; mobile shows task counts. Command output stays available
+as usual. Progress remains visible when you reconnect to the chat remotely.
+
+Disconnected progress preserves the last reported values without claiming the
+command succeeded. The command's result determines whether it completed.
+
+Capture needs Node's synchronous module hooks and a separately loaded progress
+module. A progress library bundled into a script is not captured automatically.
diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json
index 703d418ac93a749739dd4a8419b092636b2a6ac0..f7444f2108a32a903e947387691e45f13ede4dde 100644
--- a/packages/client-runtime/package.json
+++ b/packages/client-runtime/package.json
@@ -418,6 +418,10 @@
"./pull-request-markdown": {
"types": "./src/pullRequestMarkdown.ts",
"default": "./src/pullRequestMarkdown.ts"
+ },
+ "./state/script-progress": {
+ "types": "./src/state/scriptProgress.ts",
+ "default": "./src/state/scriptProgress.ts"
}
},
"scripts": {
diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts
index bf2e2ddc800d3247bfef923fea2a3f28a82282d9..ae98ebbfea6f02c0c69fb4ea3de299b022398879 100644
--- a/packages/client-runtime/src/rpc/client.ts
+++ b/packages/client-runtime/src/rpc/client.ts
@@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
type RpcMethod = WsRpcProtocolClient[TTag];
export type EnvironmentSubscriptionRpcTag =
+ | typeof WS_METHODS.subscribeScriptProgress
| typeof WS_METHODS.codexAuthCallbackSubscribe
| typeof WS_METHODS.providerAuthSubscribe
| typeof WS_METHODS.providerInstallSubscribe
diff --git a/packages/client-runtime/src/state/scriptProgress.test.ts b/packages/client-runtime/src/state/scriptProgress.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..26dcc6030315f36fd79c7886f63c8a79d84c02b5
--- /dev/null
+++ b/packages/client-runtime/src/state/scriptProgress.test.ts
@@ -0,0 +1,101 @@
+import { describe, expect, vi } from "vite-plus/test";
+import { it } from "@effect/vitest";
+import {
+ EnvironmentId,
+ ThreadId,
+ type ScriptProgressEvent,
+ type ScriptProgressStream,
+} from "@t3tools/contracts";
+import * as Effect from "effect/Effect";
+import * as Layer from "effect/Layer";
+import * as Stream from "effect/Stream";
+import { Atom, AtomRegistry } from "effect/reactivity";
+
+import { EnvironmentRegistry } from "../connection/registry.ts";
+import {
+ applyScriptProgressEvent,
+ createScriptProgressEnvironmentAtoms,
+} from "./scriptProgress.ts";
+
+const source = vi.hoisted(() => ({ stream: undefined as unknown }));
+vi.mock("../rpc/client.ts", async (original) => ({
+ ...(await original()),
+ subscribe: () => source.stream,
+}));
+
+const build: ScriptProgressStream = {
+ id: "build",
+ pid: 42,
+ label: "build.ts",
+ status: "connected",
+ summary: [{ text: "Building", value: 4, total: 10, detail: "4/10" }],
+};
+
+describe("script progress summaries", () => {
+ it.effect("preserves every protocol event when RPC delivers a chunk in one atom update", () =>
+ Effect.scoped(
+ Effect.gen(function* () {
+ const zero = [{ text: "Building", value: 0, total: 10, detail: "0/10" }];
+ const events: ScriptProgressEvent[] = [
+ { type: "snapshot", streams: [] },
+ { type: "upsert", stream: build },
+ {
+ type: "progress",
+ id: build.id,
+ event: [-1, { k: 1, t: "Building", v: 4, e: 10 }, [1]],
+ summary: build.summary,
+ },
+ { type: "progress", id: build.id, event: [0, { k: 1, v: 0 }], summary: zero },
+ ];
+ source.stream = Stream.fromArray(events);
+ const runtime = Atom.runtime(
+ Layer.succeed(EnvironmentRegistry, {
+ followStream: (_id, stream) => stream,
+ } as EnvironmentRegistry["Service"]),
+ );
+ const progress = createScriptProgressEnvironmentAtoms(runtime);
+ const registry = AtomRegistry.make();
+ yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
+ const result = yield* AtomRegistry.getResult(
+ registry,
+ progress.watch({
+ environmentId: EnvironmentId.make("chunk-proof"),
+ input: { threadId: ThreadId.make("thread") },
+ }),
+ );
+ expect(result.events).toEqual(events);
+ expect(result.streams).toEqual([{ ...build, summary: zero }]);
+ }),
+ ),
+ );
+
+ it("replaces a prior connection's streams with the reconnect snapshot", () => {
+ expect(applyScriptProgressEvent([build], { type: "snapshot", streams: [] })).toEqual([]);
+ });
+
+ it("keeps independent scripts and accepts progress resets to zero", () => {
+ const other = { ...build, id: "other", pid: 43 };
+ const summaries = applyScriptProgressEvent([build], { type: "upsert", stream: other });
+ const zero = [{ text: "Building", value: 0, total: 10, detail: "0/10" }];
+
+ expect(
+ applyScriptProgressEvent(summaries, {
+ type: "progress",
+ id: build.id,
+ event: [0, { k: 1, v: 0 }],
+ summary: zero,
+ }),
+ ).toEqual([{ ...build, summary: zero }, other]);
+ });
+
+ it("preserves the last summary when disconnected without reporting success", () => {
+ const disconnected = { ...build, status: "disconnected" as const };
+ const summaries = applyScriptProgressEvent([build], {
+ type: "upsert",
+ stream: disconnected,
+ });
+
+ expect(summaries).toEqual([disconnected]);
+ expect(applyScriptProgressEvent(summaries, { type: "remove", id: build.id })).toEqual([]);
+ });
+});
diff --git a/packages/client-runtime/src/state/scriptProgress.ts b/packages/client-runtime/src/state/scriptProgress.ts
new file mode 100644
index 0000000000000000000000000000000000000000..16ec97f5fc08091a733a4ee3c4374c2f3eecf2a3
--- /dev/null
+++ b/packages/client-runtime/src/state/scriptProgress.ts
@@ -0,0 +1,54 @@
+import {
+ type ScriptProgressEvent,
+ type ScriptProgressStream,
+ WS_METHODS,
+} from "@t3tools/contracts";
+import * as Stream from "effect/Stream";
+import { Atom } from "effect/reactivity";
+
+import type { EnvironmentRegistry } from "../connection/registry.ts";
+import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts";
+
+export function applyScriptProgressEvent(
+ streams: ReadonlyArray,
+ event: ScriptProgressEvent,
+): ReadonlyArray {
+ switch (event.type) {
+ case "snapshot":
+ return event.streams;
+ case "upsert": {
+ const index = streams.findIndex((stream) => stream.id === event.stream.id);
+ return index < 0
+ ? [...streams, event.stream]
+ : streams.map((stream) => (stream.id === event.stream.id ? event.stream : stream));
+ }
+ case "progress":
+ return streams.map((stream) =>
+ stream.id === event.id ? { ...stream, summary: event.summary } : stream,
+ );
+ case "remove":
+ return streams.filter((stream) => stream.id !== event.id);
+ }
+}
+
+export function createScriptProgressEnvironmentAtoms(
+ runtime: Atom.AtomRuntime,
+) {
+ return {
+ watch: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
+ label: "environment-data:script-progress:watch",
+ tag: WS_METHODS.subscribeScriptProgress,
+ idleTtlMs: 0,
+ transform: (stream) =>
+ Stream.suspend(() => {
+ let streams: ReadonlyArray = [];
+ return stream.pipe(
+ Stream.mapArray((events) => {
+ for (const event of events) streams = applyScriptProgressEvent(streams, event);
+ return [{ events, streams }];
+ }),
+ );
+ }),
+ }),
+ };
+}
diff --git a/packages/clo-lib/Events.ts b/packages/clo-lib/Events.ts
new file mode 100644
index 0000000000000000000000000000000000000000..beb253cf331df8c641bdd73c9395984e09032ec4
--- /dev/null
+++ b/packages/clo-lib/Events.ts
@@ -0,0 +1,107 @@
+/**
+ * type-safe, browser compatible, mini event emitter.
+ * this module is intended to be imported via the main class.
+ *
+ * ```ts
+ * import { Events } from '@clo/lib/Events';
+ * const emitter = new Events<{ change: [string] }>();
+ * ```
+ *
+ * @module
+ */
+
+/**
+ * minimal event emitter supporting typed events. Unlike `EventTarget` and
+ * `node:events`, this supports an `onAny`, which is used by `lib/progress.ts`
+ *
+ * beware that the type parameter cannot be an `interface`.
+ */
+export class Events