authorclover caruso <git@paperclover.net> 2026-10-15 02:15:07-07:00
committerclover caruso <git@paperclover.net> 2026-10-16 16:52:04-07:00
log311da51db22ffc7e759634b785b8327b5d075fd8
tree65e0f14106b0b1bbf727828cf3467948872abfb6
parent6f7fee3541b9ef1fcedd996d055052a4be440bbb
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

feat: integrate `@clo/lib/progress`

Clover wants long-running scripts to show their progress in the chat instead of leaving it buried in command output. Capture existing @clo/lib/progress copies without requiring script changes or a library upgrade. Preload a synchronous Node module hook through thread-scoped command environments. Observe each loaded progress module and forward its stable event protocol to a bounded loopback collector. Preserve existing Node options, command output, exit behavior, worker isolation, and parent progress pipes. Codex, Claude, Pi, Muse, ACP commands, and T3 terminals use this path; the current Cursor and OpenCode APIs do not expose the necessary command environment. Copy the progress implementation and browser renderer from Clover's unpublished library until that release is available. Web and desktop render nested task trees above the composer, and mobile renders task summaries. Fresh subscriptions receive full trees after reconnects or switching chats. Disconnections retain the last values without claiming success. Keep progress out of durable chat state. Verify real v3/v4 scripts, preload ordering, handled and unhandled errors, concurrent producers, reconnects, bounded streams, provider environments, and client batching. Exercise the real client with a v4 script in Iris, light, dark, and a narrow viewport, including chat switching and reloads. Assisted-by: gpt-6.1-sol

94 files changed, 11204 insertions(+), 240 deletions(-)

apps/mobile/src/features/threads/ScriptProgress.tsx created+65
...@@ -0,0 +1,65 @@
1import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
2import { ScrollView, View } from "react-native";
3
4import { AppText as Text } from "../../components/AppText";
5import { scriptProgressEnvironment } from "../../state/scriptProgress";
6import { useEnvironmentQuery } from "../../state/query";
7
8export function ScriptProgress({
9 environmentId,
10 threadId,
11}: {
12 environmentId: EnvironmentId;
13 threadId: ThreadId;
14}) {
15 const { data, isSuccess } = useEnvironmentQuery(
16 scriptProgressEnvironment.watch({ environmentId, input: { threadId } }),
17 );
18 const streams = isSuccess
19 ? (data?.streams.filter((stream) => stream.summary.length > 0) ?? [])
20 : [];
21 if (streams.length === 0) return null;
22
23 return (
24 <View accessibilityLabel="Script progress" className="mb-2 rounded-2xl bg-card px-4 py-3">
25 <ScrollView style={{ maxHeight: 180 }}>
26 {streams.map((stream) => (
27 <View key={stream.id} className="gap-2">
28 {stream.status === "disconnected" ? (
29 <Text className="text-xs text-foreground-muted">{stream.label} · Disconnected</Text>
30 ) : null}
31 {stream.summary.map((node, index) => {
32 const fraction =
33 node.total > 0 ? Math.max(0, Math.min(1, node.value / node.total)) : 0;
34 return (
35 // Summary rows are ordered by their progress root; labels may repeat.
36 // oxlint-disable-next-line react/no-array-index-key
37 <View key={index} className="gap-1">
38 <View className="flex-row items-center gap-2">
39 <Text className="min-w-0 flex-1 text-sm text-foreground">{node.text}</Text>
40 <Text className="text-xs text-foreground-muted">{node.detail}</Text>
41 </View>
42 {node.total > 0 ? (
43 <View
44 accessibilityRole="progressbar"
45 accessibilityLabel={node.text}
46 accessibilityValue={{
47 min: 0,
48 max: node.total,
49 now: fraction * node.total,
50 text: node.detail,
51 }}
52 className="h-1 overflow-hidden rounded-full bg-border-subtle"
53 >
54 <View className="h-full bg-primary" style={{ width: `${fraction * 100}%` }} />
55 </View>
56 ) : null}
57 </View>
58 );
59 })}
60 </View>
61 ))}
62 </ScrollView>
63 </View>
64 );
65}
apps/mobile/src/features/threads/ThreadDetailScreen.tsx+5
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { useAtomValue } from "@effect/atom-react";1import { useAtomValue } from "@effect/atom-react";
2import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";2import { useChildThreadInputs, useThreadReportedModelSelection } from "../../state/entities";
3import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";3import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard";
4import { ScriptProgress } from "./ScriptProgress";
4import { useNavigation } from "@react-navigation/native";5import { useNavigation } from "@react-navigation/native";
5import type { WorktreeSetupCardProps } from "./worktree-setup-card";6import type { WorktreeSetupCardProps } from "./worktree-setup-card";
6import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";7import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types";
...@@ -1243,6 +1244,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread...@@ -1243,6 +1244,10 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
1243 list's bottom inset, so any padding above the pill/composer1244 list's bottom inset, so any padding above the pill/composer
1244 pushes the resting content floor up by the same amount. */}1245 pushes the resting content floor up by the same amount. */}
1245 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">1246 <View ref={composerOverlayRef} onLayout={onComposerLayout} className="w-full">
1247 <ScriptProgress
1248 environmentId={props.environmentId}
1249 threadId={props.selectedThread.id}
1250 />
1246 <FloatingWorkingControl1251 <FloatingWorkingControl
1247 colorScheme={isDarkMode ? "dark" : "light"}1252 colorScheme={isDarkMode ? "dark" : "light"}
1248 status={floatingStatus}1253 status={floatingStatus}
apps/mobile/src/state/scriptProgress.ts created+6
...@@ -0,0 +1,6 @@
1import { createScriptProgressEnvironmentAtoms } from "@t3tools/client-runtime/state/script-progress";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const scriptProgressEnvironment =
6 createScriptProgressEnvironmentAtoms(connectionAtomRuntime);
apps/server/package.json+1
...@@ -30,6 +30,7 @@...@@ -30,6 +30,7 @@
30 },30 },
31 "dependencies": {31 "dependencies": {
32 "@anthropic-ai/claude-agent-sdk": "^0.3.276",32 "@anthropic-ai/claude-agent-sdk": "^0.3.276",
33 "@clo/lib": "workspace:*",
33 "@cursor/sdk": "1.0.35",34 "@cursor/sdk": "1.0.35",
34 "@effect/platform-node": "catalog:",35 "@effect/platform-node": "catalog:",
35 "@effect/platform-node-shared": "catalog:",36 "@effect/platform-node-shared": "catalog:",
apps/server/src/auth/RpcAuthorization.test.ts+5
...@@ -35,6 +35,11 @@ describe("RPC authorization scopes", () => {...@@ -35,6 +35,11 @@ describe("RPC authorization scopes", () => {
35 it("requires filesystem read permission to watch file and folder changes", () => {35 it("requires filesystem read permission to watch file and folder changes", () => {
36 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);36 expect(requiredScopeForRpcMethod(WS_METHODS.projectsWatch)).toBe(AuthFilesystemReadScope);
37 });37 });
38 it("reads script progress under orchestration read access", () => {
39 expect(requiredScopeForRpcMethod(WS_METHODS.subscribeScriptProgress)).toBe(
40 AuthOrchestrationReadScope,
41 );
42 });
38 it("declares exactly one scope for every RPC in the server group", () => {43 it("declares exactly one scope for every RPC in the server group", () => {
39 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));44 expect(new Set(Object.keys(RPC_REQUIRED_SCOPES))).toEqual(new Set(WsRpcGroup.requests.keys()));
40 });45 });
apps/server/src/auth/RpcAuthorization.ts+1
...@@ -189,6 +189,7 @@ export const RPC_REQUIRED_SCOPES = {...@@ -189,6 +189,7 @@ export const RPC_REQUIRED_SCOPES = {
189 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,189 [WS_METHODS.terminalClose]: AuthTerminalOperateScope,
190 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope,190 [WS_METHODS.subscribeTerminalEvents]: AuthTerminalReadScope,
191 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope,191 [WS_METHODS.subscribeTerminalMetadata]: AuthTerminalReadScope,
192 [WS_METHODS.subscribeScriptProgress]: AuthOrchestrationReadScope,
192 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,193 [WS_METHODS.previewOpen]: AuthPreviewOperateScope,
193 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,194 [WS_METHODS.previewNavigate]: AuthPreviewOperateScope,
194 [WS_METHODS.previewResize]: AuthPreviewOperateScope,195 [WS_METHODS.previewResize]: AuthPreviewOperateScope,
apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts+4-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";3import { describe, expect, it } from "@effect/vitest";
3import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";4import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv";
...@@ -3911,7 +3912,9 @@ describe("orchestrator MCP toolkit", () => {...@@ -3911,7 +3912,9 @@ describe("orchestrator MCP toolkit", () => {
3911 Layer.provideMerge(layerOrchestration),3912 Layer.provideMerge(layerOrchestration),
3912 Layer.provide(3913 Layer.provide(
3913 CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe(3914 CodexOrchestratorReplayHarness.makeProviderAdapterRegistryLayer(transcript).pipe(
3914 Layer.provide(McpProviderSessions.layer),3915 Layer.provide(
3916 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
3917 ),
3915 ),3918 ),
3916 ),3919 ),
3917 Layer.provide(layerProviderRegistry),3920 Layer.provide(layerProviderRegistry),
apps/server/src/observability/RpcInstrumentation.ts+1
...@@ -184,6 +184,7 @@ const RPC_AGGREGATES = {...@@ -184,6 +184,7 @@ const RPC_AGGREGATES = {
184 [WS_METHODS.terminalClose]: "terminal",184 [WS_METHODS.terminalClose]: "terminal",
185 [WS_METHODS.subscribeTerminalEvents]: "terminal",185 [WS_METHODS.subscribeTerminalEvents]: "terminal",
186 [WS_METHODS.subscribeTerminalMetadata]: "terminal",186 [WS_METHODS.subscribeTerminalMetadata]: "terminal",
187 [WS_METHODS.subscribeScriptProgress]: "orchestration",
187 [WS_METHODS.previewOpen]: "preview",188 [WS_METHODS.previewOpen]: "preview",
188 [WS_METHODS.previewNavigate]: "preview",189 [WS_METHODS.previewNavigate]: "preview",
189 [WS_METHODS.previewResize]: "preview",190 [WS_METHODS.previewResize]: "preview",
apps/server/src/orchestration-v2/AcpRegistryOrchestratorV2.live.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";2import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";3import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -145,7 +146,7 @@ const layerLive = RuntimeLayer.layer.pipe(...@@ -145,7 +146,7 @@ const layerLive = RuntimeLayer.layer.pipe(
145 Layer.provide(layerBackgroundPolicy),146 Layer.provide(layerBackgroundPolicy),
146 Layer.provide(ProviderTurnStartServiceTestkit.layer),147 Layer.provide(ProviderTurnStartServiceTestkit.layer),
147 Layer.provide(layerPlatformTest),148 Layer.provide(layerPlatformTest),
148 Layer.provide(McpProviderSessions.layer),149 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
149);150);
150151
151const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* (152const waitForIdle = Effect.fn("AcpRegistryOrchestratorV2Live.waitForIdle")(function* (
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import {2import {
2 extractCopilotSubagentUpdate,3 extractCopilotSubagentUpdate,
3 makeCopilotSubagentRouting,4 makeCopilotSubagentRouting,
...@@ -108,6 +109,7 @@ const layerTest = Layer.mergeAll(...@@ -108,6 +109,7 @@ const layerTest = Layer.mergeAll(
108 NodeServices.layer,109 NodeServices.layer,
109 IdAllocator.layer,110 IdAllocator.layer,
110 McpProviderSessions.layer,111 McpProviderSessions.layer,
112 ScriptProgress.layerUnavailable,
111 layerHost,113 layerHost,
112);114);
113const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");115const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test");
apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
3import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";4import { ProviderInstanceId, ProviderSessionId, ThreadId } from "@t3tools/contracts";
...@@ -93,6 +94,7 @@ const layerTest = Layer.mergeAll(...@@ -93,6 +94,7 @@ const layerTest = Layer.mergeAll(
93 NodeServices.layer,94 NodeServices.layer,
94 IdAllocator.layer,95 IdAllocator.layer,
95 McpProviderSessions.layer,96 McpProviderSessions.layer,
97 ScriptProgress.layerUnavailable,
96 layerHost,98 layerHost,
97 layerRegistry,99 layerRegistry,
98 ServerSettings.layerTest(),100 ServerSettings.layerTest(),
apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";3import { assert, describe, it } from "@effect/vitest";
3import {4import {
...@@ -131,6 +132,7 @@ const layerSession = Layer.mergeAll(...@@ -131,6 +132,7 @@ const layerSession = Layer.mergeAll(
131 NodeServices.layer,132 NodeServices.layer,
132 IdAllocator.layer,133 IdAllocator.layer,
133 McpProviderSessions.layer,134 McpProviderSessions.layer,
135 ScriptProgress.layerUnavailable,
134 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),136 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
135);137);
136138
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts+761-119
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1// @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams2// @effect-diagnostics nodeBuiltinImport:off -- workflow fixtures use files and the fake Claude CLI uses Node streams
2import * as NodeEvents from "node:events";3import * as NodeEvents from "node:events";
3import * as NodeFS from "node:fs";4import * as NodeFS from "node:fs";
...@@ -1114,7 +1115,12 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {...@@ -1114,7 +1115,12 @@ describe("ClaudeAdapterV2 Auto-accept edits", () => {
1114 assert.equal((yield* Fiber.join(decision))?.behavior, "allow");1115 assert.equal((yield* Fiber.join(decision))?.behavior, "allow");
1115 }).pipe(1116 }).pipe(
1116 Effect.provide(1117 Effect.provide(
1117 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1118 Layer.mergeAll(
1119 IdAllocator.layer,
1120 McpProviderSessions.layer,
1121 ScriptProgress.layerUnavailable,
1122 NodeServices.layer,
1123 ),
1118 ),1124 ),
1119 ),1125 ),
1120 ),1126 ),
...@@ -1143,7 +1149,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => {...@@ -1143,7 +1149,7 @@ describe("ClaudeAdapterV2 approval cancellation", () => {
1143 const controller = new AbortController();1149 const controller = new AbortController();
1144 let removes = 0;1150 let removes = 0;
1145 const removeEventListener = controller.signal.removeEventListener.bind(controller.signal);1151 const removeEventListener = controller.signal.removeEventListener.bind(controller.signal);
1146 controller.signal.removeEventListener = (...args) => {1152 controller.signal.removeEventListener = (...args: Parameters<typeof removeEventListener>) => {
1147 removes += 1;1153 removes += 1;
1148 return removeEventListener(...args);1154 return removeEventListener(...args);
1149 };1155 };
...@@ -1261,7 +1267,12 @@ describe("ClaudeAdapterV2 executable path", () => {...@@ -1261,7 +1267,12 @@ describe("ClaudeAdapterV2 executable path", () => {
1261 ).pipe(1267 ).pipe(
1262 Effect.provideService(HostProcess.Environment, testCase.hostEnvironment),1268 Effect.provideService(HostProcess.Environment, testCase.hostEnvironment),
1263 Effect.provide(1269 Effect.provide(
1264 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1270 Layer.mergeAll(
1271 IdAllocator.layer,
1272 McpProviderSessions.layer,
1273 ScriptProgress.layerUnavailable,
1274 NodeServices.layer,
1275 ),
1265 ),1276 ),
1266 ),1277 ),
1267 );1278 );
...@@ -1281,7 +1292,12 @@ describe("ClaudeAdapterV2 executable path", () => {...@@ -1281,7 +1292,12 @@ describe("ClaudeAdapterV2 executable path", () => {
1281 }),1292 }),
1282 ).pipe(1293 ).pipe(
1283 Effect.provide(1294 Effect.provide(
1284 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1295 Layer.mergeAll(
1296 IdAllocator.layer,
1297 McpProviderSessions.layer,
1298 ScriptProgress.layerUnavailable,
1299 NodeServices.layer,
1300 ),
1285 ),1301 ),
1286 ),1302 ),
1287 );1303 );
...@@ -1505,7 +1521,12 @@ describe("ClaudeAdapterV2 resume compaction", () => {...@@ -1505,7 +1521,12 @@ describe("ClaudeAdapterV2 resume compaction", () => {
1505 );1521 );
1506 }).pipe(1522 }).pipe(
1507 Effect.provide(1523 Effect.provide(
1508 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1524 Layer.mergeAll(
1525 IdAllocator.layer,
1526 McpProviderSessions.layer,
1527 ScriptProgress.layerUnavailable,
1528 NodeServices.layer,
1529 ),
1509 ),1530 ),
1510 ),1531 ),
1511 ),1532 ),
...@@ -1655,7 +1676,12 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1655,7 +1676,12 @@ describe("ClaudeAdapterV2 attachments", () => {
1655 ]);1676 ]);
1656 }).pipe(1677 }).pipe(
1657 Effect.provide(1678 Effect.provide(
1658 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1679 Layer.mergeAll(
1680 IdAllocator.layer,
1681 McpProviderSessions.layer,
1682 ScriptProgress.layerUnavailable,
1683 NodeServices.layer,
1684 ),
1659 ),1685 ),
1660 ),1686 ),
1661 ),1687 ),
...@@ -1743,7 +1769,12 @@ describe("ClaudeAdapterV2 attachments", () => {...@@ -1743,7 +1769,12 @@ describe("ClaudeAdapterV2 attachments", () => {
1743 assert.equal(openCount, 0);1769 assert.equal(openCount, 0);
1744 }).pipe(1770 }).pipe(
1745 Effect.provide(1771 Effect.provide(
1746 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1772 Layer.mergeAll(
1773 IdAllocator.layer,
1774 McpProviderSessions.layer,
1775 ScriptProgress.layerUnavailable,
1776 NodeServices.layer,
1777 ),
1747 ),1778 ),
1748 ),1779 ),
1749 ),1780 ),
...@@ -1928,7 +1959,12 @@ describe("ClaudeAdapterV2 native fork", () => {...@@ -1928,7 +1959,12 @@ describe("ClaudeAdapterV2 native fork", () => {
1928 assert.equal(openedQueries[0]?.options.sessionId, undefined);1959 assert.equal(openedQueries[0]?.options.sessionId, undefined);
1929 }).pipe(1960 }).pipe(
1930 Effect.provide(1961 Effect.provide(
1931 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1962 Layer.mergeAll(
1963 IdAllocator.layer,
1964 McpProviderSessions.layer,
1965 ScriptProgress.layerUnavailable,
1966 NodeServices.layer,
1967 ),
1932 ),1968 ),
1933 ),1969 ),
1934 ),1970 ),
...@@ -2118,7 +2154,12 @@ describe("ClaudeAdapterV2 native session identity", () => {...@@ -2118,7 +2154,12 @@ describe("ClaudeAdapterV2 native session identity", () => {
2118 return openedQueries;2154 return openedQueries;
2119 }).pipe(2155 }).pipe(
2120 Effect.provide(2156 Effect.provide(
2121 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2157 Layer.mergeAll(
2158 IdAllocator.layer,
2159 McpProviderSessions.layer,
2160 ScriptProgress.layerUnavailable,
2161 NodeServices.layer,
2162 ),
2122 ),2163 ),
2123 ),2164 ),
2124 );2165 );
...@@ -2343,6 +2384,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2343,6 +2384,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2343 readonly environment?: NodeJS.ProcessEnv;2384 readonly environment?: NodeJS.ProcessEnv;
2344 readonly subagentLaunchToolUseId?: string;2385 readonly subagentLaunchToolUseId?: string;
2345 readonly nativeSessionId?: string;2386 readonly nativeSessionId?: string;
2387 readonly threadId?: string;
2346 readonly settings?: ClaudeSettings;2388 readonly settings?: ClaudeSettings;
2347 // A CLI process opened after the first streams from its own queue, so the2389 // A CLI process opened after the first streams from its own queue, so the
2348 // first one can exit (Queue.shutdown) and a later turn can start another.2390 // first one can exit (Queue.shutdown) and a later turn can start another.
...@@ -2470,7 +2512,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2470,7 +2512,7 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2470 assertComplete: Effect.void,2512 assertComplete: Effect.void,
2471 },2513 },
2472 });2514 });
2473 const threadId = ThreadId.make("thread-claude-wake");2515 const threadId = ThreadId.make(options?.threadId ?? "thread-claude-wake");
2474 const runtime = yield* adapter.openSession({2516 const runtime = yield* adapter.openSession({
2475 threadId,2517 threadId,
2476 providerSessionId: ProviderSessionId.make("provider-session-claude-wake"),2518 providerSessionId: ProviderSessionId.make("provider-session-claude-wake"),
...@@ -2540,6 +2582,64 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2540,6 +2582,64 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2540 });2582 });
2541 const makeWakeHarness = makeWakeHarnessWithOptions();2583 const makeWakeHarness = makeWakeHarnessWithOptions();
25422584
2585 it.effect("keeps progress environments isolated across concurrent Claude queries", () =>
2586 Effect.gen(function* () {
2587 const environment = { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" };
2588 const harnesses = yield* Effect.all(
2589 ["one", "two"].map((name) =>
2590 makeWakeHarnessWithOptions({
2591 threadId: `claude-progress-${name}`,
2592 nativeSessionId: `native-progress-${name}`,
2593 environment,
2594 }),
2595 ),
2596 { concurrency: "unbounded" },
2597 );
2598 const now = yield* DateTime.now;
2599 yield* Effect.all(
2600 harnesses.map((harness, index) =>
2601 harness.runtime.startTurn(
2602 makeClaudeTestTurnInput({
2603 threadId: harness.threadId,
2604 providerThread: harness.providerThread,
2605 now,
2606 attemptId: RunAttemptId.make(`progress-query-${index}`),
2607 text: "Run a script",
2608 attachments: [],
2609 }),
2610 ),
2611 ),
2612 { concurrency: "unbounded" },
2613 );
2614 const options = harnesses.map((harness) => harness.getOpenedOptions()?.env);
2615 assert.equal(options[0]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-one");
2616 assert.equal(options[1]?.T3_SCRIPT_PROGRESS_TOKEN, "claude-progress-two");
2617 for (const option of options) {
2618 assert.equal(
2619 option?.NODE_OPTIONS,
2620 "--import=file:///private/progress.mjs --trace-warnings",
2621 );
2622 assert.equal(option?.USER_SETTING, "keep");
2623 }
2624 assert.deepEqual(environment, { NODE_OPTIONS: "--trace-warnings", USER_SETTING: "keep" });
2625 assert.notStrictEqual(options[0], options[1]);
2626 }).pipe(
2627 Effect.scoped,
2628 Effect.provideService(ScriptProgress.ScriptProgress, {
2629 environmentForThread: (threadId) =>
2630 Effect.succeed({
2631 NODE_OPTIONS: "--import=file:///private/progress.mjs",
2632 T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress",
2633 T3_SCRIPT_PROGRESS_TOKEN: threadId,
2634 }),
2635 subscribe: () => Stream.empty,
2636 }),
2637 Effect.provide(
2638 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
2639 ),
2640 ),
2641 );
2642
2543 it.effect.each([2643 it.effect.each([
2544 { isError: false, title: "Check weather" },2644 { isError: false, title: "Check weather" },
2545 { isError: true, title: "Check weather" },2645 { isError: true, title: "Check weather" },
...@@ -2645,7 +2745,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2645,7 +2745,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2645 }).pipe(2745 }).pipe(
2646 Effect.scoped,2746 Effect.scoped,
2647 Effect.provide(2747 Effect.provide(
2648 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2748 Layer.mergeAll(
2749 IdAllocator.layer,
2750 McpProviderSessions.layer,
2751 ScriptProgress.layerUnavailable,
2752 NodeServices.layer,
2753 ),
2649 ),2754 ),
2650 ),2755 ),
2651 );2756 );
...@@ -2734,7 +2839,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2734,7 +2839,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2734 assert.isTrue(yield* harness.hasPendingBackgroundWork);2839 assert.isTrue(yield* harness.hasPendingBackgroundWork);
2735 }).pipe(2840 }).pipe(
2736 Effect.provide(2841 Effect.provide(
2737 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2842 Layer.mergeAll(
2843 IdAllocator.layer,
2844 McpProviderSessions.layer,
2845 ScriptProgress.layerUnavailable,
2846 NodeServices.layer,
2847 ),
2738 ),2848 ),
2739 ),2849 ),
2740 ),2850 ),
...@@ -2858,7 +2968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2858,7 +2968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2858 }).pipe(2968 }).pipe(
2859 Effect.scoped,2969 Effect.scoped,
2860 Effect.provide(2970 Effect.provide(
2861 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2971 Layer.mergeAll(
2972 IdAllocator.layer,
2973 McpProviderSessions.layer,
2974 ScriptProgress.layerUnavailable,
2975 NodeServices.layer,
2976 ),
2862 ),2977 ),
2863 ),2978 ),
2864 );2979 );
...@@ -2939,7 +3054,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -2939,7 +3054,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
2939 }).pipe(3054 }).pipe(
2940 Effect.scoped,3055 Effect.scoped,
2941 Effect.provide(3056 Effect.provide(
2942 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3057 Layer.mergeAll(
3058 IdAllocator.layer,
3059 McpProviderSessions.layer,
3060 ScriptProgress.layerUnavailable,
3061 NodeServices.layer,
3062 ),
2943 ),3063 ),
2944 ),3064 ),
2945 );3065 );
...@@ -3013,7 +3133,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3013,7 +3133,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3013 assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status);3133 assert.equal(node?.type === "node.updated" ? node.node.status : undefined, failed.status);
3014 }).pipe(3134 }).pipe(
3015 Effect.provide(3135 Effect.provide(
3016 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3136 Layer.mergeAll(
3137 IdAllocator.layer,
3138 McpProviderSessions.layer,
3139 ScriptProgress.layerUnavailable,
3140 NodeServices.layer,
3141 ),
3017 ),3142 ),
3018 ),3143 ),
3019 ),3144 ),
...@@ -3121,7 +3246,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3121,7 +3246,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3121 assert.lengthOf(harness.terminalEvents(), 1);3246 assert.lengthOf(harness.terminalEvents(), 1);
3122 }).pipe(3247 }).pipe(
3123 Effect.provide(3248 Effect.provide(
3124 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3249 Layer.mergeAll(
3250 IdAllocator.layer,
3251 McpProviderSessions.layer,
3252 ScriptProgress.layerUnavailable,
3253 NodeServices.layer,
3254 ),
3125 ),3255 ),
3126 ),3256 ),
3127 ),3257 ),
...@@ -3191,7 +3321,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3191,7 +3321,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3191 assert.notEqual(notices()[0]?.id, notices()[2]?.id);3321 assert.notEqual(notices()[0]?.id, notices()[2]?.id);
3192 }).pipe(3322 }).pipe(
3193 Effect.provide(3323 Effect.provide(
3194 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3324 Layer.mergeAll(
3325 IdAllocator.layer,
3326 McpProviderSessions.layer,
3327 ScriptProgress.layerUnavailable,
3328 NodeServices.layer,
3329 ),
3195 ),3330 ),
3196 ),3331 ),
3197 );3332 );
...@@ -3242,7 +3377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3242,7 +3377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3242 );3377 );
3243 }).pipe(3378 }).pipe(
3244 Effect.provide(3379 Effect.provide(
3245 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3380 Layer.mergeAll(
3381 IdAllocator.layer,
3382 McpProviderSessions.layer,
3383 ScriptProgress.layerUnavailable,
3384 NodeServices.layer,
3385 ),
3246 ),3386 ),
3247 ),3387 ),
3248 );3388 );
...@@ -3293,7 +3433,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3293,7 +3433,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3293 assert.notInclude(terminal.failure.message, "repeated API errors");3433 assert.notInclude(terminal.failure.message, "repeated API errors");
3294 }).pipe(3434 }).pipe(
3295 Effect.provide(3435 Effect.provide(
3296 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3436 Layer.mergeAll(
3437 IdAllocator.layer,
3438 McpProviderSessions.layer,
3439 ScriptProgress.layerUnavailable,
3440 NodeServices.layer,
3441 ),
3297 ),3442 ),
3298 ),3443 ),
3299 );3444 );
...@@ -3336,7 +3481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3336,7 +3481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3336 assert.equal(terminal.failure.code, "api_error_403");3481 assert.equal(terminal.failure.code, "api_error_403");
3337 }).pipe(3482 }).pipe(
3338 Effect.provide(3483 Effect.provide(
3339 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3484 Layer.mergeAll(
3485 IdAllocator.layer,
3486 McpProviderSessions.layer,
3487 ScriptProgress.layerUnavailable,
3488 NodeServices.layer,
3489 ),
3340 ),3490 ),
3341 ),3491 ),
3342 );3492 );
...@@ -3394,7 +3544,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3394,7 +3544,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3394 assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit");3544 assert.equal(terminal.failure.class, recovered ? "provider_error" : "usage_limit");
3395 }).pipe(3545 }).pipe(
3396 Effect.provide(3546 Effect.provide(
3397 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3547 Layer.mergeAll(
3548 IdAllocator.layer,
3549 McpProviderSessions.layer,
3550 ScriptProgress.layerUnavailable,
3551 NodeServices.layer,
3552 ),
3398 ),3553 ),
3399 ),3554 ),
3400 );3555 );
...@@ -3458,7 +3613,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3458,7 +3613,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3458 );3613 );
3459 }).pipe(3614 }).pipe(
3460 Effect.provide(3615 Effect.provide(
3461 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3616 Layer.mergeAll(
3617 IdAllocator.layer,
3618 McpProviderSessions.layer,
3619 ScriptProgress.layerUnavailable,
3620 NodeServices.layer,
3621 ),
3462 ),3622 ),
3463 ),3623 ),
3464 );3624 );
...@@ -3516,7 +3676,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3516,7 +3676,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3516 }3676 }
3517 }).pipe(3677 }).pipe(
3518 Effect.provide(3678 Effect.provide(
3519 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3679 Layer.mergeAll(
3680 IdAllocator.layer,
3681 McpProviderSessions.layer,
3682 ScriptProgress.layerUnavailable,
3683 NodeServices.layer,
3684 ),
3520 ),3685 ),
3521 ),3686 ),
3522 );3687 );
...@@ -3564,7 +3729,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3564,7 +3729,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3564 assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit");3729 assert.notInclude(terminal.failure.message.toLowerCase(), "usage limit");
3565 }).pipe(3730 }).pipe(
3566 Effect.provide(3731 Effect.provide(
3567 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3732 Layer.mergeAll(
3733 IdAllocator.layer,
3734 McpProviderSessions.layer,
3735 ScriptProgress.layerUnavailable,
3736 NodeServices.layer,
3737 ),
3568 ),3738 ),
3569 ),3739 ),
3570 );3740 );
...@@ -3632,7 +3802,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3632,7 +3802,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3632 assert.deepEqual(reportedContext, [2]);3802 assert.deepEqual(reportedContext, [2]);
3633 }).pipe(3803 }).pipe(
3634 Effect.provide(3804 Effect.provide(
3635 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3805 Layer.mergeAll(
3806 IdAllocator.layer,
3807 McpProviderSessions.layer,
3808 ScriptProgress.layerUnavailable,
3809 NodeServices.layer,
3810 ),
3636 ),3811 ),
3637 ),3812 ),
3638 );3813 );
...@@ -3696,7 +3871,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3696,7 +3871,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3696 }).pipe(3871 }).pipe(
3697 Effect.scoped,3872 Effect.scoped,
3698 Effect.provide(3873 Effect.provide(
3699 Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer),3874 Layer.mergeAll(
3875 NodeServices.layer,
3876 IdAllocator.layer,
3877 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
3878 ),
3700 ),3879 ),
3701 ),3880 ),
3702 );3881 );
...@@ -3773,7 +3952,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3773,7 +3952,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3773 }).pipe(3952 }).pipe(
3774 Effect.scoped,3953 Effect.scoped,
3775 Effect.provide(3954 Effect.provide(
3776 Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer),3955 Layer.mergeAll(
3956 NodeServices.layer,
3957 IdAllocator.layer,
3958 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
3959 ),
3777 ),3960 ),
3778 ),3961 ),
3779 );3962 );
...@@ -3876,7 +4059,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -3876,7 +4059,11 @@ describe("ClaudeAdapterV2 background wake turns", () => {
3876 }).pipe(4059 }).pipe(
3877 Effect.scoped,4060 Effect.scoped,
3878 Effect.provide(4061 Effect.provide(
3879 Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer),4062 Layer.mergeAll(
4063 NodeServices.layer,
4064 IdAllocator.layer,
4065 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
4066 ),
3880 ),4067 ),
3881 ),4068 ),
3882 );4069 );
...@@ -4089,7 +4276,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4089,7 +4276,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4089 assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]);4276 assert.deepEqual(harness.permissionModeChanges, ["bypassPermissions"]);
4090 }).pipe(4277 }).pipe(
4091 Effect.provide(4278 Effect.provide(
4092 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4279 Layer.mergeAll(
4280 IdAllocator.layer,
4281 McpProviderSessions.layer,
4282 ScriptProgress.layerUnavailable,
4283 NodeServices.layer,
4284 ),
4093 ),4285 ),
4094 ),4286 ),
4095 ),4287 ),
...@@ -4317,7 +4509,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4317,7 +4509,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4317 }).pipe(4509 }).pipe(
4318 Effect.scoped,4510 Effect.scoped,
4319 Effect.provide(4511 Effect.provide(
4320 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4512 Layer.mergeAll(
4513 IdAllocator.layer,
4514 McpProviderSessions.layer,
4515 ScriptProgress.layerUnavailable,
4516 NodeServices.layer,
4517 ),
4321 ),4518 ),
4322 ),4519 ),
4323 );4520 );
...@@ -4348,7 +4545,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4348,7 +4545,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4348 }).pipe(4545 }).pipe(
4349 Effect.scoped,4546 Effect.scoped,
4350 Effect.provide(4547 Effect.provide(
4351 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4548 Layer.mergeAll(
4549 IdAllocator.layer,
4550 McpProviderSessions.layer,
4551 ScriptProgress.layerUnavailable,
4552 NodeServices.layer,
4553 ),
4352 ),4554 ),
4353 ),4555 ),
4354 );4556 );
...@@ -4375,7 +4577,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4375,7 +4577,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4375 }).pipe(4577 }).pipe(
4376 Effect.scoped,4578 Effect.scoped,
4377 Effect.provide(4579 Effect.provide(
4378 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4580 Layer.mergeAll(
4581 IdAllocator.layer,
4582 McpProviderSessions.layer,
4583 ScriptProgress.layerUnavailable,
4584 NodeServices.layer,
4585 ),
4379 ),4586 ),
4380 ),4587 ),
4381 );4588 );
...@@ -4417,7 +4624,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4417,7 +4624,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4417 }).pipe(4624 }).pipe(
4418 Effect.scoped,4625 Effect.scoped,
4419 Effect.provide(4626 Effect.provide(
4420 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4627 Layer.mergeAll(
4628 IdAllocator.layer,
4629 McpProviderSessions.layer,
4630 ScriptProgress.layerUnavailable,
4631 NodeServices.layer,
4632 ),
4421 ),4633 ),
4422 ),4634 ),
4423 );4635 );
...@@ -4440,7 +4652,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4440,7 +4652,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4440 }).pipe(4652 }).pipe(
4441 Effect.scoped,4653 Effect.scoped,
4442 Effect.provide(4654 Effect.provide(
4443 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4655 Layer.mergeAll(
4656 IdAllocator.layer,
4657 McpProviderSessions.layer,
4658 ScriptProgress.layerUnavailable,
4659 NodeServices.layer,
4660 ),
4444 ),4661 ),
4445 ),4662 ),
4446 );4663 );
...@@ -4455,7 +4672,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4455,7 +4672,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4455 }).pipe(4672 }).pipe(
4456 Effect.scoped,4673 Effect.scoped,
4457 Effect.provide(4674 Effect.provide(
4458 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4675 Layer.mergeAll(
4676 IdAllocator.layer,
4677 McpProviderSessions.layer,
4678 ScriptProgress.layerUnavailable,
4679 NodeServices.layer,
4680 ),
4459 ),4681 ),
4460 ),4682 ),
4461 );4683 );
...@@ -4532,7 +4754,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4532,7 +4754,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4532 yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn");4754 yield* awaitUntil(() => harness.terminalEvents().length === 1, "recovered Claude turn");
4533 }).pipe(4755 }).pipe(
4534 Effect.provide(4756 Effect.provide(
4535 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4757 Layer.mergeAll(
4758 IdAllocator.layer,
4759 McpProviderSessions.layer,
4760 ScriptProgress.layerUnavailable,
4761 NodeServices.layer,
4762 ),
4536 ),4763 ),
4537 ),4764 ),
4538 ),4765 ),
...@@ -4590,7 +4817,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4590,7 +4817,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4590 assert.equal(terminal.failure.code, "api_error_529");4817 assert.equal(terminal.failure.code, "api_error_529");
4591 }).pipe(4818 }).pipe(
4592 Effect.provide(4819 Effect.provide(
4593 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4820 Layer.mergeAll(
4821 IdAllocator.layer,
4822 McpProviderSessions.layer,
4823 ScriptProgress.layerUnavailable,
4824 NodeServices.layer,
4825 ),
4594 ),4826 ),
4595 ),4827 ),
4596 ),4828 ),
...@@ -4656,7 +4888,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4656,7 +4888,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4656 );4888 );
4657 }).pipe(4889 }).pipe(
4658 Effect.provide(4890 Effect.provide(
4659 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4891 Layer.mergeAll(
4892 IdAllocator.layer,
4893 McpProviderSessions.layer,
4894 ScriptProgress.layerUnavailable,
4895 NodeServices.layer,
4896 ),
4660 ),4897 ),
4661 ),4898 ),
4662 ),4899 ),
...@@ -4725,7 +4962,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4725,7 +4962,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4725 assert.isFalse(yield* harness.hasPendingBackgroundWork);4962 assert.isFalse(yield* harness.hasPendingBackgroundWork);
4726 }).pipe(4963 }).pipe(
4727 Effect.provide(4964 Effect.provide(
4728 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4965 Layer.mergeAll(
4966 IdAllocator.layer,
4967 McpProviderSessions.layer,
4968 ScriptProgress.layerUnavailable,
4969 NodeServices.layer,
4970 ),
4729 ),4971 ),
4730 ),4972 ),
4731 ),4973 ),
...@@ -4864,7 +5106,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4864,7 +5106,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4864 ]);5106 ]);
4865 }).pipe(5107 }).pipe(
4866 Effect.provide(5108 Effect.provide(
4867 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5109 Layer.mergeAll(
5110 IdAllocator.layer,
5111 McpProviderSessions.layer,
5112 ScriptProgress.layerUnavailable,
5113 NodeServices.layer,
5114 ),
4868 ),5115 ),
4869 ),5116 ),
4870 ),5117 ),
...@@ -4918,7 +5165,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4918,7 +5165,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4918 assert.lengthOf(harness.continuationRequests, 0);5165 assert.lengthOf(harness.continuationRequests, 0);
4919 }).pipe(5166 }).pipe(
4920 Effect.provide(5167 Effect.provide(
4921 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5168 Layer.mergeAll(
5169 IdAllocator.layer,
5170 McpProviderSessions.layer,
5171 ScriptProgress.layerUnavailable,
5172 NodeServices.layer,
5173 ),
4922 ),5174 ),
4923 ),5175 ),
4924 ),5176 ),
...@@ -4967,7 +5219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -4967,7 +5219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
4967 assert.lengthOf(harness.continuationRequests, 0);5219 assert.lengthOf(harness.continuationRequests, 0);
4968 }).pipe(5220 }).pipe(
4969 Effect.provide(5221 Effect.provide(
4970 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5222 Layer.mergeAll(
5223 IdAllocator.layer,
5224 McpProviderSessions.layer,
5225 ScriptProgress.layerUnavailable,
5226 NodeServices.layer,
5227 ),
4971 ),5228 ),
4972 ),5229 ),
4973 ),5230 ),
...@@ -5155,7 +5412,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5155,7 +5412,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5155 yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake");5412 yield* awaitUntil(() => continuationRequests.length === 1, "replacement wake");
5156 }).pipe(5413 }).pipe(
5157 Effect.provide(5414 Effect.provide(
5158 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5415 Layer.mergeAll(
5416 IdAllocator.layer,
5417 McpProviderSessions.layer,
5418 ScriptProgress.layerUnavailable,
5419 NodeServices.layer,
5420 ),
5159 ),5421 ),
5160 ),5422 ),
5161 ),5423 ),
...@@ -5217,7 +5479,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5217,7 +5479,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5217 assert.isFalse(yield* harness.hasPendingBackgroundWork);5479 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5218 }).pipe(5480 }).pipe(
5219 Effect.provide(5481 Effect.provide(
5220 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5482 Layer.mergeAll(
5483 IdAllocator.layer,
5484 McpProviderSessions.layer,
5485 ScriptProgress.layerUnavailable,
5486 NodeServices.layer,
5487 ),
5221 ),5488 ),
5222 ),5489 ),
5223 ),5490 ),
...@@ -5485,7 +5752,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5485,7 +5752,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5485 assert.isFalse(yield* hasPendingBackgroundWork);5752 assert.isFalse(yield* hasPendingBackgroundWork);
5486 }).pipe(5753 }).pipe(
5487 Effect.provide(5754 Effect.provide(
5488 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5755 Layer.mergeAll(
5756 IdAllocator.layer,
5757 McpProviderSessions.layer,
5758 ScriptProgress.layerUnavailable,
5759 NodeServices.layer,
5760 ),
5489 ),5761 ),
5490 ),5762 ),
5491 ),5763 ),
...@@ -5709,7 +5981,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5709,7 +5981,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5709 );5981 );
5710 }).pipe(5982 }).pipe(
5711 Effect.provide(5983 Effect.provide(
5712 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5984 Layer.mergeAll(
5985 IdAllocator.layer,
5986 McpProviderSessions.layer,
5987 ScriptProgress.layerUnavailable,
5988 NodeServices.layer,
5989 ),
5713 ),5990 ),
5714 ),5991 ),
5715 ),5992 ),
...@@ -5796,7 +6073,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5796,7 +6073,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5796 );6073 );
5797 }).pipe(6074 }).pipe(
5798 Effect.provide(6075 Effect.provide(
5799 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6076 Layer.mergeAll(
6077 IdAllocator.layer,
6078 McpProviderSessions.layer,
6079 ScriptProgress.layerUnavailable,
6080 NodeServices.layer,
6081 ),
5800 ),6082 ),
5801 ),6083 ),
5802 ),6084 ),
...@@ -5929,7 +6211,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -5929,7 +6211,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
5929 assert.equal(result?.behavior, "allow");6211 assert.equal(result?.behavior, "allow");
5930 }).pipe(6212 }).pipe(
5931 Effect.provide(6213 Effect.provide(
5932 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6214 Layer.mergeAll(
6215 IdAllocator.layer,
6216 McpProviderSessions.layer,
6217 ScriptProgress.layerUnavailable,
6218 NodeServices.layer,
6219 ),
5933 ),6220 ),
5934 ),6221 ),
5935 ),6222 ),
...@@ -6029,7 +6316,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6029,7 +6316,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6029 );6316 );
6030 }).pipe(6317 }).pipe(
6031 Effect.provide(6318 Effect.provide(
6032 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6319 Layer.mergeAll(
6320 IdAllocator.layer,
6321 McpProviderSessions.layer,
6322 ScriptProgress.layerUnavailable,
6323 NodeServices.layer,
6324 ),
6033 ),6325 ),
6034 ),6326 ),
6035 ),6327 ),
...@@ -6111,7 +6403,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6111,7 +6403,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6111 assert.equal(harness.terminalEvents()[1]?.status, "failed");6403 assert.equal(harness.terminalEvents()[1]?.status, "failed");
6112 }).pipe(6404 }).pipe(
6113 Effect.provide(6405 Effect.provide(
6114 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6406 Layer.mergeAll(
6407 IdAllocator.layer,
6408 McpProviderSessions.layer,
6409 ScriptProgress.layerUnavailable,
6410 NodeServices.layer,
6411 ),
6115 ),6412 ),
6116 ),6413 ),
6117 ),6414 ),
...@@ -6141,7 +6438,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6141,7 +6438,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6141 assert.notEqual(original.promptUuid(0), copy.promptUuid(0));6438 assert.notEqual(original.promptUuid(0), copy.promptUuid(0));
6142 }).pipe(6439 }).pipe(
6143 Effect.provide(6440 Effect.provide(
6144 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6441 Layer.mergeAll(
6442 IdAllocator.layer,
6443 McpProviderSessions.layer,
6444 ScriptProgress.layerUnavailable,
6445 NodeServices.layer,
6446 ),
6145 ),6447 ),
6146 ),6448 ),
6147 ),6449 ),
...@@ -6182,7 +6484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6182,7 +6484,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6182 assert.include(terminal.failure.message, "never started a turn");6484 assert.include(terminal.failure.message, "never started a turn");
6183 }).pipe(6485 }).pipe(
6184 Effect.provide(6486 Effect.provide(
6185 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6487 Layer.mergeAll(
6488 IdAllocator.layer,
6489 McpProviderSessions.layer,
6490 ScriptProgress.layerUnavailable,
6491 NodeServices.layer,
6492 ),
6186 ),6493 ),
6187 ),6494 ),
6188 ),6495 ),
...@@ -6235,7 +6542,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6235,7 +6542,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6235 assert.equal(harness.terminalEvents()[0]?.status, "completed");6542 assert.equal(harness.terminalEvents()[0]?.status, "completed");
6236 }).pipe(6543 }).pipe(
6237 Effect.provide(6544 Effect.provide(
6238 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6545 Layer.mergeAll(
6546 IdAllocator.layer,
6547 McpProviderSessions.layer,
6548 ScriptProgress.layerUnavailable,
6549 NodeServices.layer,
6550 ),
6239 ),6551 ),
6240 ),6552 ),
6241 ),6553 ),
...@@ -6285,7 +6597,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6285,7 +6597,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6285 assert.isTrue(yield* harness.hasPendingBackgroundWork);6597 assert.isTrue(yield* harness.hasPendingBackgroundWork);
6286 }).pipe(6598 }).pipe(
6287 Effect.provide(6599 Effect.provide(
6288 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6600 Layer.mergeAll(
6601 IdAllocator.layer,
6602 McpProviderSessions.layer,
6603 ScriptProgress.layerUnavailable,
6604 NodeServices.layer,
6605 ),
6289 ),6606 ),
6290 ),6607 ),
6291 ),6608 ),
...@@ -6346,7 +6663,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6346,7 +6663,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6346 );6663 );
6347 }).pipe(6664 }).pipe(
6348 Effect.provide(6665 Effect.provide(
6349 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6666 Layer.mergeAll(
6667 IdAllocator.layer,
6668 McpProviderSessions.layer,
6669 ScriptProgress.layerUnavailable,
6670 NodeServices.layer,
6671 ),
6350 ),6672 ),
6351 ),6673 ),
6352 ),6674 ),
...@@ -6401,7 +6723,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6401,7 +6723,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6401 assert.lengthOf(harness.offeredMessages, 1);6723 assert.lengthOf(harness.offeredMessages, 1);
6402 }).pipe(6724 }).pipe(
6403 Effect.provide(6725 Effect.provide(
6404 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6726 Layer.mergeAll(
6727 IdAllocator.layer,
6728 McpProviderSessions.layer,
6729 ScriptProgress.layerUnavailable,
6730 NodeServices.layer,
6731 ),
6405 ),6732 ),
6406 ),6733 ),
6407 ),6734 ),
...@@ -6493,7 +6820,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6493,7 +6820,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6493 assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit");6820 assert.include(pause.turnItem.message, "This turn is paused until the 5-hour limit");
6494 }).pipe(6821 }).pipe(
6495 Effect.provide(6822 Effect.provide(
6496 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6823 Layer.mergeAll(
6824 IdAllocator.layer,
6825 McpProviderSessions.layer,
6826 ScriptProgress.layerUnavailable,
6827 NodeServices.layer,
6828 ),
6497 ),6829 ),
6498 ),6830 ),
6499 ),6831 ),
...@@ -6588,7 +6920,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6588,7 +6920,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6588 assert.isFalse(yield* harness.hasPendingBackgroundWork);6920 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6589 }).pipe(6921 }).pipe(
6590 Effect.provide(6922 Effect.provide(
6591 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6923 Layer.mergeAll(
6924 IdAllocator.layer,
6925 McpProviderSessions.layer,
6926 ScriptProgress.layerUnavailable,
6927 NodeServices.layer,
6928 ),
6592 ),6929 ),
6593 ),6930 ),
6594 ),6931 ),
...@@ -6654,7 +6991,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6654,7 +6991,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6654 assert.equal(harness.terminalEvents()[1]?.status, "completed");6991 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6655 }).pipe(6992 }).pipe(
6656 Effect.provide(6993 Effect.provide(
6657 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6994 Layer.mergeAll(
6995 IdAllocator.layer,
6996 McpProviderSessions.layer,
6997 ScriptProgress.layerUnavailable,
6998 NodeServices.layer,
6999 ),
6658 ),7000 ),
6659 ),7001 ),
6660 ),7002 ),
...@@ -6696,7 +7038,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6696,7 +7038,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6696 );7038 );
6697 }).pipe(7039 }).pipe(
6698 Effect.provide(7040 Effect.provide(
6699 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7041 Layer.mergeAll(
7042 IdAllocator.layer,
7043 McpProviderSessions.layer,
7044 ScriptProgress.layerUnavailable,
7045 NodeServices.layer,
7046 ),
6700 ),7047 ),
6701 ),7048 ),
6702 ),7049 ),
...@@ -6783,7 +7130,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6783,7 +7130,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6783 assert.isFalse(hasMessageText(staleResultText));7130 assert.isFalse(hasMessageText(staleResultText));
6784 }).pipe(7131 }).pipe(
6785 Effect.provide(7132 Effect.provide(
6786 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7133 Layer.mergeAll(
7134 IdAllocator.layer,
7135 McpProviderSessions.layer,
7136 ScriptProgress.layerUnavailable,
7137 NodeServices.layer,
7138 ),
6787 ),7139 ),
6788 ),7140 ),
6789 ),7141 ),
...@@ -6837,7 +7189,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6837,7 +7189,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6837 assert.equal(harness.terminalEvents()[1]?.status, "completed");7189 assert.equal(harness.terminalEvents()[1]?.status, "completed");
6838 }).pipe(7190 }).pipe(
6839 Effect.provide(7191 Effect.provide(
6840 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7192 Layer.mergeAll(
7193 IdAllocator.layer,
7194 McpProviderSessions.layer,
7195 ScriptProgress.layerUnavailable,
7196 NodeServices.layer,
7197 ),
6841 ),7198 ),
6842 ),7199 ),
6843 ),7200 ),
...@@ -6901,7 +7258,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6901,7 +7258,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6901 assert.lengthOf(harness.terminalEvents(), 1);7258 assert.lengthOf(harness.terminalEvents(), 1);
6902 }).pipe(7259 }).pipe(
6903 Effect.provide(7260 Effect.provide(
6904 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7261 Layer.mergeAll(
7262 IdAllocator.layer,
7263 McpProviderSessions.layer,
7264 ScriptProgress.layerUnavailable,
7265 NodeServices.layer,
7266 ),
6905 ),7267 ),
6906 ),7268 ),
6907 ),7269 ),
...@@ -6965,7 +7327,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -6965,7 +7327,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
6965 );7327 );
6966 }).pipe(7328 }).pipe(
6967 Effect.provide(7329 Effect.provide(
6968 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7330 Layer.mergeAll(
7331 IdAllocator.layer,
7332 McpProviderSessions.layer,
7333 ScriptProgress.layerUnavailable,
7334 NodeServices.layer,
7335 ),
6969 ),7336 ),
6970 ),7337 ),
6971 ),7338 ),
...@@ -7003,7 +7370,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7003,7 +7370,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7003 assert.equal(harness.terminalEvents()[0]?.status, "failed");7370 assert.equal(harness.terminalEvents()[0]?.status, "failed");
7004 }).pipe(7371 }).pipe(
7005 Effect.provide(7372 Effect.provide(
7006 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7373 Layer.mergeAll(
7374 IdAllocator.layer,
7375 McpProviderSessions.layer,
7376 ScriptProgress.layerUnavailable,
7377 NodeServices.layer,
7378 ),
7007 ),7379 ),
7008 ),7380 ),
7009 ),7381 ),
...@@ -7033,7 +7405,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7033,7 +7405,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7033 assert.lengthOf(harness.offeredMessages, 0);7405 assert.lengthOf(harness.offeredMessages, 0);
7034 }).pipe(7406 }).pipe(
7035 Effect.provide(7407 Effect.provide(
7036 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7408 Layer.mergeAll(
7409 IdAllocator.layer,
7410 McpProviderSessions.layer,
7411 ScriptProgress.layerUnavailable,
7412 NodeServices.layer,
7413 ),
7037 ),7414 ),
7038 ),7415 ),
7039 ),7416 ),
...@@ -7234,7 +7611,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7234,7 +7611,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7234 );7611 );
7235 }).pipe(7612 }).pipe(
7236 Effect.provide(7613 Effect.provide(
7237 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7614 Layer.mergeAll(
7615 IdAllocator.layer,
7616 McpProviderSessions.layer,
7617 ScriptProgress.layerUnavailable,
7618 NodeServices.layer,
7619 ),
7238 ),7620 ),
7239 ),7621 ),
7240 ),7622 ),
...@@ -7299,7 +7681,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7299,7 +7681,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7299 );7681 );
7300 }).pipe(7682 }).pipe(
7301 Effect.provide(7683 Effect.provide(
7302 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7684 Layer.mergeAll(
7685 IdAllocator.layer,
7686 McpProviderSessions.layer,
7687 ScriptProgress.layerUnavailable,
7688 NodeServices.layer,
7689 ),
7303 ),7690 ),
7304 ),7691 ),
7305 ),7692 ),
...@@ -7459,7 +7846,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7459,7 +7846,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7459 assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]);7846 assert.deepEqual([...new Set(userRunItems)], ["assistant_message"]);
7460 }).pipe(7847 }).pipe(
7461 Effect.provide(7848 Effect.provide(
7462 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7849 Layer.mergeAll(
7850 IdAllocator.layer,
7851 McpProviderSessions.layer,
7852 ScriptProgress.layerUnavailable,
7853 NodeServices.layer,
7854 ),
7463 ),7855 ),
7464 ),7856 ),
7465 ),7857 ),
...@@ -7569,7 +7961,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7569,7 +7961,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7569 );7961 );
7570 }).pipe(7962 }).pipe(
7571 Effect.provide(7963 Effect.provide(
7572 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7964 Layer.mergeAll(
7965 IdAllocator.layer,
7966 McpProviderSessions.layer,
7967 ScriptProgress.layerUnavailable,
7968 NodeServices.layer,
7969 ),
7573 ),7970 ),
7574 ),7971 ),
7575 ),7972 ),
...@@ -7734,7 +8131,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7734,7 +8131,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7734 );8131 );
7735 }).pipe(8132 }).pipe(
7736 Effect.provide(8133 Effect.provide(
7737 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8134 Layer.mergeAll(
8135 IdAllocator.layer,
8136 McpProviderSessions.layer,
8137 ScriptProgress.layerUnavailable,
8138 NodeServices.layer,
8139 ),
7738 ),8140 ),
7739 ),8141 ),
7740 ),8142 ),
...@@ -7926,7 +8328,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -7926,7 +8328,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
7926 );8328 );
7927 }).pipe(8329 }).pipe(
7928 Effect.provide(8330 Effect.provide(
7929 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8331 Layer.mergeAll(
8332 IdAllocator.layer,
8333 McpProviderSessions.layer,
8334 ScriptProgress.layerUnavailable,
8335 NodeServices.layer,
8336 ),
7930 ),8337 ),
7931 ),8338 ),
7932 ),8339 ),
...@@ -8060,7 +8467,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8060,7 +8467,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8060 assert.lengthOf(harness.continuationRequests, 0);8467 assert.lengthOf(harness.continuationRequests, 0);
8061 }).pipe(8468 }).pipe(
8062 Effect.provide(8469 Effect.provide(
8063 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8470 Layer.mergeAll(
8471 IdAllocator.layer,
8472 McpProviderSessions.layer,
8473 ScriptProgress.layerUnavailable,
8474 NodeServices.layer,
8475 ),
8064 ),8476 ),
8065 ),8477 ),
8066 ),8478 ),
...@@ -8116,7 +8528,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8116,7 +8528,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8116 assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]);8528 assert.deepEqual(bashStatuses(), ["command_execution:running", "command_execution:failed"]);
8117 }).pipe(8529 }).pipe(
8118 Effect.provide(8530 Effect.provide(
8119 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8531 Layer.mergeAll(
8532 IdAllocator.layer,
8533 McpProviderSessions.layer,
8534 ScriptProgress.layerUnavailable,
8535 NodeServices.layer,
8536 ),
8120 ),8537 ),
8121 ),8538 ),
8122 ),8539 ),
...@@ -8182,7 +8599,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8182,7 +8599,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8182 ]);8599 ]);
8183 }).pipe(8600 }).pipe(
8184 Effect.provide(8601 Effect.provide(
8185 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8602 Layer.mergeAll(
8603 IdAllocator.layer,
8604 McpProviderSessions.layer,
8605 ScriptProgress.layerUnavailable,
8606 NodeServices.layer,
8607 ),
8186 ),8608 ),
8187 ),8609 ),
8188 ),8610 ),
...@@ -8352,7 +8774,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8352,7 +8774,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8352 ]);8774 ]);
8353 }).pipe(8775 }).pipe(
8354 Effect.provide(8776 Effect.provide(
8355 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8777 Layer.mergeAll(
8778 IdAllocator.layer,
8779 McpProviderSessions.layer,
8780 ScriptProgress.layerUnavailable,
8781 NodeServices.layer,
8782 ),
8356 ),8783 ),
8357 ),8784 ),
8358 ),8785 ),
...@@ -8512,7 +8939,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8512,7 +8939,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8512 ]);8939 ]);
8513 }).pipe(8940 }).pipe(
8514 Effect.provide(8941 Effect.provide(
8515 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8942 Layer.mergeAll(
8943 IdAllocator.layer,
8944 McpProviderSessions.layer,
8945 ScriptProgress.layerUnavailable,
8946 NodeServices.layer,
8947 ),
8516 ),8948 ),
8517 ),8949 ),
8518 ),8950 ),
...@@ -8593,7 +9025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8593,7 +9025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8593 assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]);9025 assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]);
8594 }).pipe(9026 }).pipe(
8595 Effect.provide(9027 Effect.provide(
8596 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9028 Layer.mergeAll(
9029 IdAllocator.layer,
9030 McpProviderSessions.layer,
9031 ScriptProgress.layerUnavailable,
9032 NodeServices.layer,
9033 ),
8597 ),9034 ),
8598 ),9035 ),
8599 ),9036 ),
...@@ -8772,7 +9209,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8772,7 +9209,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8772 assert.lengthOf(harness.continuationRequests, 1);9209 assert.lengthOf(harness.continuationRequests, 1);
8773 }).pipe(9210 }).pipe(
8774 Effect.provide(9211 Effect.provide(
8775 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9212 Layer.mergeAll(
9213 IdAllocator.layer,
9214 McpProviderSessions.layer,
9215 ScriptProgress.layerUnavailable,
9216 NodeServices.layer,
9217 ),
8776 ),9218 ),
8777 ),9219 ),
8778 ),9220 ),
...@@ -8837,7 +9279,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8837,7 +9279,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8837 assert.deepEqual(retryStatuses, ["running"]);9279 assert.deepEqual(retryStatuses, ["running"]);
8838 }).pipe(9280 }).pipe(
8839 Effect.provide(9281 Effect.provide(
8840 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9282 Layer.mergeAll(
9283 IdAllocator.layer,
9284 McpProviderSessions.layer,
9285 ScriptProgress.layerUnavailable,
9286 NodeServices.layer,
9287 ),
8841 ),9288 ),
8842 ),9289 ),
8843 ),9290 ),
...@@ -8930,7 +9377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -8930,7 +9377,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
8930 assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]);9377 assert.deepEqual(routing.assistantTexts(harness.threadId), ["The auditor finished."]);
8931 }).pipe(9378 }).pipe(
8932 Effect.provide(9379 Effect.provide(
8933 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9380 Layer.mergeAll(
9381 IdAllocator.layer,
9382 McpProviderSessions.layer,
9383 ScriptProgress.layerUnavailable,
9384 NodeServices.layer,
9385 ),
8934 ),9386 ),
8935 ),9387 ),
8936 ),9388 ),
...@@ -9017,7 +9469,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9017,7 +9469,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9017 assert.equal(latest.subagent.completedAt === null, resume);9469 assert.equal(latest.subagent.completedAt === null, resume);
9018 }).pipe(9470 }).pipe(
9019 Effect.provide(9471 Effect.provide(
9020 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9472 Layer.mergeAll(
9473 IdAllocator.layer,
9474 McpProviderSessions.layer,
9475 ScriptProgress.layerUnavailable,
9476 NodeServices.layer,
9477 ),
9021 ),9478 ),
9022 ),9479 ),
9023 ),9480 ),
...@@ -9139,7 +9596,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9139,7 +9596,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9139 assert.isFalse(yield* fixture.hasPendingBackgroundWork);9596 assert.isFalse(yield* fixture.hasPendingBackgroundWork);
9140 }).pipe(9597 }).pipe(
9141 Effect.provide(9598 Effect.provide(
9142 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9599 Layer.mergeAll(
9600 IdAllocator.layer,
9601 McpProviderSessions.layer,
9602 ScriptProgress.layerUnavailable,
9603 NodeServices.layer,
9604 ),
9143 ),9605 ),
9144 ),9606 ),
9145 ),9607 ),
...@@ -9186,7 +9648,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9186,7 +9648,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9186 );9648 );
9187 }).pipe(9649 }).pipe(
9188 Effect.provide(9650 Effect.provide(
9189 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9651 Layer.mergeAll(
9652 IdAllocator.layer,
9653 McpProviderSessions.layer,
9654 ScriptProgress.layerUnavailable,
9655 NodeServices.layer,
9656 ),
9190 ),9657 ),
9191 ),9658 ),
9192 ),9659 ),
...@@ -9222,7 +9689,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9222,7 +9689,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9222 });9689 });
9223 }).pipe(9690 }).pipe(
9224 Effect.provide(9691 Effect.provide(
9225 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9692 Layer.mergeAll(
9693 IdAllocator.layer,
9694 McpProviderSessions.layer,
9695 ScriptProgress.layerUnavailable,
9696 NodeServices.layer,
9697 ),
9226 ),9698 ),
9227 ),9699 ),
9228 ),9700 ),
...@@ -9281,7 +9753,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9281,7 +9753,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9281 );9753 );
9282 }).pipe(9754 }).pipe(
9283 Effect.provide(9755 Effect.provide(
9284 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9756 Layer.mergeAll(
9757 IdAllocator.layer,
9758 McpProviderSessions.layer,
9759 ScriptProgress.layerUnavailable,
9760 NodeServices.layer,
9761 ),
9285 ),9762 ),
9286 ),9763 ),
9287 ),9764 ),
...@@ -9395,7 +9872,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9395,7 +9872,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9395 assert.equal(closes, 0);9872 assert.equal(closes, 0);
9396 }).pipe(9873 }).pipe(
9397 Effect.provide(9874 Effect.provide(
9398 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9875 Layer.mergeAll(
9876 IdAllocator.layer,
9877 McpProviderSessions.layer,
9878 ScriptProgress.layerUnavailable,
9879 NodeServices.layer,
9880 ),
9399 ),9881 ),
9400 ),9882 ),
9401 ),9883 ),
...@@ -9508,7 +9990,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9508,7 +9990,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9508 assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel);9990 assert.equal(child?.appThread.modelSelection?.model, initialModel ?? parentModel);
9509 }).pipe(9991 }).pipe(
9510 Effect.provide(9992 Effect.provide(
9511 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9993 Layer.mergeAll(
9994 IdAllocator.layer,
9995 McpProviderSessions.layer,
9996 ScriptProgress.layerUnavailable,
9997 NodeServices.layer,
9998 ),
9512 ),9999 ),
9513 ),10000 ),
9514 ),10001 ),
...@@ -9622,7 +10109,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9622,7 +10109,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9622 );10109 );
9623 }).pipe(10110 }).pipe(
9624 Effect.provide(10111 Effect.provide(
9625 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),10112 Layer.mergeAll(
10113 IdAllocator.layer,
10114 McpProviderSessions.layer,
10115 ScriptProgress.layerUnavailable,
10116 NodeServices.layer,
10117 ),
9626 ),10118 ),
9627 ),10119 ),
9628 ),10120 ),
...@@ -9726,7 +10218,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9726,7 +10218,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9726 });10218 });
9727 }).pipe(10219 }).pipe(
9728 Effect.provide(10220 Effect.provide(
9729 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),10221 Layer.mergeAll(
10222 IdAllocator.layer,
10223 McpProviderSessions.layer,
10224 ScriptProgress.layerUnavailable,
10225 NodeServices.layer,
10226 ),
9730 ),10227 ),
9731 ),10228 ),
9732 ),10229 ),
...@@ -9846,7 +10343,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9846,7 +10343,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9846 assert.deepEqual(childEfforts, ["low", "low", "max"]);10343 assert.deepEqual(childEfforts, ["low", "low", "max"]);
9847 }).pipe(10344 }).pipe(
9848 Effect.provide(10345 Effect.provide(
9849 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),10346 Layer.mergeAll(
10347 IdAllocator.layer,
10348 McpProviderSessions.layer,
10349 ScriptProgress.layerUnavailable,
10350 NodeServices.layer,
10351 ),
9850 ),10352 ),
9851 ),10353 ),
9852 ),10354 ),
...@@ -9935,7 +10437,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -9935,7 +10437,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
9935 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");10437 yield* awaitUntil(() => harness.terminalEvents().length === 1, "turn terminal");
9936 }).pipe(10438 }).pipe(
9937 Effect.provide(10439 Effect.provide(
9938 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),10440 Layer.mergeAll(
10441 IdAllocator.layer,
10442 McpProviderSessions.layer,
10443 ScriptProgress.layerUnavailable,
10444 NodeServices.layer,
10445 ),
9939 ),10446 ),
9940 ),10447 ),
9941 ),10448 ),
...@@ -10032,7 +10539,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10032,7 +10539,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10032 assert.isFalse(yield* harness.hasPendingBackgroundWork);10539 assert.isFalse(yield* harness.hasPendingBackgroundWork);
10033 }).pipe(10540 }).pipe(
10034 Effect.provide(10541 Effect.provide(
10035 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),10542 Layer.mergeAll(
10543 IdAllocator.layer,
10544 McpProviderSessions.layer,
10545 ScriptProgress.layerUnavailable,
10546 NodeServices.layer,
10547 ),
10036 ),10548 ),
10037 ),10549 ),
10038 ),10550 ),
...@@ -10322,7 +10834,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10322,7 +10834,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10322 assert.isFalse(yield* harness.hasPendingBackgroundWork);10834 assert.isFalse(yield* harness.hasPendingBackgroundWork);
10323 }).pipe(10835 }).pipe(
10324 Effect.provide(10836 Effect.provide(
10325 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),10837 Layer.mergeAll(
10838 IdAllocator.layer,
10839 McpProviderSessions.layer,
10840 ScriptProgress.layerUnavailable,
10841 NodeServices.layer,
10842 ),
10326 ),10843 ),
10327 ),10844 ),
10328 ),10845 ),
...@@ -10508,7 +11025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10508,7 +11025,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10508 );11025 );
10509 }).pipe(11026 }).pipe(
10510 Effect.provide(11027 Effect.provide(
10511 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),11028 Layer.mergeAll(
11029 IdAllocator.layer,
11030 McpProviderSessions.layer,
11031 ScriptProgress.layerUnavailable,
11032 NodeServices.layer,
11033 ),
10512 ),11034 ),
10513 ),11035 ),
10514 ),11036 ),
...@@ -10699,7 +11221,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10699,7 +11221,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10699 assert.isFalse(yield* hasPendingBackgroundWork);11221 assert.isFalse(yield* hasPendingBackgroundWork);
10700 }).pipe(11222 }).pipe(
10701 Effect.provide(11223 Effect.provide(
10702 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),11224 Layer.mergeAll(
11225 IdAllocator.layer,
11226 McpProviderSessions.layer,
11227 ScriptProgress.layerUnavailable,
11228 NodeServices.layer,
11229 ),
10703 ),11230 ),
10704 ),11231 ),
10705 ),11232 ),
...@@ -10954,7 +11481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -10954,7 +11481,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
10954 assert.isFalse(yield* hasPendingBackgroundWork);11481 assert.isFalse(yield* hasPendingBackgroundWork);
10955 }).pipe(11482 }).pipe(
10956 Effect.provide(11483 Effect.provide(
10957 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),11484 Layer.mergeAll(
11485 IdAllocator.layer,
11486 McpProviderSessions.layer,
11487 ScriptProgress.layerUnavailable,
11488 NodeServices.layer,
11489 ),
10958 ),11490 ),
10959 ),11491 ),
10960 ),11492 ),
...@@ -11123,7 +11655,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11123,7 +11655,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11123 assert.lengthOf(processQueues, 3);11655 assert.lengthOf(processQueues, 3);
11124 }).pipe(11656 }).pipe(
11125 Effect.provide(11657 Effect.provide(
11126 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),11658 Layer.mergeAll(
11659 IdAllocator.layer,
11660 McpProviderSessions.layer,
11661 ScriptProgress.layerUnavailable,
11662 NodeServices.layer,
11663 ),
11127 ),11664 ),
11128 ),11665 ),
11129 ),11666 ),
...@@ -11255,7 +11792,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11255,7 +11792,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11255 assert.isTrue(yield* hasPendingBackgroundWork);11792 assert.isTrue(yield* hasPendingBackgroundWork);
11256 }).pipe(11793 }).pipe(
11257 Effect.provide(11794 Effect.provide(
11258 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),11795 Layer.mergeAll(
11796 IdAllocator.layer,
11797 McpProviderSessions.layer,
11798 ScriptProgress.layerUnavailable,
11799 NodeServices.layer,
11800 ),
11259 ),11801 ),
11260 ),11802 ),
11261 ),11803 ),
...@@ -11459,7 +12001,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11459,7 +12001,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11459 assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully");12001 assert.equal(continuationRequests[1]?.detail, "Retry build completed successfully");
11460 }).pipe(12002 }).pipe(
11461 Effect.provide(12003 Effect.provide(
11462 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12004 Layer.mergeAll(
12005 IdAllocator.layer,
12006 McpProviderSessions.layer,
12007 ScriptProgress.layerUnavailable,
12008 NodeServices.layer,
12009 ),
11463 ),12010 ),
11464 ),12011 ),
11465 ),12012 ),
...@@ -11538,7 +12085,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11538,7 +12085,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11538 assert.lengthOf(providerThreadRosterEvents(events), 0);12085 assert.lengthOf(providerThreadRosterEvents(events), 0);
11539 }).pipe(12086 }).pipe(
11540 Effect.provide(12087 Effect.provide(
11541 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12088 Layer.mergeAll(
12089 IdAllocator.layer,
12090 McpProviderSessions.layer,
12091 ScriptProgress.layerUnavailable,
12092 NodeServices.layer,
12093 ),
11542 ),12094 ),
11543 ),12095 ),
11544 ),12096 ),
...@@ -11817,7 +12369,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11817,7 +12369,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11817 assert.isFalse(yield* harness.hasPendingBackgroundWork);12369 assert.isFalse(yield* harness.hasPendingBackgroundWork);
11818 }).pipe(12370 }).pipe(
11819 Effect.provide(12371 Effect.provide(
11820 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12372 Layer.mergeAll(
12373 IdAllocator.layer,
12374 McpProviderSessions.layer,
12375 ScriptProgress.layerUnavailable,
12376 NodeServices.layer,
12377 ),
11821 ),12378 ),
11822 ),12379 ),
11823 ),12380 ),
...@@ -11886,7 +12443,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11886,7 +12443,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11886 );12443 );
11887 }).pipe(12444 }).pipe(
11888 Effect.provide(12445 Effect.provide(
11889 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12446 Layer.mergeAll(
12447 IdAllocator.layer,
12448 McpProviderSessions.layer,
12449 ScriptProgress.layerUnavailable,
12450 NodeServices.layer,
12451 ),
11890 ),12452 ),
11891 ),12453 ),
11892 ),12454 ),
...@@ -11994,7 +12556,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -11994,7 +12556,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
11994 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);12556 assert.deepEqual(stoppedTasks, [WORKFLOW_TASK_ID]);
11995 }).pipe(12557 }).pipe(
11996 Effect.provide(12558 Effect.provide(
11997 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12559 Layer.mergeAll(
12560 IdAllocator.layer,
12561 McpProviderSessions.layer,
12562 ScriptProgress.layerUnavailable,
12563 NodeServices.layer,
12564 ),
11998 ),12565 ),
11999 ),12566 ),
12000 ),12567 ),
...@@ -12252,7 +12819,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -12252,7 +12819,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
12252 );12819 );
12253 }).pipe(12820 }).pipe(
12254 Effect.provide(12821 Effect.provide(
12255 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12822 Layer.mergeAll(
12823 IdAllocator.layer,
12824 McpProviderSessions.layer,
12825 ScriptProgress.layerUnavailable,
12826 NodeServices.layer,
12827 ),
12256 ),12828 ),
12257 ),12829 ),
12258 ),12830 ),
...@@ -12323,7 +12895,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -12323,7 +12895,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
12323 );12895 );
12324 }).pipe(12896 }).pipe(
12325 Effect.provide(12897 Effect.provide(
12326 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12898 Layer.mergeAll(
12899 IdAllocator.layer,
12900 McpProviderSessions.layer,
12901 ScriptProgress.layerUnavailable,
12902 NodeServices.layer,
12903 ),
12327 ),12904 ),
12328 ),12905 ),
12329 ),12906 ),
...@@ -12391,7 +12968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -12391,7 +12968,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
12391 assert.equal(coordinatorCount(), betweenTurnCount + 1);12968 assert.equal(coordinatorCount(), betweenTurnCount + 1);
12392 }).pipe(12969 }).pipe(
12393 Effect.provide(12970 Effect.provide(
12394 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),12971 Layer.mergeAll(
12972 IdAllocator.layer,
12973 McpProviderSessions.layer,
12974 ScriptProgress.layerUnavailable,
12975 NodeServices.layer,
12976 ),
12395 ),12977 ),
12396 ),12978 ),
12397 ),12979 ),
...@@ -12521,7 +13103,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -12521,7 +13103,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
12521 );13103 );
12522 }).pipe(13104 }).pipe(
12523 Effect.provide(13105 Effect.provide(
12524 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),13106 Layer.mergeAll(
13107 IdAllocator.layer,
13108 McpProviderSessions.layer,
13109 ScriptProgress.layerUnavailable,
13110 NodeServices.layer,
13111 ),
12525 ),13112 ),
12526 ),13113 ),
12527 ),13114 ),
...@@ -12691,7 +13278,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -12691,7 +13278,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
12691 );13278 );
12692 }).pipe(13279 }).pipe(
12693 Effect.provide(13280 Effect.provide(
12694 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),13281 Layer.mergeAll(
13282 IdAllocator.layer,
13283 McpProviderSessions.layer,
13284 ScriptProgress.layerUnavailable,
13285 NodeServices.layer,
13286 ),
12695 ),13287 ),
12696 ),13288 ),
12697 ),13289 ),
...@@ -12834,7 +13426,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -12834,7 +13426,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
12834 );13426 );
12835 }).pipe(13427 }).pipe(
12836 Effect.provide(13428 Effect.provide(
12837 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),13429 Layer.mergeAll(
13430 IdAllocator.layer,
13431 McpProviderSessions.layer,
13432 ScriptProgress.layerUnavailable,
13433 NodeServices.layer,
13434 ),
12838 ),13435 ),
12839 ),13436 ),
12840 ),13437 ),
...@@ -13068,7 +13665,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13068,7 +13665,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13068 );13665 );
13069 }).pipe(13666 }).pipe(
13070 Effect.provide(13667 Effect.provide(
13071 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),13668 Layer.mergeAll(
13669 IdAllocator.layer,
13670 McpProviderSessions.layer,
13671 ScriptProgress.layerUnavailable,
13672 NodeServices.layer,
13673 ),
13072 ),13674 ),
13073 ),13675 ),
13074 ),13676 ),
...@@ -13192,7 +13794,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13192,7 +13794,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13192 );13794 );
13193 }).pipe(13795 }).pipe(
13194 Effect.provide(13796 Effect.provide(
13195 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),13797 Layer.mergeAll(
13798 IdAllocator.layer,
13799 McpProviderSessions.layer,
13800 ScriptProgress.layerUnavailable,
13801 NodeServices.layer,
13802 ),
13196 ),13803 ),
13197 ),13804 ),
13198 ),13805 ),
...@@ -13303,7 +13910,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13303,7 +13910,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13303 }13910 }
13304 }).pipe(13911 }).pipe(
13305 Effect.provide(13912 Effect.provide(
13306 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),13913 Layer.mergeAll(
13914 IdAllocator.layer,
13915 McpProviderSessions.layer,
13916 ScriptProgress.layerUnavailable,
13917 NodeServices.layer,
13918 ),
13307 ),13919 ),
13308 ),13920 ),
13309 ),13921 ),
...@@ -13607,7 +14219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13607,7 +14219,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13607 }14219 }
13608 }).pipe(14220 }).pipe(
13609 Effect.provide(14221 Effect.provide(
13610 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),14222 Layer.mergeAll(
14223 IdAllocator.layer,
14224 McpProviderSessions.layer,
14225 ScriptProgress.layerUnavailable,
14226 NodeServices.layer,
14227 ),
13611 ),14228 ),
13612 ),14229 ),
13613 ),14230 ),
...@@ -13675,7 +14292,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13675,7 +14292,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13675 yield* Queue.take(harness.terminalReceipts);14292 yield* Queue.take(harness.terminalReceipts);
13676 }).pipe(14293 }).pipe(
13677 Effect.provide(14294 Effect.provide(
13678 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),14295 Layer.mergeAll(
14296 IdAllocator.layer,
14297 McpProviderSessions.layer,
14298 ScriptProgress.layerUnavailable,
14299 NodeServices.layer,
14300 ),
13679 ),14301 ),
13680 ),14302 ),
13681 ),14303 ),
...@@ -13904,7 +14526,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13904,7 +14526,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13904 assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]);14526 assert.deepEqual(currentAssistantAnswers(), ["No answer from this workflow attempt."]);
13905 }).pipe(14527 }).pipe(
13906 Effect.provide(14528 Effect.provide(
13907 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),14529 Layer.mergeAll(
14530 IdAllocator.layer,
14531 McpProviderSessions.layer,
14532 ScriptProgress.layerUnavailable,
14533 NodeServices.layer,
14534 ),
13908 ),14535 ),
13909 ),14536 ),
13910 ),14537 ),
...@@ -13990,7 +14617,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -13990,7 +14617,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
13990 }).pipe(14617 }).pipe(
13991 Effect.scoped,14618 Effect.scoped,
13992 Effect.provide(14619 Effect.provide(
13993 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),14620 Layer.mergeAll(
14621 IdAllocator.layer,
14622 McpProviderSessions.layer,
14623 ScriptProgress.layerUnavailable,
14624 NodeServices.layer,
14625 ),
13994 ),14626 ),
13995 ),14627 ),
13996 );14628 );
...@@ -14029,7 +14661,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -14029,7 +14661,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
14029 }).pipe(14661 }).pipe(
14030 Effect.scoped,14662 Effect.scoped,
14031 Effect.provide(14663 Effect.provide(
14032 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),14664 Layer.mergeAll(
14665 IdAllocator.layer,
14666 McpProviderSessions.layer,
14667 ScriptProgress.layerUnavailable,
14668 NodeServices.layer,
14669 ),
14033 ),14670 ),
14034 ),14671 ),
14035 );14672 );
...@@ -14068,7 +14705,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {...@@ -14068,7 +14705,12 @@ describe("ClaudeAdapterV2 background wake turns", () => {
14068 }).pipe(14705 }).pipe(
14069 Effect.scoped,14706 Effect.scoped,
14070 Effect.provide(14707 Effect.provide(
14071 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),14708 Layer.mergeAll(
14709 IdAllocator.layer,
14710 McpProviderSessions.layer,
14711 ScriptProgress.layerUnavailable,
14712 NodeServices.layer,
14713 ),
14072 ),14714 ),
14073 ),14715 ),
14074 );14716 );
apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts+8-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";2import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
2import {3import {
3 dynamicToolTitle,4 dynamicToolTitle,
...@@ -3464,6 +3465,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -3464,6 +3465,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
3464 adapterOptions: ClaudeAdapterV2Options,3465 adapterOptions: ClaudeAdapterV2Options,
3465) {3466) {
3466 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;3467 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
3468 const scriptProgress = yield* ScriptProgress.ScriptProgress;
3467 const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions;3469 const { attachmentsDir, fileSystem, path, crypto, idAllocator, queryRunner } = adapterOptions;
3468 // Subagent model names and effort levels, including the instance's custom models.3470 // Subagent model names and effort levels, including the instance's custom models.
3469 const subagentModelCatalog = scopeClaudeModelCatalog(3471 const subagentModelCatalog = scopeClaudeModelCatalog(
...@@ -8548,7 +8550,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (...@@ -8548,7 +8550,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
8548 cwd: turnInput.runtimePolicy.cwd,8550 cwd: turnInput.runtimePolicy.cwd,
8549 attachmentsDir,8551 attachmentsDir,
8550 settings: adapterOptions.settings,8552 settings: adapterOptions.settings,
8551 environment: adapterOptions.environment,8553 environment: ScriptProgress.withScriptProgressEnvironment(
8554 adapterOptions.environment,
8555 yield* scriptProgress.environmentForThread(turnInput.threadId),
8556 ),
8552 tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,8557 tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
8553 ...(mcpOverrides.allowedTools === undefined8558 ...(mcpOverrides.allowedTools === undefined
8554 ? {}8559 ? {}
...@@ -9515,6 +9520,7 @@ export type ClaudeAdapterV2DriverEnv =...@@ -9515,6 +9520,7 @@ export type ClaudeAdapterV2DriverEnv =
9515 | Crypto.Crypto9520 | Crypto.Crypto
9516 | FileSystem.FileSystem9521 | FileSystem.FileSystem
9517 | IdAllocator.IdAllocatorV29522 | IdAllocator.IdAllocatorV2
9523 | ScriptProgress.ScriptProgress
9518 | McpProviderSessions.McpProviderSessions9524 | McpProviderSessions.McpProviderSessions
9519 | Path.Path9525 | Path.Path
9520 | ServerConfig.ServerConfig;9526 | ServerConfig.ServerConfig;
...@@ -9608,6 +9614,7 @@ const layer: Layer.Layer<...@@ -9608,6 +9614,7 @@ const layer: Layer.Layer<
9608 | Crypto.Crypto9614 | Crypto.Crypto
9609 | FileSystem.FileSystem9615 | FileSystem.FileSystem
9610 | IdAllocator.IdAllocatorV29616 | IdAllocator.IdAllocatorV2
9617 | ScriptProgress.ScriptProgress
9611 | McpProviderSessions.McpProviderSessions9618 | McpProviderSessions.McpProviderSessions
9612 | Path.Path9619 | Path.Path
9613 | ServerConfig.ServerConfig9620 | ServerConfig.ServerConfig
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts+501-67
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeOS from "node:os";2import * as NodeOS from "node:os";
23
3import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget";4import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget";
...@@ -826,7 +827,12 @@ describe("CodexAdapterV2 process spawning", () => {...@@ -826,7 +827,12 @@ describe("CodexAdapterV2 process spawning", () => {
826 assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]);827 assert.deepEqual(spawnedCommands, [path.join(NodeOS.homedir(), "bin", "codex")]);
827 }).pipe(828 }).pipe(
828 Effect.provide(829 Effect.provide(
829 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),830 Layer.mergeAll(
831 IdAllocator.layer,
832 McpProviderSessions.layer,
833 ScriptProgress.layerUnavailable,
834 NodeServices.layer,
835 ),
830 ),836 ),
831 Effect.provideService(HostProcess.Platform, "linux"),837 Effect.provideService(HostProcess.Platform, "linux"),
832 ),838 ),
...@@ -1864,6 +1870,104 @@ describe("CodexAdapterV2 session initialize", () => {...@@ -1864,6 +1870,104 @@ describe("CodexAdapterV2 session initialize", () => {
1864 );1870 );
1865 }).pipe(1871 }).pipe(
1866 Effect.scoped,1872 Effect.scoped,
1873 Effect.provide(
1874 Layer.mergeAll(
1875 IdAllocator.layer,
1876 McpProviderSessions.layer,
1877 ScriptProgress.layerUnavailable,
1878 NodeServices.layer,
1879 ),
1880 ),
1881 ),
1882 );
1883
1884 it.effect("keeps progress credentials local to each Codex thread on a shared app-server", () =>
1885 Effect.gen(function* () {
1886 const progressEnvironment = (threadId: string) => ({
1887 NODE_OPTIONS: "--import=file:///private/t3-progress.mjs",
1888 T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress",
1889 T3_SCRIPT_PROGRESS_TOKEN: threadId,
1890 });
1891 const entries: Array<CodexReplay.CodexAppServerReplayEntry> = replayPreamble(
1892 "progress-first",
1893 ).slice(0, 3);
1894 for (const [index, nativeId] of ["progress-first", "progress-second"].entries()) {
1895 const readId = index * 2 + 2;
1896 entries.push(
1897 {
1898 type: "expect_outbound",
1899 label: "config/read",
1900 frame: {
1901 id: readId,
1902 method: "config/read",
1903 params: { cwd: "/workspace", includeLayers: false },
1904 },
1905 },
1906 {
1907 type: "emit_inbound",
1908 label: "config/read",
1909 frame: {
1910 id: readId,
1911 result: {
1912 origins: {},
1913 config: {
1914 shell_environment_policy: {
1915 set: { KEEP_ME: "yes", NODE_OPTIONS: "--trace-warnings" },
1916 include_only: ["PATH"],
1917 exclude: ["SECRET_*"],
1918 },
1919 },
1920 },
1921 },
1922 },
1923 {
1924 type: "expect_outbound",
1925 label: "thread/start",
1926 frame: {
1927 id: readId + 1,
1928 method: "thread/start",
1929 params: {
1930 config: {
1931 ...CodexAdapterV2.CODEX_THREAD_CONFIG,
1932 "shell_environment_policy.set": {
1933 KEEP_ME: "yes",
1934 ...progressEnvironment(nativeId),
1935 NODE_OPTIONS: "--import=file:///private/t3-progress.mjs --trace-warnings",
1936 },
1937 "shell_environment_policy.include_only": [
1938 "PATH",
1939 "NODE_OPTIONS",
1940 "T3_SCRIPT_PROGRESS_ENDPOINT",
1941 "T3_SCRIPT_PROGRESS_TOKEN",
1942 ],
1943 },
1944 },
1945 },
1946 },
1947 ...replayPreamble(nativeId)
1948 .slice(4, 5)
1949 .map((entry) => withReplayRequestId(entry, readId + 1)),
1950 );
1951 }
1952 const session = yield* openReplaySession(
1953 makeCodexReplayTranscript({ scenario: "thread-local-progress", entries }),
1954 );
1955 const first = yield* session.ensureThread("progress-first");
1956 const second = yield* session.ensureThread("progress-second");
1957 assert.equal(first.nativeThreadRef?.nativeId, "progress-first");
1958 assert.equal(second.nativeThreadRef?.nativeId, "progress-second");
1959 assert.equal(session.initializeRequests(), 1);
1960 }).pipe(
1961 Effect.scoped,
1962 Effect.provideService(ScriptProgress.ScriptProgress, {
1963 environmentForThread: (threadId) =>
1964 Effect.succeed({
1965 NODE_OPTIONS: "--import=file:///private/t3-progress.mjs",
1966 T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/progress",
1967 T3_SCRIPT_PROGRESS_TOKEN: threadId,
1968 }),
1969 subscribe: () => Stream.empty,
1970 }),
1867 Effect.provide(1971 Effect.provide(
1868 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),1972 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),
1869 ),1973 ),
...@@ -1896,7 +2000,12 @@ describe("CodexAdapterV2 session initialize", () => {...@@ -1896,7 +2000,12 @@ describe("CodexAdapterV2 session initialize", () => {
1896 }).pipe(2000 }).pipe(
1897 Effect.scoped,2001 Effect.scoped,
1898 Effect.provide(2002 Effect.provide(
1899 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2003 Layer.mergeAll(
2004 IdAllocator.layer,
2005 McpProviderSessions.layer,
2006 ScriptProgress.layerUnavailable,
2007 NodeServices.layer,
2008 ),
1900 ),2009 ),
1901 ),2010 ),
1902 );2011 );
...@@ -1944,7 +2053,12 @@ describe("CodexAdapterV2 session initialize", () => {...@@ -1944,7 +2053,12 @@ describe("CodexAdapterV2 session initialize", () => {
1944 }).pipe(2053 }).pipe(
1945 Effect.scoped,2054 Effect.scoped,
1946 Effect.provide(2055 Effect.provide(
1947 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2056 Layer.mergeAll(
2057 IdAllocator.layer,
2058 McpProviderSessions.layer,
2059 ScriptProgress.layerUnavailable,
2060 NodeServices.layer,
2061 ),
1948 ),2062 ),
1949 ),2063 ),
1950 );2064 );
...@@ -2190,7 +2304,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2190,7 +2304,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2190 }).pipe(2304 }).pipe(
2191 Effect.scoped,2305 Effect.scoped,
2192 Effect.provide(2306 Effect.provide(
2193 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2307 Layer.mergeAll(
2308 IdAllocator.layer,
2309 McpProviderSessions.layer,
2310 ScriptProgress.layerUnavailable,
2311 NodeServices.layer,
2312 ),
2194 ),2313 ),
2195 ),2314 ),
2196 );2315 );
...@@ -2231,7 +2350,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2231,7 +2350,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2231 }).pipe(2350 }).pipe(
2232 Effect.scoped,2351 Effect.scoped,
2233 Effect.provide(2352 Effect.provide(
2234 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2353 Layer.mergeAll(
2354 IdAllocator.layer,
2355 McpProviderSessions.layer,
2356 ScriptProgress.layerUnavailable,
2357 NodeServices.layer,
2358 ),
2235 ),2359 ),
2236 ),2360 ),
2237 );2361 );
...@@ -2274,7 +2398,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2274,7 +2398,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2274 }).pipe(2398 }).pipe(
2275 Effect.scoped,2399 Effect.scoped,
2276 Effect.provide(2400 Effect.provide(
2277 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2401 Layer.mergeAll(
2402 IdAllocator.layer,
2403 McpProviderSessions.layer,
2404 ScriptProgress.layerUnavailable,
2405 NodeServices.layer,
2406 ),
2278 ),2407 ),
2279 ),2408 ),
2280 );2409 );
...@@ -2313,7 +2442,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2313,7 +2442,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2313 }).pipe(2442 }).pipe(
2314 Effect.scoped,2443 Effect.scoped,
2315 Effect.provide(2444 Effect.provide(
2316 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2445 Layer.mergeAll(
2446 IdAllocator.layer,
2447 McpProviderSessions.layer,
2448 ScriptProgress.layerUnavailable,
2449 NodeServices.layer,
2450 ),
2317 ),2451 ),
2318 ),2452 ),
2319 );2453 );
...@@ -2412,7 +2546,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2412,7 +2546,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2412 }).pipe(2546 }).pipe(
2413 Effect.scoped,2547 Effect.scoped,
2414 Effect.provide(2548 Effect.provide(
2415 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2549 Layer.mergeAll(
2550 IdAllocator.layer,
2551 McpProviderSessions.layer,
2552 ScriptProgress.layerUnavailable,
2553 NodeServices.layer,
2554 ),
2416 ),2555 ),
2417 ),2556 ),
2418 );2557 );
...@@ -2499,7 +2638,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2499,7 +2638,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2499 }).pipe(2638 }).pipe(
2500 Effect.scoped,2639 Effect.scoped,
2501 Effect.provide(2640 Effect.provide(
2502 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2641 Layer.mergeAll(
2642 IdAllocator.layer,
2643 McpProviderSessions.layer,
2644 ScriptProgress.layerUnavailable,
2645 NodeServices.layer,
2646 ),
2503 ),2647 ),
2504 ),2648 ),
2505 );2649 );
...@@ -2564,7 +2708,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2564,7 +2708,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2564 }).pipe(2708 }).pipe(
2565 Effect.scoped,2709 Effect.scoped,
2566 Effect.provide(2710 Effect.provide(
2567 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2711 Layer.mergeAll(
2712 IdAllocator.layer,
2713 McpProviderSessions.layer,
2714 ScriptProgress.layerUnavailable,
2715 NodeServices.layer,
2716 ),
2568 ),2717 ),
2569 ),2718 ),
2570 );2719 );
...@@ -2628,7 +2777,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2628,7 +2777,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2628 }).pipe(2777 }).pipe(
2629 Effect.scoped,2778 Effect.scoped,
2630 Effect.provide(2779 Effect.provide(
2631 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2780 Layer.mergeAll(
2781 IdAllocator.layer,
2782 McpProviderSessions.layer,
2783 ScriptProgress.layerUnavailable,
2784 NodeServices.layer,
2785 ),
2632 ),2786 ),
2633 ),2787 ),
2634 );2788 );
...@@ -2782,7 +2936,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2782,7 +2936,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2782 assert.isEmpty(assistantMessages(harness.events));2936 assert.isEmpty(assistantMessages(harness.events));
2783 }).pipe(2937 }).pipe(
2784 Effect.provide(2938 Effect.provide(
2785 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),2939 Layer.mergeAll(
2940 IdAllocator.layer,
2941 McpProviderSessions.layer,
2942 ScriptProgress.layerUnavailable,
2943 NodeServices.layer,
2944 ),
2786 ),2945 ),
2787 ),2946 ),
2788 ),2947 ),
...@@ -2992,7 +3151,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -2992,7 +3151,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
2992 assert.lengthOf(requests(), 4);3151 assert.lengthOf(requests(), 4);
2993 }).pipe(3152 }).pipe(
2994 Effect.provide(3153 Effect.provide(
2995 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3154 Layer.mergeAll(
3155 IdAllocator.layer,
3156 McpProviderSessions.layer,
3157 ScriptProgress.layerUnavailable,
3158 NodeServices.layer,
3159 ),
2996 ),3160 ),
2997 ),3161 ),
2998 ),3162 ),
...@@ -3090,7 +3254,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3090,7 +3254,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3090 assert.equal(harness.terminalEvents()[0]?.status, "completed");3254 assert.equal(harness.terminalEvents()[0]?.status, "completed");
3091 }).pipe(3255 }).pipe(
3092 Effect.provide(3256 Effect.provide(
3093 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3257 Layer.mergeAll(
3258 IdAllocator.layer,
3259 McpProviderSessions.layer,
3260 ScriptProgress.layerUnavailable,
3261 NodeServices.layer,
3262 ),
3094 ),3263 ),
3095 ),3264 ),
3096 ),3265 ),
...@@ -3170,7 +3339,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3170,7 +3339,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3170 assert.equal(harness.terminalEvents()[0]?.status, "completed");3339 assert.equal(harness.terminalEvents()[0]?.status, "completed");
3171 }).pipe(3340 }).pipe(
3172 Effect.provide(3341 Effect.provide(
3173 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3342 Layer.mergeAll(
3343 IdAllocator.layer,
3344 McpProviderSessions.layer,
3345 ScriptProgress.layerUnavailable,
3346 NodeServices.layer,
3347 ),
3174 ),3348 ),
3175 ),3349 ),
3176 ),3350 ),
...@@ -3222,7 +3396,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3222,7 +3396,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3222 assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000);3396 assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000);
3223 }).pipe(3397 }).pipe(
3224 Effect.provide(3398 Effect.provide(
3225 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3399 Layer.mergeAll(
3400 IdAllocator.layer,
3401 McpProviderSessions.layer,
3402 ScriptProgress.layerUnavailable,
3403 NodeServices.layer,
3404 ),
3226 ),3405 ),
3227 ),3406 ),
3228 ),3407 ),
...@@ -3381,7 +3560,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3381,7 +3560,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3381 assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000);3560 assert.equal(DateTime.toEpochMillis(resumed.updatedAt), 1782622450000);
3382 }).pipe(3561 }).pipe(
3383 Effect.provide(3562 Effect.provide(
3384 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3563 Layer.mergeAll(
3564 IdAllocator.layer,
3565 McpProviderSessions.layer,
3566 ScriptProgress.layerUnavailable,
3567 NodeServices.layer,
3568 ),
3385 ),3569 ),
3386 ),3570 ),
3387 ),3571 ),
...@@ -3467,7 +3651,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3467,7 +3651,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3467 assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId);3651 assert.equal(resumed.nativeThreadRef?.nativeId, nativeThreadId);
3468 }).pipe(3652 }).pipe(
3469 Effect.provide(3653 Effect.provide(
3470 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3654 Layer.mergeAll(
3655 IdAllocator.layer,
3656 McpProviderSessions.layer,
3657 ScriptProgress.layerUnavailable,
3658 NodeServices.layer,
3659 ),
3471 ),3660 ),
3472 ),3661 ),
3473 ),3662 ),
...@@ -3619,7 +3808,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3619,7 +3808,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3619 assert.isAbove(terminalIndex, resumedCommandIndex);3808 assert.isAbove(terminalIndex, resumedCommandIndex);
3620 }).pipe(3809 }).pipe(
3621 Effect.provide(3810 Effect.provide(
3622 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3811 Layer.mergeAll(
3812 IdAllocator.layer,
3813 McpProviderSessions.layer,
3814 ScriptProgress.layerUnavailable,
3815 NodeServices.layer,
3816 ),
3623 ),3817 ),
3624 ),3818 ),
3625 ),3819 ),
...@@ -3722,7 +3916,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3722,7 +3916,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3722 );3916 );
3723 }).pipe(3917 }).pipe(
3724 Effect.provide(3918 Effect.provide(
3725 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),3919 Layer.mergeAll(
3920 IdAllocator.layer,
3921 McpProviderSessions.layer,
3922 ScriptProgress.layerUnavailable,
3923 NodeServices.layer,
3924 ),
3726 ),3925 ),
3727 ),3926 ),
3728 ),3927 ),
...@@ -3890,7 +4089,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3890,7 +4089,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3890 assert.deepEqual(assistantMessages(harness.events), []);4089 assert.deepEqual(assistantMessages(harness.events), []);
3891 }).pipe(4090 }).pipe(
3892 Effect.provide(4091 Effect.provide(
3893 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4092 Layer.mergeAll(
4093 IdAllocator.layer,
4094 McpProviderSessions.layer,
4095 ScriptProgress.layerUnavailable,
4096 NodeServices.layer,
4097 ),
3894 ),4098 ),
3895 ),4099 ),
3896 ),4100 ),
...@@ -3978,7 +4182,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -3978,7 +4182,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
3978 );4182 );
3979 }).pipe(4183 }).pipe(
3980 Effect.provide(4184 Effect.provide(
3981 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4185 Layer.mergeAll(
4186 IdAllocator.layer,
4187 McpProviderSessions.layer,
4188 ScriptProgress.layerUnavailable,
4189 NodeServices.layer,
4190 ),
3982 ),4191 ),
3983 ),4192 ),
3984 ),4193 ),
...@@ -4111,7 +4320,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4111,7 +4320,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4111 );4320 );
4112 }).pipe(4321 }).pipe(
4113 Effect.provide(4322 Effect.provide(
4114 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4323 Layer.mergeAll(
4324 IdAllocator.layer,
4325 McpProviderSessions.layer,
4326 ScriptProgress.layerUnavailable,
4327 NodeServices.layer,
4328 ),
4115 ),4329 ),
4116 ),4330 ),
4117 ),4331 ),
...@@ -4158,7 +4372,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4158,7 +4372,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4158 );4372 );
4159 }).pipe(4373 }).pipe(
4160 Effect.provide(4374 Effect.provide(
4161 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4375 Layer.mergeAll(
4376 IdAllocator.layer,
4377 McpProviderSessions.layer,
4378 ScriptProgress.layerUnavailable,
4379 NodeServices.layer,
4380 ),
4162 ),4381 ),
4163 ),4382 ),
4164 ),4383 ),
...@@ -4206,7 +4425,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4206,7 +4425,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4206 );4425 );
4207 }).pipe(4426 }).pipe(
4208 Effect.provide(4427 Effect.provide(
4209 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4428 Layer.mergeAll(
4429 IdAllocator.layer,
4430 McpProviderSessions.layer,
4431 ScriptProgress.layerUnavailable,
4432 NodeServices.layer,
4433 ),
4210 ),4434 ),
4211 ),4435 ),
4212 ),4436 ),
...@@ -4329,7 +4553,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4329,7 +4553,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4329 );4553 );
4330 }).pipe(4554 }).pipe(
4331 Effect.provide(4555 Effect.provide(
4332 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4556 Layer.mergeAll(
4557 IdAllocator.layer,
4558 McpProviderSessions.layer,
4559 ScriptProgress.layerUnavailable,
4560 NodeServices.layer,
4561 ),
4333 ),4562 ),
4334 ),4563 ),
4335 ),4564 ),
...@@ -4361,7 +4590,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4361,7 +4590,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4361 );4590 );
4362 }).pipe(4591 }).pipe(
4363 Effect.provide(4592 Effect.provide(
4364 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4593 Layer.mergeAll(
4594 IdAllocator.layer,
4595 McpProviderSessions.layer,
4596 ScriptProgress.layerUnavailable,
4597 NodeServices.layer,
4598 ),
4365 ),4599 ),
4366 ),4600 ),
4367 ),4601 ),
...@@ -4394,7 +4628,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4394,7 +4628,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4394 );4628 );
4395 }).pipe(4629 }).pipe(
4396 Effect.provide(4630 Effect.provide(
4397 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4631 Layer.mergeAll(
4632 IdAllocator.layer,
4633 McpProviderSessions.layer,
4634 ScriptProgress.layerUnavailable,
4635 NodeServices.layer,
4636 ),
4398 ),4637 ),
4399 ),4638 ),
4400 ),4639 ),
...@@ -4427,7 +4666,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4427,7 +4666,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4427 );4666 );
4428 }).pipe(4667 }).pipe(
4429 Effect.provide(4668 Effect.provide(
4430 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4669 Layer.mergeAll(
4670 IdAllocator.layer,
4671 McpProviderSessions.layer,
4672 ScriptProgress.layerUnavailable,
4673 NodeServices.layer,
4674 ),
4431 ),4675 ),
4432 ),4676 ),
4433 ),4677 ),
...@@ -4460,7 +4704,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4460,7 +4704,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4460 );4704 );
4461 }).pipe(4705 }).pipe(
4462 Effect.provide(4706 Effect.provide(
4463 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4707 Layer.mergeAll(
4708 IdAllocator.layer,
4709 McpProviderSessions.layer,
4710 ScriptProgress.layerUnavailable,
4711 NodeServices.layer,
4712 ),
4464 ),4713 ),
4465 ),4714 ),
4466 ),4715 ),
...@@ -4493,7 +4742,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4493,7 +4742,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4493 );4742 );
4494 }).pipe(4743 }).pipe(
4495 Effect.provide(4744 Effect.provide(
4496 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4745 Layer.mergeAll(
4746 IdAllocator.layer,
4747 McpProviderSessions.layer,
4748 ScriptProgress.layerUnavailable,
4749 NodeServices.layer,
4750 ),
4497 ),4751 ),
4498 ),4752 ),
4499 ),4753 ),
...@@ -4526,7 +4780,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4526,7 +4780,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4526 );4780 );
4527 }).pipe(4781 }).pipe(
4528 Effect.provide(4782 Effect.provide(
4529 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4783 Layer.mergeAll(
4784 IdAllocator.layer,
4785 McpProviderSessions.layer,
4786 ScriptProgress.layerUnavailable,
4787 NodeServices.layer,
4788 ),
4530 ),4789 ),
4531 ),4790 ),
4532 ),4791 ),
...@@ -4686,7 +4945,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4686,7 +4945,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4686 assert.isFalse(yield* harness.hasPendingBackgroundWork);4945 assert.isFalse(yield* harness.hasPendingBackgroundWork);
4687 }).pipe(4946 }).pipe(
4688 Effect.provide(4947 Effect.provide(
4689 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),4948 Layer.mergeAll(
4949 IdAllocator.layer,
4950 McpProviderSessions.layer,
4951 ScriptProgress.layerUnavailable,
4952 NodeServices.layer,
4953 ),
4690 ),4954 ),
4691 ),4955 ),
4692 ),4956 ),
...@@ -4845,7 +5109,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -4845,7 +5109,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
4845 assert.lengthOf(harness.continuationRequests, 0);5109 assert.lengthOf(harness.continuationRequests, 0);
4846 }).pipe(5110 }).pipe(
4847 Effect.provide(5111 Effect.provide(
4848 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5112 Layer.mergeAll(
5113 IdAllocator.layer,
5114 McpProviderSessions.layer,
5115 ScriptProgress.layerUnavailable,
5116 NodeServices.layer,
5117 ),
4849 ),5118 ),
4850 ),5119 ),
4851 );5120 );
...@@ -5209,7 +5478,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5209,7 +5478,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5209 assert.lengthOf(harness.terminalEvents(), 1);5478 assert.lengthOf(harness.terminalEvents(), 1);
5210 }).pipe(5479 }).pipe(
5211 Effect.provide(5480 Effect.provide(
5212 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5481 Layer.mergeAll(
5482 IdAllocator.layer,
5483 McpProviderSessions.layer,
5484 ScriptProgress.layerUnavailable,
5485 NodeServices.layer,
5486 ),
5213 ),5487 ),
5214 ),5488 ),
5215 ),5489 ),
...@@ -5502,7 +5776,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5502,7 +5776,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5502 assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted");5776 assert.equal(commandUpdates[commandUpdates.length - 1]?.turnItem.status, "interrupted");
5503 }).pipe(5777 }).pipe(
5504 Effect.provide(5778 Effect.provide(
5505 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),5779 Layer.mergeAll(
5780 IdAllocator.layer,
5781 McpProviderSessions.layer,
5782 ScriptProgress.layerUnavailable,
5783 NodeServices.layer,
5784 ),
5506 ),5785 ),
5507 ),5786 ),
5508 ),5787 ),
...@@ -5734,7 +6013,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5734,7 +6013,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5734 assert.isFalse(yield* harness.hasPendingBackgroundWork);6013 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5735 }).pipe(6014 }).pipe(
5736 Effect.provide(6015 Effect.provide(
5737 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6016 Layer.mergeAll(
6017 IdAllocator.layer,
6018 McpProviderSessions.layer,
6019 ScriptProgress.layerUnavailable,
6020 NodeServices.layer,
6021 ),
5738 ),6022 ),
5739 ),6023 ),
5740 ),6024 ),
...@@ -5823,7 +6107,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5823,7 +6107,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5823 assert.isFalse(yield* harness.hasPendingBackgroundWork);6107 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5824 }).pipe(6108 }).pipe(
5825 Effect.provide(6109 Effect.provide(
5826 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6110 Layer.mergeAll(
6111 IdAllocator.layer,
6112 McpProviderSessions.layer,
6113 ScriptProgress.layerUnavailable,
6114 NodeServices.layer,
6115 ),
5827 ),6116 ),
5828 ),6117 ),
5829 ),6118 ),
...@@ -5915,7 +6204,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -5915,7 +6204,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
5915 assert.isFalse(yield* harness.hasPendingBackgroundWork);6204 assert.isFalse(yield* harness.hasPendingBackgroundWork);
5916 }).pipe(6205 }).pipe(
5917 Effect.provide(6206 Effect.provide(
5918 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6207 Layer.mergeAll(
6208 IdAllocator.layer,
6209 McpProviderSessions.layer,
6210 ScriptProgress.layerUnavailable,
6211 NodeServices.layer,
6212 ),
5919 ),6213 ),
5920 ),6214 ),
5921 ),6215 ),
...@@ -6030,7 +6324,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6030,7 +6324,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6030 assert.isFalse(yield* harness.hasPendingBackgroundWork);6324 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6031 }).pipe(6325 }).pipe(
6032 Effect.provide(6326 Effect.provide(
6033 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6327 Layer.mergeAll(
6328 IdAllocator.layer,
6329 McpProviderSessions.layer,
6330 ScriptProgress.layerUnavailable,
6331 NodeServices.layer,
6332 ),
6034 ),6333 ),
6035 ),6334 ),
6036 ),6335 ),
...@@ -6199,7 +6498,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6199,7 +6498,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6199 assert.isFalse(yield* harness.hasPendingBackgroundWork);6498 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6200 }).pipe(6499 }).pipe(
6201 Effect.provide(6500 Effect.provide(
6202 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6501 Layer.mergeAll(
6502 IdAllocator.layer,
6503 McpProviderSessions.layer,
6504 ScriptProgress.layerUnavailable,
6505 NodeServices.layer,
6506 ),
6203 ),6507 ),
6204 ),6508 ),
6205 ),6509 ),
...@@ -6468,7 +6772,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6468,7 +6772,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6468 );6772 );
6469 }).pipe(6773 }).pipe(
6470 Effect.provide(6774 Effect.provide(
6471 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6775 Layer.mergeAll(
6776 IdAllocator.layer,
6777 McpProviderSessions.layer,
6778 ScriptProgress.layerUnavailable,
6779 NodeServices.layer,
6780 ),
6472 ),6781 ),
6473 ),6782 ),
6474 ),6783 ),
...@@ -6579,7 +6888,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6579,7 +6888,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6579 assert.isFalse(yield* harness.hasPendingBackgroundWork);6888 assert.isFalse(yield* harness.hasPendingBackgroundWork);
6580 }).pipe(6889 }).pipe(
6581 Effect.provide(6890 Effect.provide(
6582 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),6891 Layer.mergeAll(
6892 IdAllocator.layer,
6893 McpProviderSessions.layer,
6894 ScriptProgress.layerUnavailable,
6895 NodeServices.layer,
6896 ),
6583 ),6897 ),
6584 ),6898 ),
6585 ),6899 ),
...@@ -6819,7 +7133,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6819,7 +7133,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6819 if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);7133 if (scenario.name === "retry") assert.equal(terminal.retry?.attempt, 1);
6820 }).pipe(7134 }).pipe(
6821 Effect.provide(7135 Effect.provide(
6822 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7136 Layer.mergeAll(
7137 IdAllocator.layer,
7138 McpProviderSessions.layer,
7139 ScriptProgress.layerUnavailable,
7140 NodeServices.layer,
7141 ),
6823 ),7142 ),
6824 ),7143 ),
6825 ),7144 ),
...@@ -6921,7 +7240,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -6921,7 +7240,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
6921 assert.lengthOf(harness.continuationRequests, 0);7240 assert.lengthOf(harness.continuationRequests, 0);
6922 }).pipe(7241 }).pipe(
6923 Effect.provide(7242 Effect.provide(
6924 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7243 Layer.mergeAll(
7244 IdAllocator.layer,
7245 McpProviderSessions.layer,
7246 ScriptProgress.layerUnavailable,
7247 NodeServices.layer,
7248 ),
6925 ),7249 ),
6926 ),7250 ),
6927 ),7251 ),
...@@ -7086,7 +7410,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7086,7 +7410,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7086 assert.isFalse(yield* harness.hasPendingBackgroundWork);7410 assert.isFalse(yield* harness.hasPendingBackgroundWork);
7087 }).pipe(7411 }).pipe(
7088 Effect.provide(7412 Effect.provide(
7089 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7413 Layer.mergeAll(
7414 IdAllocator.layer,
7415 McpProviderSessions.layer,
7416 ScriptProgress.layerUnavailable,
7417 NodeServices.layer,
7418 ),
7090 ),7419 ),
7091 ),7420 ),
7092 ),7421 ),
...@@ -7176,7 +7505,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7176,7 +7505,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7176 assert.isFalse(yield* harness.hasPendingBackgroundWork);7505 assert.isFalse(yield* harness.hasPendingBackgroundWork);
7177 }).pipe(7506 }).pipe(
7178 Effect.provide(7507 Effect.provide(
7179 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7508 Layer.mergeAll(
7509 IdAllocator.layer,
7510 McpProviderSessions.layer,
7511 ScriptProgress.layerUnavailable,
7512 NodeServices.layer,
7513 ),
7180 ),7514 ),
7181 ),7515 ),
7182 ),7516 ),
...@@ -7422,7 +7756,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7422,7 +7756,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7422 assert.isFalse(yield* harness.hasPendingBackgroundWork);7756 assert.isFalse(yield* harness.hasPendingBackgroundWork);
7423 }).pipe(7757 }).pipe(
7424 Effect.provide(7758 Effect.provide(
7425 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),7759 Layer.mergeAll(
7760 IdAllocator.layer,
7761 McpProviderSessions.layer,
7762 ScriptProgress.layerUnavailable,
7763 NodeServices.layer,
7764 ),
7426 ),7765 ),
7427 ),7766 ),
7428 ),7767 ),
...@@ -7660,7 +7999,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7660,7 +7999,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7660 assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2);7999 assert.equal(metadataRequests, name === "current Codex Sol" ? 1 : 2);
7661 }).pipe(8000 }).pipe(
7662 Effect.provide(8001 Effect.provide(
7663 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8002 Layer.mergeAll(
8003 IdAllocator.layer,
8004 McpProviderSessions.layer,
8005 ScriptProgress.layerUnavailable,
8006 NodeServices.layer,
8007 ),
7664 ),8008 ),
7665 ),8009 ),
7666 ),8010 ),
...@@ -7721,7 +8065,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7721,7 +8065,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7721 assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]);8065 assert.deepEqual(metadataRequests, ["thread/read", "thread/resume"]);
7722 }).pipe(8066 }).pipe(
7723 Effect.provide(8067 Effect.provide(
7724 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8068 Layer.mergeAll(
8069 IdAllocator.layer,
8070 McpProviderSessions.layer,
8071 ScriptProgress.layerUnavailable,
8072 NodeServices.layer,
8073 ),
7725 ),8074 ),
7726 ),8075 ),
7727 ),8076 ),
...@@ -7824,7 +8173,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7824,7 +8173,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7824 ]);8173 ]);
7825 }).pipe(8174 }).pipe(
7826 Effect.provide(8175 Effect.provide(
7827 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8176 Layer.mergeAll(
8177 IdAllocator.layer,
8178 McpProviderSessions.layer,
8179 ScriptProgress.layerUnavailable,
8180 NodeServices.layer,
8181 ),
7828 ),8182 ),
7829 ),8183 ),
7830 ),8184 ),
...@@ -7890,7 +8244,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7890,7 +8244,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7890 assert.lengthOf(harness.continuationRequests, 0);8244 assert.lengthOf(harness.continuationRequests, 0);
7891 }).pipe(8245 }).pipe(
7892 Effect.provide(8246 Effect.provide(
7893 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8247 Layer.mergeAll(
8248 IdAllocator.layer,
8249 McpProviderSessions.layer,
8250 ScriptProgress.layerUnavailable,
8251 NodeServices.layer,
8252 ),
7894 ),8253 ),
7895 ),8254 ),
7896 ),8255 ),
...@@ -7974,7 +8333,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -7974,7 +8333,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
7974 assert.equal(DateTime.toEpochMillis(row!.startedAt!), 1782622470000);8333 assert.equal(DateTime.toEpochMillis(row!.startedAt!), 1782622470000);
7975 }).pipe(8334 }).pipe(
7976 Effect.provide(8335 Effect.provide(
7977 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8336 Layer.mergeAll(
8337 IdAllocator.layer,
8338 McpProviderSessions.layer,
8339 ScriptProgress.layerUnavailable,
8340 NodeServices.layer,
8341 ),
7978 ),8342 ),
7979 ),8343 ),
7980 ),8344 ),
...@@ -8140,7 +8504,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8140,7 +8504,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8140 }8504 }
8141 }).pipe(8505 }).pipe(
8142 Effect.provide(8506 Effect.provide(
8143 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8507 Layer.mergeAll(
8508 IdAllocator.layer,
8509 McpProviderSessions.layer,
8510 ScriptProgress.layerUnavailable,
8511 NodeServices.layer,
8512 ),
8144 ),8513 ),
8145 ),8514 ),
8146 ),8515 ),
...@@ -8293,7 +8662,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8293,7 +8662,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8293 }).pipe(8662 }).pipe(
8294 Effect.scoped,8663 Effect.scoped,
8295 Effect.provide(8664 Effect.provide(
8296 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8665 Layer.mergeAll(
8666 IdAllocator.layer,
8667 McpProviderSessions.layer,
8668 ScriptProgress.layerUnavailable,
8669 NodeServices.layer,
8670 ),
8297 ),8671 ),
8298 ),8672 ),
8299 );8673 );
...@@ -8437,7 +8811,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8437,7 +8811,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8437 }).pipe(8811 }).pipe(
8438 Effect.scoped,8812 Effect.scoped,
8439 Effect.provide(8813 Effect.provide(
8440 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8814 Layer.mergeAll(
8815 IdAllocator.layer,
8816 McpProviderSessions.layer,
8817 ScriptProgress.layerUnavailable,
8818 NodeServices.layer,
8819 ),
8441 ),8820 ),
8442 ),8821 ),
8443 );8822 );
...@@ -8544,7 +8923,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8544,7 +8923,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8544 }).pipe(8923 }).pipe(
8545 Effect.scoped,8924 Effect.scoped,
8546 Effect.provide(8925 Effect.provide(
8547 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8926 Layer.mergeAll(
8927 IdAllocator.layer,
8928 McpProviderSessions.layer,
8929 ScriptProgress.layerUnavailable,
8930 NodeServices.layer,
8931 ),
8548 ),8932 ),
8549 ),8933 ),
8550 );8934 );
...@@ -8605,7 +8989,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8605,7 +8989,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8605 }).pipe(8989 }).pipe(
8606 Effect.scoped,8990 Effect.scoped,
8607 Effect.provide(8991 Effect.provide(
8608 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),8992 Layer.mergeAll(
8993 IdAllocator.layer,
8994 McpProviderSessions.layer,
8995 ScriptProgress.layerUnavailable,
8996 NodeServices.layer,
8997 ),
8609 ),8998 ),
8610 ),8999 ),
8611 );9000 );
...@@ -8774,7 +9163,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8774,7 +9163,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8774 }).pipe(9163 }).pipe(
8775 Effect.scoped,9164 Effect.scoped,
8776 Effect.provide(9165 Effect.provide(
8777 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9166 Layer.mergeAll(
9167 IdAllocator.layer,
9168 McpProviderSessions.layer,
9169 ScriptProgress.layerUnavailable,
9170 NodeServices.layer,
9171 ),
8778 ),9172 ),
8779 ),9173 ),
8780 );9174 );
...@@ -8823,7 +9217,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8823,7 +9217,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8823 }).pipe(9217 }).pipe(
8824 Effect.scoped,9218 Effect.scoped,
8825 Effect.provide(9219 Effect.provide(
8826 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9220 Layer.mergeAll(
9221 IdAllocator.layer,
9222 McpProviderSessions.layer,
9223 ScriptProgress.layerUnavailable,
9224 NodeServices.layer,
9225 ),
8827 ),9226 ),
8828 ),9227 ),
8829 );9228 );
...@@ -8919,7 +9318,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -8919,7 +9318,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
8919 }).pipe(9318 }).pipe(
8920 Effect.scoped,9319 Effect.scoped,
8921 Effect.provide(9320 Effect.provide(
8922 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9321 Layer.mergeAll(
9322 IdAllocator.layer,
9323 McpProviderSessions.layer,
9324 ScriptProgress.layerUnavailable,
9325 NodeServices.layer,
9326 ),
8923 ),9327 ),
8924 ),9328 ),
8925 );9329 );
...@@ -9003,7 +9407,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -9003,7 +9407,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
9003 }).pipe(9407 }).pipe(
9004 Effect.scoped,9408 Effect.scoped,
9005 Effect.provide(9409 Effect.provide(
9006 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9410 Layer.mergeAll(
9411 IdAllocator.layer,
9412 McpProviderSessions.layer,
9413 ScriptProgress.layerUnavailable,
9414 NodeServices.layer,
9415 ),
9007 ),9416 ),
9008 ),9417 ),
9009 );9418 );
...@@ -9110,7 +9519,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -9110,7 +9519,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
9110 }).pipe(9519 }).pipe(
9111 Effect.scoped,9520 Effect.scoped,
9112 Effect.provide(9521 Effect.provide(
9113 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9522 Layer.mergeAll(
9523 IdAllocator.layer,
9524 McpProviderSessions.layer,
9525 ScriptProgress.layerUnavailable,
9526 NodeServices.layer,
9527 ),
9114 ),9528 ),
9115 ),9529 ),
9116 );9530 );
...@@ -9197,7 +9611,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -9197,7 +9611,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
9197 }).pipe(9611 }).pipe(
9198 Effect.scoped,9612 Effect.scoped,
9199 Effect.provide(9613 Effect.provide(
9200 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9614 Layer.mergeAll(
9615 IdAllocator.layer,
9616 McpProviderSessions.layer,
9617 ScriptProgress.layerUnavailable,
9618 NodeServices.layer,
9619 ),
9201 ),9620 ),
9202 ),9621 ),
9203 );9622 );
...@@ -9299,7 +9718,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -9299,7 +9718,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
9299 }).pipe(9718 }).pipe(
9300 Effect.scoped,9719 Effect.scoped,
9301 Effect.provide(9720 Effect.provide(
9302 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9721 Layer.mergeAll(
9722 IdAllocator.layer,
9723 McpProviderSessions.layer,
9724 ScriptProgress.layerUnavailable,
9725 NodeServices.layer,
9726 ),
9303 ),9727 ),
9304 ),9728 ),
9305 );9729 );
...@@ -9417,7 +9841,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -9417,7 +9841,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
9417 }).pipe(9841 }).pipe(
9418 Effect.scoped,9842 Effect.scoped,
9419 Effect.provide(9843 Effect.provide(
9420 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9844 Layer.mergeAll(
9845 IdAllocator.layer,
9846 McpProviderSessions.layer,
9847 ScriptProgress.layerUnavailable,
9848 NodeServices.layer,
9849 ),
9421 ),9850 ),
9422 ),9851 ),
9423 );9852 );
...@@ -9486,7 +9915,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {...@@ -9486,7 +9915,12 @@ describe("CodexAdapterV2 post-settle continuation", () => {
9486 }).pipe(9915 }).pipe(
9487 Effect.scoped,9916 Effect.scoped,
9488 Effect.provide(9917 Effect.provide(
9489 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),9918 Layer.mergeAll(
9919 IdAllocator.layer,
9920 McpProviderSessions.layer,
9921 ScriptProgress.layerUnavailable,
9922 NodeServices.layer,
9923 ),
9490 ),9924 ),
9491 ),9925 ),
9492 );9926 );
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts+51-8
...@@ -1,3 +1,5 @@...@@ -1,3 +1,5 @@
1import { codexScriptProgressConfig } from "./codexScriptProgress.ts";
2import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { revertCodexThread } from "../../provider/CodexThreadRevert.ts";3import { revertCodexThread } from "../../provider/CodexThreadRevert.ts";
2import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget";4import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget";
3import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";5import { makeProviderTextDeltaCoalescer } from "./ProviderTextDeltaCoalescer.ts";
...@@ -1332,6 +1334,7 @@ export function codexThreadRuntimeParams(input: {...@@ -1332,6 +1334,7 @@ export function codexThreadRuntimeParams(input: {
1332 readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;1334 readonly mcpSession: McpProviderSession.McpProviderSessionConfig | undefined;
1333 readonly modelSelection?: { readonly model: string };1335 readonly modelSelection?: { readonly model: string };
1334 readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy;1336 readonly runtimePolicy?: ProviderAdapter.ProviderAdapterV2RuntimePolicy;
1337 readonly scriptProgressConfig?: Readonly<Record<string, Schema.Json>>;
1335}): {1338}): {
1336 readonly cwd?: string;1339 readonly cwd?: string;
1337 readonly model?: string;1340 readonly model?: string;
...@@ -1343,6 +1346,7 @@ export function codexThreadRuntimeParams(input: {...@@ -1343,6 +1346,7 @@ export function codexThreadRuntimeParams(input: {
1343 ...(input.modelSelection === undefined ? {} : { model: input.modelSelection.model }),1346 ...(input.modelSelection === undefined ? {} : { model: input.modelSelection.model }),
1344 config: {1347 config: {
1345 ...CODEX_THREAD_CONFIG,1348 ...CODEX_THREAD_CONFIG,
1349 ...input.scriptProgressConfig,
1346 ...(mcpSession === undefined1350 ...(mcpSession === undefined
1347 ? {}1351 ? {}
1348 : {1352 : {
...@@ -1592,6 +1596,7 @@ export type CodexAdapterV2DriverEnv =...@@ -1592,6 +1596,7 @@ export type CodexAdapterV2DriverEnv =
1592 | Crypto.Crypto1596 | Crypto.Crypto
1593 | FileSystem.FileSystem1597 | FileSystem.FileSystem
1594 | IdAllocator.IdAllocatorV21598 | IdAllocator.IdAllocatorV2
1599 | ScriptProgress.ScriptProgress
1595 | McpProviderSessions.McpProviderSessions1600 | McpProviderSessions.McpProviderSessions
1596 | Path.Path1601 | Path.Path
1597 | ServerConfig;1602 | ServerConfig;
...@@ -1657,6 +1662,7 @@ const layer: Layer.Layer<...@@ -1657,6 +1662,7 @@ const layer: Layer.Layer<
1657 | Crypto.Crypto1662 | Crypto.Crypto
1658 | FileSystem.FileSystem1663 | FileSystem.FileSystem
1659 | IdAllocator.IdAllocatorV21664 | IdAllocator.IdAllocatorV2
1665 | ScriptProgress.ScriptProgress
1660 | McpProviderSessions.McpProviderSessions1666 | McpProviderSessions.McpProviderSessions
1661 | ServerConfig1667 | ServerConfig
1662> = Layer.effect(1668> = Layer.effect(
...@@ -1716,6 +1722,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -1716,6 +1722,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
1716 adapterOptions: CodexAdapterV2Options,1722 adapterOptions: CodexAdapterV2Options,
1717) {1723) {
1718 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;1724 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
1725 const scriptProgress = yield* ScriptProgress.ScriptProgress;
1719 const readMcpSession = (threadId: ThreadId | null) =>1726 const readMcpSession = (threadId: ThreadId | null) =>
1720 threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId);1727 threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId);
1721 const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions;1728 const { clientFactory, crypto, fileSystem, idAllocator, serverConfig } = adapterOptions;
...@@ -1827,6 +1834,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -1827,6 +1834,24 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
1827 yield* Ref.set(initialized, true);1834 yield* Ref.set(initialized, true);
1828 }),1835 }),
1829 );1836 );
1837 const readScriptProgressConfig = Effect.fnUntraced(function* (
1838 threadId: ThreadId | null,
1839 cwd: string | null | undefined,
1840 ) {
1841 if (threadId === null) return {};
1842 const environment = yield* scriptProgress.environmentForThread(threadId);
1843 if (Object.keys(environment).length === 0) return {};
1844 yield* ensureInitialized;
1845 const { config } = yield* client.request("config/read", {
1846 ...(cwd == null ? {} : { cwd }),
1847 includeLayers: false,
1848 });
1849 return codexScriptProgressConfig(
1850 config.shell_environment_policy,
1851 environment,
1852 (resolvedRuntime?.environment ?? adapterOptions.environment).NODE_OPTIONS,
1853 );
1854 });
1830 const now = yield* DateTime.now;1855 const now = yield* DateTime.now;
1831 const session = providerSession({1856 const session = providerSession({
1832 providerSessionId: input.providerSessionId,1857 providerSessionId: input.providerSessionId,
...@@ -6567,14 +6592,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -6567,14 +6592,20 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
6567 ensureInitialized.pipe(6592 ensureInitialized.pipe(
6568 Effect.andThen(mcpSessions.read(threadInput.threadId)),6593 Effect.andThen(mcpSessions.read(threadInput.threadId)),
6569 Effect.flatMap((mcpSession) =>6594 Effect.flatMap((mcpSession) =>
6570 client.request(6595 Effect.gen(function* () {
6571 "thread/start",6596 return yield* client.request(
6572 codexThreadRuntimeParams({6597 "thread/start",
6573 mcpSession,6598 codexThreadRuntimeParams({
6574 modelSelection: threadInput.modelSelection,6599 mcpSession,
6575 runtimePolicy: threadInput.runtimePolicy,6600 scriptProgressConfig: yield* readScriptProgressConfig(
6576 }),6601 threadInput.threadId,
6577 ),6602 threadInput.runtimePolicy?.cwd,
6603 ),
6604 modelSelection: threadInput.modelSelection,
6605 runtimePolicy: threadInput.runtimePolicy,
6606 }),
6607 );
6608 }),
6578 ),6609 ),
6579 Effect.map((response): OrchestrationV2ProviderThread =>6610 Effect.map((response): OrchestrationV2ProviderThread =>
6580 providerThreadFromCodexThread({6611 providerThreadFromCodexThread({
...@@ -6606,6 +6637,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -6606,6 +6637,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
6606 mcpSession: yield* readMcpSession(6637 mcpSession: yield* readMcpSession(
6607 threadInput.threadId ?? threadInput.providerThread.appThreadId,6638 threadInput.threadId ?? threadInput.providerThread.appThreadId,
6608 ),6639 ),
6640 scriptProgressConfig: yield* readScriptProgressConfig(
6641 threadInput.threadId ?? threadInput.providerThread.appThreadId,
6642 threadInput.runtimePolicy?.cwd ?? input.runtimePolicy.cwd,
6643 ),
6609 ...(threadInput.modelSelection === undefined6644 ...(threadInput.modelSelection === undefined
6610 ? {}6645 ? {}
6611 : { modelSelection: threadInput.modelSelection }),6646 : { modelSelection: threadInput.modelSelection }),
...@@ -7335,6 +7370,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -7335,6 +7370,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
7335 excludeTurns: true,7370 excludeTurns: true,
7336 ...codexThreadRuntimeParams({7371 ...codexThreadRuntimeParams({
7337 mcpSession: yield* readMcpSession(threadInput.providerThread.appThreadId),7372 mcpSession: yield* readMcpSession(threadInput.providerThread.appThreadId),
7373 scriptProgressConfig: yield* readScriptProgressConfig(
7374 threadInput.providerThread.appThreadId,
7375 input.runtimePolicy.cwd,
7376 ),
7338 modelSelection: input.modelSelection,7377 modelSelection: input.modelSelection,
7339 runtimePolicy: input.runtimePolicy,7378 runtimePolicy: input.runtimePolicy,
7340 }),7379 }),
...@@ -7384,6 +7423,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (...@@ -7384,6 +7423,10 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
7384 : { lastTurnId: boundary.lastTurnId }),7423 : { lastTurnId: boundary.lastTurnId }),
7385 ...codexThreadRuntimeParams({7424 ...codexThreadRuntimeParams({
7386 mcpSession: yield* mcpSessions.read(threadInput.targetThreadId),7425 mcpSession: yield* mcpSessions.read(threadInput.targetThreadId),
7426 scriptProgressConfig: yield* readScriptProgressConfig(
7427 threadInput.targetThreadId,
7428 threadInput.runtimePolicy?.cwd ?? input.runtimePolicy.cwd,
7429 ),
7387 ...(threadInput.modelSelection === undefined7430 ...(threadInput.modelSelection === undefined
7388 ? {}7431 ? {}
7389 : { modelSelection: threadInput.modelSelection }),7432 : { modelSelection: threadInput.modelSelection }),
apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import {3import {
3 ProjectId,4 ProjectId,
...@@ -326,6 +327,7 @@ describe("Grok launch permission mode", () => {...@@ -326,6 +327,7 @@ describe("Grok launch permission mode", () => {
326 NodeServices.layer,327 NodeServices.layer,
327 IdAllocator.layer,328 IdAllocator.layer,
328 McpProviderSessions.layer,329 McpProviderSessions.layer,
330 ScriptProgress.layerUnavailable,
329 layerHost,331 layerHost,
330 );332 );
331333
apps/server/src/orchestration-v2/Adapters/OpenCode2AdapterV2.test.ts+6-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1/**2/**
2 * Failures and settings a live OpenCode 2 server cannot produce on demand,3 * Failures and settings a live OpenCode 2 server cannot produce on demand,
3 * driven through the real adapter and `@opencode/client` against a replayed4 * driven through the real adapter and `@opencode/client` against a replayed
...@@ -423,7 +424,9 @@ const history = {...@@ -423,7 +424,9 @@ const history = {
423 cursor: {},424 cursor: {},
424};425};
425426
426it.layer(McpProviderSessions.layer)("OpenCode2 adapter", (it) => {427const layerThreadServices = Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable);
428
429it.layer(layerThreadServices)("OpenCode2 adapter", (it) => {
427 it.effect("switches the session's model and variant before a turn that changed them", () =>430 it.effect("switches the session's model and variant before a turn that changed them", () =>
428 Effect.gen(function* () {431 Effect.gen(function* () {
429 const { runtime, thread } = yield* resumed([432 const { runtime, thread } = yield* resumed([
...@@ -5243,7 +5246,7 @@ describe("OpenCode2 adapter server connection", () => {...@@ -5243,7 +5246,7 @@ describe("OpenCode2 adapter server connection", () => {
5243 assert.equal(borrowers.current, 1);5246 assert.equal(borrowers.current, 1);
5244 }).pipe(Effect.scoped);5247 }).pipe(Effect.scoped);
5245 assert.equal(borrowers.current, 0);5248 assert.equal(borrowers.current, 0);
5246 }).pipe(Effect.provide(McpProviderSessions.layer)),5249 }).pipe(Effect.provide(layerThreadServices)),
5247 );5250 );
5248});5251});
52495252
...@@ -5256,7 +5259,7 @@ const providerTurnId = Effect.gen(function* () {...@@ -5256,7 +5259,7 @@ const providerTurnId = Effect.gen(function* () {
5256 });5259 });
5257}).pipe(Effect.provide(IdAllocator.layer));5260}).pipe(Effect.provide(IdAllocator.layer));
52585261
5259it.layer(McpProviderSessions.layer)("OpenCode reported model variants", (it) => {5262it.layer(layerThreadServices)("OpenCode reported model variants", (it) => {
5260 it.effect(5263 it.effect(
5261 "updates reported variants from selected-model and step events without duplicate updates",5264 "updates reported variants from selected-model and step events without duplicate updates",
5262 () =>5265 () =>
apps/server/src/orchestration-v2/Adapters/codexScriptProgress.test.ts created+65
...@@ -0,0 +1,65 @@
1import { describe, expect, it } from "vite-plus/test";
2import { codexScriptProgressConfig } from "./codexScriptProgress.ts";
3
4const progress = {
5 NODE_OPTIONS: '--import="/private/progress.mjs"',
6 T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1234/ingest",
7 T3_SCRIPT_PROGRESS_TOKEN: "thread-one",
8};
9
10describe("Codex script progress environment", () => {
11 it("preserves user overrides and Node options without weakening exclusions", () => {
12 const policy = {
13 inherit: "core",
14 ignore_default_excludes: false,
15 exclude: ["PRIVATE_*"],
16 include_only: ["PATH", "MY_SETTING"],
17 set: { MY_SETTING: "keep", NODE_OPTIONS: "--trace-warnings" },
18 };
19 expect(codexScriptProgressConfig(policy, progress, "--max-old-space-size=2048")).toEqual({
20 "shell_environment_policy.set": {
21 MY_SETTING: "keep",
22 ...progress,
23 NODE_OPTIONS: '--import="/private/progress.mjs" --max-old-space-size=2048 --trace-warnings',
24 },
25 "shell_environment_policy.include_only": [
26 "PATH",
27 "MY_SETTING",
28 "NODE_OPTIONS",
29 "T3_SCRIPT_PROGRESS_ENDPOINT",
30 "T3_SCRIPT_PROGRESS_TOKEN",
31 ],
32 });
33 expect(policy.set.NODE_OPTIONS).toBe("--trace-warnings");
34 expect(policy.include_only).toEqual(["PATH", "MY_SETTING"]);
35 });
36
37 it("accepts null optional fields returned by Codex config/read", () => {
38 expect(
39 codexScriptProgressConfig(
40 {
41 inherit: null,
42 ignore_default_excludes: null,
43 exclude: null,
44 set: {},
45 include_only: null,
46 filters: null,
47 experimental_use_profile: null,
48 },
49 progress,
50 ),
51 ).toEqual({ "shell_environment_policy.set": progress });
52 expect(codexScriptProgressConfig({ set: null, include_only: null }, progress)).toEqual({
53 "shell_environment_policy.set": progress,
54 });
55 });
56
57 it("does not create an allowlist when the user inherits the normal environment", () => {
58 expect(codexScriptProgressConfig(undefined, progress)).toEqual({
59 "shell_environment_policy.set": progress,
60 });
61 expect(codexScriptProgressConfig({ include_only: [] }, progress)).toEqual({
62 "shell_environment_policy.set": progress,
63 });
64 });
65});
apps/server/src/orchestration-v2/Adapters/codexScriptProgress.ts created+36
...@@ -0,0 +1,36 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
2import * as Schema from "effect/Schema";
3
4const ShellEnvironmentPolicy = Schema.StructWithRest(
5 Schema.Struct({
6 set: Schema.optionalKey(Schema.NullOr(Schema.Record(Schema.String, Schema.String))),
7 include_only: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.String))),
8 }),
9 [Schema.Record(Schema.String, Schema.Json)],
10);
11
12const decodeShellEnvironmentPolicy = Schema.decodeUnknownSync(ShellEnvironmentPolicy);
13
14export function codexScriptProgressConfig(
15 policy: Schema.Json | undefined,
16 environment: Readonly<Record<string, string>>,
17 inheritedNodeOptions?: string,
18): Readonly<Record<string, Schema.Json>> {
19 const current = decodeShellEnvironmentPolicy(policy ?? {});
20 const nodeOptions = [inheritedNodeOptions, current.set?.NODE_OPTIONS].filter(Boolean).join(" ");
21 const shellEnvironment = {
22 ...current.set,
23 ...(nodeOptions ? { NODE_OPTIONS: nodeOptions } : {}),
24 };
25 const merged = ScriptProgress.withScriptProgressEnvironment(shellEnvironment, environment);
26 return {
27 "shell_environment_policy.set": merged,
28 ...(current.include_only == null || current.include_only.length === 0
29 ? {}
30 : {
31 "shell_environment_policy.include_only": [
32 ...new Set([...current.include_only, ...Object.keys(environment)]),
33 ],
34 }),
35 };
36}
apps/server/src/orchestration-v2/ClaudeAutomaticDelivery.integration.test.ts+7-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";2import type { SDKMessage, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";3import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -434,7 +435,12 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con...@@ -434,7 +435,12 @@ it.effect.each(["child completion", "scheduled message", "user steering"] as con
434 );435 );
435 }).pipe(436 }).pipe(
436 Effect.provide(437 Effect.provide(
437 Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer),438 Layer.mergeAll(
439 IdAllocator.layer,
440 McpProviderSessions.layer,
441 ScriptProgress.layerUnavailable,
442 NodeServices.layer,
443 ),
438 ),444 ),
439 ),445 ),
440 ),446 ),
apps/server/src/orchestration-v2/CursorOrchestratorV2.live.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";2import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";3import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -125,7 +126,7 @@ const layerLive = RuntimeLayer.layer.pipe(...@@ -125,7 +126,7 @@ const layerLive = RuntimeLayer.layer.pipe(
125 Layer.provide(ResetCreditCoordinator.layer),126 Layer.provide(ResetCreditCoordinator.layer),
126 Layer.provide(layerBackgroundPolicy),127 Layer.provide(layerBackgroundPolicy),
127 Layer.provide(layerPlatformTest),128 Layer.provide(layerPlatformTest),
128 Layer.provide(McpProviderSessions.layer),129 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
129);130);
130131
131const waitForIdle = Effect.fn("CursorOrchestratorV2Live.waitForIdle")(function* (132const waitForIdle = Effect.fn("CursorOrchestratorV2Live.waitForIdle")(function* (
apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";2import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
2import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";3import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -133,7 +134,7 @@ const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink...@@ -133,7 +134,7 @@ const layerTest = Layer.mergeAll(RuntimeLayer.layer, RuntimeLayer.layerEventSink
133 }),134 }),
134 ),135 ),
135 Layer.provide(McpSessionRegistryTestkit.layer),136 Layer.provide(McpSessionRegistryTestkit.layer),
136 Layer.provide(McpProviderSessions.layer),137 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
137 Layer.provide(SqlitePersistence.layerMemory),138 Layer.provide(SqlitePersistence.layerMemory),
138 Layer.provide(layerCheckpointStoreTest),139 Layer.provide(layerCheckpointStoreTest),
139 Layer.provide(layerServerConfig),140 Layer.provide(layerServerConfig),
apps/server/src/orchestration-v2/GrokOrchestratorV2.live.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";2import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";3import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -124,7 +125,7 @@ const layerLive = RuntimeLayer.layer.pipe(...@@ -124,7 +125,7 @@ const layerLive = RuntimeLayer.layer.pipe(
124 Layer.provide(ResetCreditCoordinator.layer),125 Layer.provide(ResetCreditCoordinator.layer),
125 Layer.provide(layerBackgroundPolicy),126 Layer.provide(layerBackgroundPolicy),
126 Layer.provide(layerPlatformTest),127 Layer.provide(layerPlatformTest),
127 Layer.provide(McpProviderSessions.layer),128 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
128);129);
129130
130const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) {131const waitForIdle = Effect.fn("GrokOrchestratorV2Live.waitForIdle")(function* (threadId: ThreadId) {
apps/server/src/orchestration-v2/OpenCode2OrchestratorV2.live.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1/**2/**
2 * Runs OpenCode 2 through the whole orchestrator with the real driver: the3 * Runs OpenCode 2 through the whole orchestrator with the real driver: the
3 * driver probes the binary, spawns `opencode serve`, and routes to the 2.x4 * driver probes the binary, spawns `opencode serve`, and routes to the 2.x
...@@ -218,7 +219,7 @@ const layerOrchestration = RuntimeLayer.layer.pipe(...@@ -218,7 +219,7 @@ const layerOrchestration = RuntimeLayer.layer.pipe(
218 Layer.provide(ResetCreditCoordinator.layer),219 Layer.provide(ResetCreditCoordinator.layer),
219 Layer.provide(layerBackgroundPolicy),220 Layer.provide(layerBackgroundPolicy),
220 Layer.provide(layerPlatformTest),221 Layer.provide(layerPlatformTest),
221 Layer.provide(McpProviderSessions.layer),222 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
222);223);
223224
224// Starts the continuation run a provider wake asks for, as the production layer does.225// Starts the continuation run a provider wake asks for, as the production layer does.
apps/server/src/orchestration-v2/ProjectSettingsUpgrade.integration.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";3import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
3import { assert, it } from "@effect/vitest";4import { assert, it } from "@effect/vitest";
...@@ -153,7 +154,7 @@ const layerRuntime = (dbPath: string) => {...@@ -153,7 +154,7 @@ const layerRuntime = (dbPath: string) => {
153 }),154 }),
154 ),155 ),
155 Layer.provide(McpSessionRegistryTestkit.layer),156 Layer.provide(McpSessionRegistryTestkit.layer),
156 Layer.provide(McpProviderSessions.layer),157 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
157 Layer.provideMerge(SqlitePersistence.layerFromPath(dbPath)),158 Layer.provideMerge(SqlitePersistence.layerFromPath(dbPath)),
158 Layer.provide(layerCheckpointStore),159 Layer.provide(layerCheckpointStore),
159 Layer.provide(layerServerConfig),160 Layer.provide(layerServerConfig),
apps/server/src/orchestration-v2/ProviderSessionManager.test.ts+7-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NetAddress from "effect/net/NetAddress";2import * as NetAddress from "effect/net/NetAddress";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";4import { assert, it } from "@effect/vitest";
...@@ -553,13 +554,18 @@ function layerTest(input: {...@@ -553,13 +554,18 @@ function layerTest(input: {
553 IdAllocator.layer,554 IdAllocator.layer,
554 layerConfiguredMcpRegistry,555 layerConfiguredMcpRegistry,
555 McpProviderSessions.layer,556 McpProviderSessions.layer,
557 ScriptProgress.layerUnavailable,
556 ProviderSessionManager.layerWithOptions({558 ProviderSessionManager.layerWithOptions({
557 idleTimeoutMs: input.idleTimeoutMs,559 idleTimeoutMs: input.idleTimeoutMs,
558 ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }),560 ...(input.maxIdlePinMs === undefined ? {} : { maxIdlePinMs: input.maxIdlePinMs }),
559 }).pipe(561 }).pipe(
560 Layer.provide(562 Layer.provide(
561 Layer.mergeAll(563 Layer.mergeAll(
562 layerRegistry.pipe(Layer.provideMerge(McpProviderSessions.layer)),564 layerRegistry.pipe(
565 Layer.provideMerge(
566 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
567 ),
568 ),
563 layerConfiguredEventSink,569 layerConfiguredEventSink,
564 IdAllocator.layer,570 IdAllocator.layer,
565 layerProviderEventIngestorTest,571 layerProviderEventIngestorTest,
apps/server/src/orchestration-v2/runtimeLayer.test.ts+6-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { SourceControlProviderKind } from "@t3tools/contracts";2import { SourceControlProviderKind } from "@t3tools/contracts";
2import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";3import { limitRecoveryCommand } from "./UsageLimitRecoveryWorker.ts";
3import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";4import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
...@@ -274,7 +275,7 @@ const layerTest = Layer.mergeAll(...@@ -274,7 +275,7 @@ const layerTest = Layer.mergeAll(
274 ThreadCommandExecutor.layer,275 ThreadCommandExecutor.layer,
275).pipe(276).pipe(
276 Layer.provide(McpSessionRegistryTestkit.layer),277 Layer.provide(McpSessionRegistryTestkit.layer),
277 Layer.provide(McpProviderSessions.layer),278 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
278 Layer.provide(SqlitePersistence.layerMemory),279 Layer.provide(SqlitePersistence.layerMemory),
279 Layer.provide(layerCheckpointStoreTest),280 Layer.provide(layerCheckpointStoreTest),
280 Layer.provide(layerServerConfig),281 Layer.provide(layerServerConfig),
...@@ -287,7 +288,7 @@ const layerTest = Layer.mergeAll(...@@ -287,7 +288,7 @@ const layerTest = Layer.mergeAll(
287288
288const layerLegacyImportTest = RuntimeLayer.layer.pipe(289const layerLegacyImportTest = RuntimeLayer.layer.pipe(
289 Layer.provide(McpSessionRegistryTestkit.layer),290 Layer.provide(McpSessionRegistryTestkit.layer),
290 Layer.provide(McpProviderSessions.layer),291 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
291 Layer.provideMerge(SqlitePersistence.layerMemory),292 Layer.provideMerge(SqlitePersistence.layerMemory),
292 Layer.provide(layerCheckpointStoreTest),293 Layer.provide(layerCheckpointStoreTest),
293 Layer.provide(layerServerConfig),294 Layer.provide(layerServerConfig),
...@@ -327,7 +328,7 @@ const layerProjectDeletionTest = Layer.mergeAll(...@@ -327,7 +328,7 @@ const layerProjectDeletionTest = Layer.mergeAll(
327 }),328 }),
328 ),329 ),
329 Layer.provide(McpSessionRegistryTestkit.layer),330 Layer.provide(McpSessionRegistryTestkit.layer),
330 Layer.provide(McpProviderSessions.layer),331 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
331 Layer.provide(SqlitePersistence.layerMemory),332 Layer.provide(SqlitePersistence.layerMemory),
332 Layer.provide(layerCheckpointStoreTest),333 Layer.provide(layerCheckpointStoreTest),
333 Layer.provide(layerServerConfig),334 Layer.provide(layerServerConfig),
...@@ -472,7 +473,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll(...@@ -472,7 +473,7 @@ const layerSharedApplicationDataPlaneTest = Layer.mergeAll(
472 }),473 }),
473 ),474 ),
474 Layer.provide(McpSessionRegistryTestkit.layer),475 Layer.provide(McpSessionRegistryTestkit.layer),
475 Layer.provide(McpProviderSessions.layer),476 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
476 Layer.provideMerge(SqlitePersistence.layerMemory),477 Layer.provideMerge(SqlitePersistence.layerMemory),
477 Layer.provide(layerCheckpointStoreTest),478 Layer.provide(layerCheckpointStoreTest),
478 Layer.provide(layerServerConfig),479 Layer.provide(layerServerConfig),
...@@ -5710,7 +5711,7 @@ const layerQueueResumeDisabledInstanceTest = Layer.mergeAll(...@@ -5710,7 +5711,7 @@ const layerQueueResumeDisabledInstanceTest = Layer.mergeAll(
5710 ThreadCommandExecutor.layer,5711 ThreadCommandExecutor.layer,
5711).pipe(5712).pipe(
5712 Layer.provide(McpSessionRegistryTestkit.layer),5713 Layer.provide(McpSessionRegistryTestkit.layer),
5713 Layer.provide(McpProviderSessions.layer),5714 Layer.provide(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
5714 Layer.provide(SqlitePersistence.layerMemory),5715 Layer.provide(SqlitePersistence.layerMemory),
5715 Layer.provide(layerCheckpointStoreTest),5716 Layer.provide(layerCheckpointStoreTest),
5716 Layer.provide(layerServerConfig),5717 Layer.provide(layerServerConfig),
apps/server/src/orchestration-v2/testkit/ProviderReplayHarness.ts+11-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";2import * as OtelEnvironment from "@t3tools/shared/otelEnvironment";
2import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";3import { DEFAULT_SIGNAL_EXPORT } from "@t3tools/shared/observability";
3import * as NodeServices from "@effect/platform-node/NodeServices";4import * as NodeServices from "@effect/platform-node/NodeServices";
...@@ -174,7 +175,7 @@ export interface OrchestratorV2ProviderReplayHarness<...@@ -174,7 +175,7 @@ export interface OrchestratorV2ProviderReplayHarness<
174 ) => Layer.Layer<175 ) => Layer.Layer<
175 ProviderAdapterRegistry.ProviderAdapterRegistryV2,176 ProviderAdapterRegistry.ProviderAdapterRegistryV2,
176 Error,177 Error,
177 McpProviderSessions.McpProviderSessions178 McpProviderSessions.McpProviderSessions | ScriptProgress.ScriptProgress
178 >;179 >;
179}180}
180181
...@@ -260,7 +261,7 @@ export function layerWithRegistry<Error>(...@@ -260,7 +261,7 @@ export function layerWithRegistry<Error>(
260 registryLayer: Layer.Layer<261 registryLayer: Layer.Layer<
261 ProviderAdapterRegistry.ProviderAdapterRegistryV2,262 ProviderAdapterRegistry.ProviderAdapterRegistryV2,
262 Error,263 Error,
263 McpProviderSessions.McpProviderSessions264 McpProviderSessions.McpProviderSessions | ScriptProgress.ScriptProgress
264 >,265 >,
265 options: {266 options: {
266 readonly databaseLayer?: Layer.Layer<267 readonly databaseLayer?: Layer.Layer<
...@@ -299,7 +300,13 @@ export function layerWithRegistry<Error>(...@@ -299,7 +300,13 @@ export function layerWithRegistry<Error>(
299 const layerContinuationRequests =300 const layerContinuationRequests =
300 options.runContinuationWorker === true ? ProviderContinuationRequests.layer : Layer.empty;301 options.runContinuationWorker === true ? ProviderContinuationRequests.layer : Layer.empty;
301 const layerProvidedRegistry = registryLayer.pipe(302 const layerProvidedRegistry = registryLayer.pipe(
302 Layer.provide(Layer.merge(layerContinuationRequests, McpProviderSessions.layer)),303 Layer.provide(
304 Layer.mergeAll(
305 layerContinuationRequests,
306 McpProviderSessions.layer,
307 ScriptProgress.layerUnavailable,
308 ),
309 ),
303 );310 );
304 const layerServerSettings = ServerSettings.layerTest({311 const layerServerSettings = ServerSettings.layerTest({
305 responseStreamingMode: "turn",312 responseStreamingMode: "turn",
...@@ -362,6 +369,7 @@ export function layerWithRegistry<Error>(...@@ -362,6 +369,7 @@ export function layerWithRegistry<Error>(
362 layerEventSinkProvided,369 layerEventSinkProvided,
363 IdAllocator.layer,370 IdAllocator.layer,
364 McpProviderSessions.layer,371 McpProviderSessions.layer,
372 ScriptProgress.layerUnavailable,
365 McpSessionRegistryTestkit.layer,373 McpSessionRegistryTestkit.layer,
366 layerProviderEventIngestorProvided,374 layerProviderEventIngestorProvided,
367 layerStores,375 layerStores,
apps/server/src/progress/ScriptProgress.test.ts created+537
...@@ -0,0 +1,537 @@
1// @effect-diagnostics nodeBuiltinImport:off -- A native child validates NODE_OPTIONS before application code runs.
2import * as NodeNet from "node:net";
3import * as NodeChildProcess from "node:child_process";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import { expect, it } from "@effect/vitest";
6import { Root, decodeEventStream, type StreamEvent } from "@clo/lib/progress";
7import { ThreadId, type ScriptProgressEvent } from "@t3tools/contracts";
8import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
9import * as Effect from "effect/Effect";
10import * as FileSystem from "effect/FileSystem";
11import * as Layer from "effect/Layer";
12import * as Queue from "effect/Queue";
13import * as Stream from "effect/Stream";
14
15import * as ServerConfig from "../config.ts";
16import * as Capture from "./ScriptProgress.ts";
17
18const threadId = ThreadId.make("progress-thread");
19const otherThreadId = ThreadId.make("other-progress-thread");
20const testLayer = Capture.layer.pipe(
21 Layer.provide(ServerConfig.layerTest("/tmp", { prefix: "t3 script progress " })),
22 Layer.provideMerge(NodeServices.layer),
23);
24
25const connect = (endpoint: string) =>
26 Effect.acquireRelease(
27 Effect.callback<NodeNet.Socket>((resume) => {
28 const socket = NodeNet.connect({ host: "127.0.0.1", port: Number(endpoint.split(":")[1]) });
29 socket.once("connect", () => resume(Effect.succeed(socket)));
30 socket.once("error", (error) => resume(Effect.die(error)));
31 return Effect.sync(() => socket.destroy());
32 }),
33 (socket) => Effect.sync(() => socket.destroy()),
34 );
35
36const closed = (socket: NodeNet.Socket) =>
37 Effect.callback<void>((resume) => {
38 if (socket.closed) return resume(Effect.void);
39 socket.once("close", () => resume(Effect.void));
40 });
41
42const write = (socket: NodeNet.Socket, value: unknown) =>
43 Effect.sync(() => {
44 socket.write(`${JSON.stringify(value)}\n`);
45 });
46
47const until = <E, R>(
48 pull: Effect.Effect<ScriptProgressEvent, E, R>,
49 predicate: (event: ScriptProgressEvent) => boolean,
50) =>
51 Effect.gen(function* () {
52 while (true) {
53 const event = yield* pull;
54 if (predicate(event)) return event;
55 }
56 });
57
58it.effect("materializes an external preload and gives each thread an isolated capture token", () =>
59 Effect.gen(function* () {
60 const progress = yield* ScriptProgress.ScriptProgress;
61 const first = yield* progress.environmentForThread(threadId);
62 const again = yield* progress.environmentForThread(threadId);
63 const other = yield* progress.environmentForThread(otherThreadId);
64 expect(first).toEqual(again);
65 expect(first.T3_SCRIPT_PROGRESS_TOKEN).not.toBe(other.T3_SCRIPT_PROGRESS_TOKEN);
66 expect(first.T3_SCRIPT_PROGRESS_ENDPOINT).toMatch(/^127\.0\.0\.1:\d+$/);
67 const fs = yield* FileSystem.FileSystem;
68 const preloadPath: unknown = JSON.parse(first.NODE_OPTIONS!.slice("--require=".length));
69 if (typeof preloadPath !== "string") throw new Error("Expected preload path");
70 const stat = yield* fs.stat(preloadPath);
71 expect(Number(stat.size)).toBeGreaterThan(100);
72 expect(preloadPath).not.toContain(".asar/");
73 expect(preloadPath).toContain("t3 script progress ");
74 expect(preloadPath).toMatch(/\.cjs$/);
75 const environment = ScriptProgress.withScriptProgressEnvironment(
76 {
77 ...process.env,
78 NODE_OPTIONS: "--max-old-space-size=128",
79 },
80 first,
81 );
82 expect(ScriptProgress.withScriptProgressEnvironment(environment, first)).toEqual(environment);
83 const result = yield* Effect.callback<{ code: number | null; stdout: string; stderr: string }>(
84 (resume) => {
85 const child = NodeChildProcess.spawn(
86 process.execPath,
87 ["-e", "process.stdout.write('preload-loaded')"],
88 { env: environment },
89 );
90 let stdout = "";
91 let stderr = "";
92 child.stdout.on("data", (chunk: Buffer) => {
93 stdout += chunk.toString();
94 });
95 child.stderr.on("data", (chunk: Buffer) => {
96 stderr += chunk.toString();
97 });
98 child.once("error", (error) => resume(Effect.die(error)));
99 child.once("close", (code) => resume(Effect.succeed({ code, stdout, stderr })));
100 return Effect.sync(() => {
101 child.kill();
102 });
103 },
104 );
105 expect(result).toEqual({ code: 0, stdout: "preload-loaded", stderr: "" });
106 }).pipe(Effect.scoped, Effect.provide(testLayer)),
107);
108
109it.effect(
110 "late subscribers get a current reset snapshot with nested nodes and subsequent zero deltas",
111 () =>
112 Effect.gen(function* () {
113 const progress = yield* ScriptProgress.ScriptProgress;
114 const env = yield* progress.environmentForThread(threadId);
115 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
116 const pull = Queue.take(queue);
117 expect(yield* pull).toEqual({ type: "snapshot", streams: [] });
118 const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
119 yield* write(socket, {
120 type: "hello",
121 producerId: "fixture",
122 token: env.T3_SCRIPT_PROGRESS_TOKEN,
123 pid: 123,
124 label: "build.ts",
125 });
126 yield* write(socket, {
127 type: "progress",
128 streamId: "global",
129 event: [
130 -1,
131 { k: 1, t: "Build", v: 2, e: 10, c: [2] },
132 { k: 2, t: "Assets", v: 1, e: 3 },
133 [1],
134 ],
135 });
136 yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 2);
137 yield* write(socket, { type: "progress", streamId: "global", event: [0, { k: 1, v: 7 }] });
138 yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 7);
139
140 const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), {
141 capacity: "unbounded",
142 });
143 const late = Queue.take(lateQueue);
144 const snapshot = yield* late;
145 expect(snapshot).toMatchObject({
146 type: "snapshot",
147 streams: [
148 {
149 pid: 123,
150 label: "build.ts",
151 status: "connected",
152 summary: [{ text: "Build", value: 7, total: 10 }],
153 },
154 ],
155 });
156 const reset = yield* until(late, (event) => event.type === "progress");
157 if (reset.type !== "progress") throw new Error("Expected progress");
158 expect(reset.event[0]).toBe(-1);
159 const root = new Root();
160 let input!: ReadableStreamDefaultController<StreamEvent>;
161 const decoder = decodeEventStream(
162 new ReadableStream({
163 start(controller) {
164 input = controller;
165 },
166 }),
167 root,
168 );
169 yield* Effect.addFinalizer(() => Effect.sync(() => decoder.cancel()));
170 const decoded = new Promise<void>((resolve) =>
171 root.on("node-start", () => {
172 if (root.active[0]?.children.length === 1) resolve();
173 }),
174 );
175 input.enqueue(reset.event as StreamEvent);
176 yield* Effect.promise(() => decoded);
177 expect(root.active[0]?.value).toBe(7);
178 expect(root.active[0]?.children[0]?.text).toBe("Assets");
179 yield* write(socket, { type: "progress", streamId: "global", event: [0, { k: 1, v: 0 }] });
180 yield* until(late, (event) => event.type === "progress" && event.summary[0]?.value === 0);
181 yield* write(socket, { type: "progress", streamId: "global", event: [0, ["end", null]] });
182 yield* until(late, (event) => event.type === "remove");
183 }).pipe(Effect.scoped, Effect.provide(testLayer)),
184);
185
186it.effect("rejects unknown tokens and malformed producers without affecting another thread", () =>
187 Effect.gen(function* () {
188 const progress = yield* ScriptProgress.ScriptProgress;
189 const env = yield* progress.environmentForThread(threadId);
190 const wrong = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
191 yield* write(wrong, { type: "hello", producerId: "fixture", token: "wrong", pid: 12 });
192 yield* closed(wrong);
193 const malformed = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
194 yield* Effect.sync(() => malformed.write("not json\n"));
195 yield* closed(malformed);
196 const valid = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
197 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
198 const pull = Queue.take(queue);
199 yield* pull;
200 yield* write(valid, {
201 type: "hello",
202 producerId: "fixture",
203 token: env.T3_SCRIPT_PROGRESS_TOKEN,
204 pid: 13,
205 });
206 yield* write(valid, {
207 type: "progress",
208 streamId: "global",
209 event: [-1, { k: 1, t: "Good" }, [1]],
210 });
211 yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.text === "Good");
212 const otherQueue = yield* Stream.toQueue(progress.subscribe(otherThreadId), {
213 capacity: "unbounded",
214 });
215 const other = Queue.take(otherQueue);
216 expect(yield* other).toEqual({ type: "snapshot", streams: [] });
217 }).pipe(Effect.scoped, Effect.provide(testLayer)),
218);
219
220it.effect(
221 "coalesces a burst into current progress and labels disconnected producers as unknown",
222 () =>
223 Effect.gen(function* () {
224 const progress = yield* ScriptProgress.ScriptProgress;
225 const env = yield* progress.environmentForThread(threadId);
226 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
227 const pull = Queue.take(queue);
228 yield* pull;
229 const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
230 yield* write(socket, {
231 type: "hello",
232 producerId: "fixture",
233 token: env.T3_SCRIPT_PROGRESS_TOKEN,
234 pid: 14,
235 });
236 yield* write(socket, {
237 type: "progress",
238 streamId: "global",
239 event: [-1, { k: 1, t: "Burst", e: 1000 }, [1]],
240 });
241 yield* until(
242 pull,
243 (event) => event.type === "progress" && event.summary[0]?.text === "Burst",
244 );
245 yield* Effect.sync(() => {
246 socket.write(
247 Array.from({ length: 100 }, (_, i) =>
248 JSON.stringify({
249 type: "progress",
250 streamId: "global",
251 event: [0, { k: 1, v: i + 1 }],
252 }),
253 ).join("\n") + "\n",
254 );
255 });
256 yield* until(pull, (event) => event.type === "progress" && event.summary[0]?.value === 100);
257 yield* Effect.sync(() => socket.destroy());
258 const event = yield* until(
259 pull,
260 (event) => event.type === "upsert" && event.stream.status === "disconnected",
261 );
262 expect(event).toMatchObject({ type: "upsert", stream: { status: "disconnected" } });
263 const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), {
264 capacity: "unbounded",
265 });
266 const late = Queue.take(lateQueue);
267 expect(yield* late).toMatchObject({
268 type: "snapshot",
269 streams: [{ status: "disconnected", summary: [{ text: "Burst", value: 100 }] }],
270 });
271 }).pipe(Effect.scoped, Effect.provide(testLayer)),
272);
273
274it.effect("destroys oversized input without retaining a capture", () =>
275 Effect.gen(function* () {
276 const progress = yield* ScriptProgress.ScriptProgress;
277 const env = yield* progress.environmentForThread(threadId);
278 const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
279 yield* write(socket, {
280 type: "hello",
281 producerId: "fixture",
282 token: env.T3_SCRIPT_PROGRESS_TOKEN,
283 pid: 15,
284 });
285 yield* Effect.sync(() => socket.write("x".repeat(1024 * 1024 + 1)));
286 yield* closed(socket);
287 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
288 const pull = Queue.take(queue);
289 expect(yield* pull).toEqual({ type: "snapshot", streams: [] });
290 }).pipe(Effect.scoped, Effect.provide(testLayer)),
291);
292
293it.effect("bounds accumulated state without treating a broken producer as completed", () =>
294 Effect.gen(function* () {
295 const progress = yield* ScriptProgress.ScriptProgress;
296 const env = yield* progress.environmentForThread(threadId);
297 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
298 const pull = Queue.take(queue);
299 yield* pull;
300 const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
301 yield* write(socket, {
302 type: "hello",
303 producerId: "fixture",
304 token: env.T3_SCRIPT_PROGRESS_TOKEN,
305 pid: 16,
306 });
307 yield* write(socket, {
308 type: "progress",
309 streamId: "global",
310 event: [-1, { k: 1, t: "a".repeat(400_000) }, [1]],
311 });
312 yield* until(pull, (event) => event.type === "progress" && event.summary.length === 1);
313 yield* write(socket, {
314 type: "progress",
315 streamId: "global",
316 event: [
317 0,
318 { k: 1, c: [2, 3] },
319 { k: 2, t: "b".repeat(400_000) },
320 { k: 3, t: "c".repeat(400_000) },
321 ],
322 });
323 yield* until(pull, (event) => event.type === "remove");
324 yield* closed(socket);
325 const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), {
326 capacity: "unbounded",
327 });
328 expect(yield* Queue.take(lateQueue)).toEqual({ type: "snapshot", streams: [] });
329 }).pipe(Effect.scoped, Effect.provide(testLayer)),
330);
331
332it.effect("retains only the most recent sixteen disconnected roots for a thread", () =>
333 Effect.gen(function* () {
334 const progress = yield* ScriptProgress.ScriptProgress;
335 const env = yield* progress.environmentForThread(threadId);
336 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
337 const pull = Queue.take(queue);
338 yield* pull;
339 const producers: NodeNet.Socket[] = [];
340 for (let i = 0; i < 17; i++) {
341 const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
342 yield* write(socket, {
343 type: "hello",
344 producerId: `producer-${i}`,
345 token: env.T3_SCRIPT_PROGRESS_TOKEN,
346 pid: 100 + i,
347 });
348 yield* write(socket, {
349 type: "progress",
350 streamId: "global",
351 event: [-1, { k: 1, t: "Retained" }, [1]],
352 });
353 yield* until(pull, (event) => event.type === "upsert" && event.stream.status === "connected");
354 producers.push(socket);
355 }
356 for (const socket of producers) {
357 yield* Effect.sync(() => socket.destroy());
358 yield* until(
359 pull,
360 (event) => event.type === "upsert" && event.stream.status === "disconnected",
361 );
362 }
363 const lateQueue = yield* Stream.toQueue(progress.subscribe(threadId), {
364 capacity: "unbounded",
365 });
366 const snapshot = yield* Queue.take(lateQueue);
367 if (snapshot.type !== "snapshot") throw new Error("Expected snapshot");
368 expect(snapshot.streams).toHaveLength(16);
369 expect(snapshot.streams.map((capture) => capture.pid)).toEqual(
370 Array.from({ length: 16 }, (_, i) => 101 + i),
371 );
372 }).pipe(Effect.scoped, Effect.provide(testLayer)),
373);
374
375it.effect("closes the loopback listener when its application scope ends", () =>
376 Effect.gen(function* () {
377 const endpoint = yield* Effect.gen(function* () {
378 const progress = yield* ScriptProgress.ScriptProgress;
379 return (yield* progress.environmentForThread(threadId)).T3_SCRIPT_PROGRESS_ENDPOINT!;
380 }).pipe(Effect.provide(testLayer));
381 const code = yield* Effect.callback<string | undefined>((resume) => {
382 const socket = NodeNet.connect({ host: "127.0.0.1", port: Number(endpoint.split(":")[1]) });
383 socket.once("connect", () => resume(Effect.die("Progress listener outlived its scope")));
384 socket.once("error", (error: NodeJS.ErrnoException) => resume(Effect.succeed(error.code)));
385 return Effect.sync(() => socket.destroy());
386 });
387 expect(code).toBe("ECONNREFUSED");
388 }),
389);
390
391it.effect(
392 "replaces a reconnected root while preserving another producer with the same PID and another thread",
393 () =>
394 Effect.gen(function* () {
395 const progress = yield* ScriptProgress.ScriptProgress;
396 const env = yield* progress.environmentForThread(threadId);
397 const otherEnv = yield* progress.environmentForThread(otherThreadId);
398 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
399 const pull = Queue.take(queue);
400 yield* pull;
401 const original = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
402 yield* write(original, {
403 type: "hello",
404 token: env.T3_SCRIPT_PROGRESS_TOKEN,
405 producerId: "producer-a",
406 pid: 42,
407 });
408 yield* write(original, {
409 type: "progress",
410 streamId: "root",
411 event: [-1, { k: 1, t: "Original", v: 7 }, [1]],
412 });
413 const originalEvent = yield* until(
414 pull,
415 (event) => event.type === "progress" && event.summary[0]?.text === "Original",
416 );
417 if (originalEvent.type !== "progress") throw new Error("Expected original progress");
418
419 const worker = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
420 yield* write(worker, {
421 type: "hello",
422 token: env.T3_SCRIPT_PROGRESS_TOKEN,
423 producerId: "producer-b",
424 pid: 42,
425 });
426 yield* write(worker, {
427 type: "progress",
428 streamId: "root",
429 event: [-1, { k: 1, t: "Worker" }, [1]],
430 });
431 yield* until(
432 pull,
433 (event) => event.type === "progress" && event.summary[0]?.text === "Worker",
434 );
435 const otherQueue = yield* Stream.toQueue(progress.subscribe(otherThreadId), {
436 capacity: "unbounded",
437 });
438 const otherPull = Queue.take(otherQueue);
439 yield* otherPull;
440 const other = yield* connect(otherEnv.T3_SCRIPT_PROGRESS_ENDPOINT!);
441 yield* write(other, {
442 type: "hello",
443 token: otherEnv.T3_SCRIPT_PROGRESS_TOKEN,
444 producerId: "producer-a",
445 pid: 42,
446 });
447 yield* write(other, {
448 type: "progress",
449 streamId: "root",
450 event: [-1, { k: 1, t: "Other thread" }, [1]],
451 });
452 yield* until(
453 otherPull,
454 (event) => event.type === "progress" && event.summary[0]?.text === "Other thread",
455 );
456
457 yield* Effect.sync(() => original.destroy());
458 yield* until(
459 pull,
460 (event) =>
461 event.type === "upsert" &&
462 event.stream.id === originalEvent.id &&
463 event.stream.status === "disconnected",
464 );
465 const reconnected = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
466 yield* write(reconnected, {
467 type: "hello",
468 token: env.T3_SCRIPT_PROGRESS_TOKEN,
469 producerId: "producer-a",
470 pid: 42,
471 });
472 yield* write(reconnected, {
473 type: "progress",
474 streamId: "root",
475 event: [-1, { k: 1, t: "Reconnected", v: 8 }, [1]],
476 });
477 yield* until(
478 pull,
479 (event) => event.type === "progress" && event.summary[0]?.text === "Reconnected",
480 );
481 const late = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
482 const snapshot = yield* Queue.take(late);
483 if (snapshot.type !== "snapshot") throw new Error("Expected snapshot");
484 expect(snapshot.streams).toHaveLength(2);
485 expect(snapshot.streams.every((stream) => stream.status === "connected")).toBe(true);
486 expect(snapshot.streams.map((stream) => stream.summary[0]?.text)).toEqual([
487 "Worker",
488 "Reconnected",
489 ]);
490 expect(snapshot.streams.some((stream) => stream.id === originalEvent.id)).toBe(false);
491 const otherLate = yield* Stream.toQueue(progress.subscribe(otherThreadId), {
492 capacity: "unbounded",
493 });
494 expect(yield* Queue.take(otherLate)).toMatchObject({
495 type: "snapshot",
496 streams: [{ status: "connected", summary: [{ text: "Other thread" }] }],
497 });
498 }).pipe(Effect.scoped, Effect.provide(testLayer)),
499);
500
501it.effect("uses the library visibility rule for native summaries", () =>
502 Effect.gen(function* () {
503 const progress = yield* ScriptProgress.ScriptProgress;
504 const env = yield* progress.environmentForThread(threadId);
505 const queue = yield* Stream.toQueue(progress.subscribe(threadId), { capacity: "unbounded" });
506 const pull = Queue.take(queue);
507 yield* pull;
508 const socket = yield* connect(env.T3_SCRIPT_PROGRESS_ENDPOINT!);
509 yield* write(socket, {
510 type: "hello",
511 token: env.T3_SCRIPT_PROGRESS_TOKEN,
512 producerId: "visibility",
513 pid: 45,
514 });
515 yield* write(socket, {
516 type: "progress",
517 streamId: "root",
518 event: [
519 -1,
520 { k: 1, t: "Empty group", p: true },
521 { k: 2, t: "Hidden", h: true },
522 { k: 3, t: "Visible", v: 2, e: 5 },
523 [1, 2, 3],
524 ],
525 });
526 const event = yield* until(
527 pull,
528 (event) => event.type === "progress" && event.summary.length > 0,
529 );
530 expect(event).toMatchObject({
531 type: "progress",
532 summary: [{ text: "Visible", value: 2, total: 5 }],
533 });
534 if (event.type !== "progress") throw new Error("Expected progress");
535 expect(event.summary).toHaveLength(1);
536 }).pipe(Effect.scoped, Effect.provide(testLayer)),
537);
apps/server/src/progress/ScriptProgress.ts created+374
...@@ -0,0 +1,374 @@
1// @effect-diagnostics nodeBuiltinImport:off -- Socket callbacks allocate opaque capture identifiers synchronously.
2import * as NodeNet from "node:net";
3import * as NodeCrypto from "node:crypto";
4import {
5 Root,
6 decodeEventStream,
7 encodeEventStream,
8 isVisible,
9 type StreamEvent,
10} from "@clo/lib/progress";
11import {
12 ScriptProgressStreamError,
13 type ScriptProgressEvent,
14 type ScriptProgressStream,
15 type ThreadId,
16} from "@t3tools/contracts";
17import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
18import { progressPreloadSource } from "@t3tools/shared/progressPreload";
19import * as Cause from "effect/Cause";
20import * as Effect from "effect/Effect";
21import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";
23import * as Path from "effect/Path";
24import * as Queue from "effect/Queue";
25import * as Schema from "effect/Schema";
26import * as Stream from "effect/Stream";
27
28import * as ServerConfig from "../config.ts";
29
30export class ScriptProgressSetupError extends Schema.TaggedError<ScriptProgressSetupError>()(
31 "ScriptProgressSetupError",
32 { operation: Schema.Literals(["preload", "listen"]), cause: Schema.Defect() },
33) {
34 override get message(): string {
35 return "Unable to prepare script progress capture.";
36 }
37}
38
39const Hello = Schema.Struct({
40 type: Schema.Literal("hello"),
41 token: Schema.String.check(Schema.isMaxLength(128)),
42 pid: Schema.Int.check(Schema.isGreaterThan(0)),
43 producerId: Schema.String.check(Schema.isMinLength(1)).check(Schema.isMaxLength(128)),
44 label: Schema.optional(Schema.String.check(Schema.isMaxLength(256))),
45});
46const Progress = Schema.Struct({
47 type: Schema.Literal("progress"),
48 streamId: Schema.String.check(Schema.isMaxLength(128)),
49 event: Schema.Array(Schema.Json).check(Schema.isMaxLength(4096)),
50});
51const decodeHello = Schema.decodeUnknownSync(Hello);
52const decodeProgress = Schema.decodeUnknownSync(Progress);
53const decodePacket = Schema.decodeSync(Schema.fromJsonString(Schema.Array(Schema.Json)));
54const encodePreloadPath = Schema.encodeSync(Schema.fromJsonString(Schema.String));
55
56type Capture = {
57 readonly id: string;
58 readonly threadId: ThreadId;
59 readonly pid: number;
60 readonly producerId: string;
61 readonly streamId: string;
62 readonly socket: NodeNet.Socket;
63 readonly label: string;
64 readonly root: Root;
65 readonly input: ReadableStreamDefaultController<StreamEvent>;
66 readonly decoder: ReturnType<typeof decodeEventStream<void>>;
67 status: "connected" | "disconnected";
68};
69
70type Subscriber = {
71 readonly threadId: ThreadId;
72 readonly send: (event: ScriptProgressEvent) => boolean;
73 readonly attach: (capture: Capture) => void;
74 readonly detach: (id: string) => void;
75};
76
77const describe = (capture: Capture): ScriptProgressStream => ({
78 id: capture.id,
79 pid: capture.pid,
80 label: capture.label,
81 status: capture.status,
82 summary: capture.root.active.filter(isVisible).map((node) => ({
83 text: node.text,
84 value: node.value,
85 total: node.total,
86 detail: node.showTotal ? node.valueFormatter(node.value, node.total || null) : "",
87 })),
88});
89
90const make = Effect.gen(function* () {
91 const fileSystem = yield* FileSystem.FileSystem;
92 const path = yield* Path.Path;
93 const config = yield* ServerConfig.ServerConfig;
94 const runtimeDirectory = path.join(config.stateDir, "runtime");
95 const preloadPath = path.join(runtimeDirectory, "script-progress-preload.cjs");
96 yield* fileSystem.makeDirectory(runtimeDirectory, { recursive: true }).pipe(
97 Effect.andThen(fileSystem.writeFileString(preloadPath, progressPreloadSource, { mode: 0o600 })),
98 Effect.mapError((cause) => new ScriptProgressSetupError({ operation: "preload", cause })),
99 );
100
101 const tokens = new Map<ThreadId, string>();
102 const tokenThreads = new Map<string, ThreadId>();
103 const captures = new Map<string, Capture>();
104 const subscribers = new Set<Subscriber>();
105 const sockets = new Set<NodeNet.Socket>();
106 let closed = false;
107
108 const forget = (capture: Capture) => {
109 captures.delete(capture.id);
110 for (const subscriber of subscribers) {
111 if (subscriber.threadId !== capture.threadId) continue;
112 subscriber.detach(capture.id);
113 subscriber.send({ type: "remove", id: capture.id });
114 }
115 capture.decoder.cancel();
116 };
117
118 const server = NodeNet.createServer((socket) => {
119 if (closed || sockets.size >= 256) {
120 socket.destroy();
121 return;
122 }
123 sockets.add(socket);
124 socket.setEncoding("utf8");
125 socket.setTimeout(10_000);
126 let pending = "";
127 let hello: typeof Hello.Type | undefined;
128 let threadId: ThreadId | undefined;
129 let pendingPackets = 0;
130 const roots = new Map<string, Capture>();
131 socket.on("error", () => socket.destroy());
132 socket.on("timeout", () => socket.destroy());
133 socket.on("close", () => {
134 sockets.delete(socket);
135 if (closed) return;
136 for (const capture of roots.values()) {
137 if (!captures.has(capture.id)) continue;
138 capture.status = "disconnected";
139 captures.delete(capture.id);
140 captures.set(capture.id, capture);
141 for (const subscriber of subscribers) {
142 if (subscriber.threadId === capture.threadId) {
143 subscriber.send({ type: "upsert", stream: describe(capture) });
144 }
145 }
146 }
147 const disconnected = [...captures.values()].filter(
148 (capture) => capture.threadId === threadId && capture.status === "disconnected",
149 );
150 for (const capture of disconnected.slice(0, -16)) forget(capture);
151 });
152 socket.on("data", (chunk: string) => {
153 if (socket.destroyed) return;
154 pending += chunk;
155 if (Buffer.byteLength(pending) > 1024 * 1024) {
156 socket.destroy();
157 return;
158 }
159 try {
160 let newline: number;
161 while ((newline = pending.indexOf("\n")) !== -1) {
162 const line = pending.slice(0, newline);
163 pending = pending.slice(newline + 1);
164 const message: unknown = JSON.parse(line);
165 if (!hello) {
166 hello = decodeHello(message);
167 threadId = tokenThreads.get(hello.token);
168 if (!threadId) throw new Error("Unknown progress token");
169 socket.setTimeout(0);
170 continue;
171 }
172 const packet = decodeProgress(message);
173 if (!threadId || ++pendingPackets > 256) throw new Error("Progress input overflow");
174 let capture = roots.get(packet.streamId);
175 if (!capture) {
176 if (roots.size >= 16) throw new Error("Too many progress roots");
177 for (const previous of captures.values()) {
178 if (
179 previous.threadId !== threadId ||
180 previous.producerId !== hello.producerId ||
181 previous.streamId !== packet.streamId
182 )
183 continue;
184 forget(previous);
185 previous.socket.destroy();
186 }
187 const threadCaptures = [...captures.values()].filter(
188 (item) => item.threadId === threadId,
189 );
190 if (threadCaptures.filter((item) => item.status === "connected").length >= 64) {
191 throw new Error("Too many active progress roots");
192 }
193 if (captures.size >= 512) {
194 const oldest = [...captures.values()].find((item) => item.status === "disconnected");
195 if (!oldest) throw new Error("Too many progress captures");
196 forget(oldest);
197 }
198 const root = new Root();
199 let input!: ReadableStreamDefaultController<StreamEvent>;
200 const source = new ReadableStream<StreamEvent>({
201 start(controller) {
202 input = controller;
203 },
204 pull() {
205 pendingPackets = Math.max(0, pendingPackets - 1);
206 },
207 });
208 const decoder = decodeEventStream(source, root);
209 capture = {
210 id: NodeCrypto.randomUUID(),
211 threadId,
212 pid: hello.pid,
213 producerId: hello.producerId,
214 streamId: packet.streamId,
215 socket,
216 label: hello.label ?? "node",
217 root,
218 input,
219 decoder,
220 status: "connected",
221 };
222 const activeCapture = capture;
223 const checkRetainedState = () => {
224 const stack = [...root.active];
225 let count = 0;
226 let bytes = 0;
227 while (stack.length > 0) {
228 const node = stack.pop();
229 if (!node) break;
230 bytes +=
231 Buffer.byteLength(node.text) +
232 Buffer.byteLength(JSON.stringify(node.logs)) +
233 Buffer.byteLength(node.valueFormatter(node.value, node.total || null));
234 if (++count > 4096 || bytes > 1024 * 1024) {
235 forget(activeCapture);
236 roots.delete(packet.streamId);
237 socket.destroy();
238 return;
239 }
240 stack.push(...node.children);
241 }
242 };
243 root.on("change", checkRetainedState);
244 void decoder
245 .then(() => {
246 roots.delete(packet.streamId);
247 forget(activeCapture);
248 })
249 .catch(() => {
250 if (activeCapture.status === "connected") socket.destroy();
251 });
252 roots.set(packet.streamId, capture);
253 captures.set(capture.id, capture);
254 for (const subscriber of subscribers) {
255 if (subscriber.threadId !== threadId) continue;
256 subscriber.send({ type: "upsert", stream: describe(capture) });
257 subscriber.attach(capture);
258 }
259 }
260 // The library decoder owns protocol validation and state transitions.
261 capture.input.enqueue(packet.event as StreamEvent);
262 }
263 } catch {
264 socket.destroy();
265 }
266 });
267 });
268
269 const address = yield* Effect.acquireRelease(
270 Effect.callback<NodeNet.AddressInfo, ScriptProgressSetupError>((resume) => {
271 const onError = (cause: Error) =>
272 resume(Effect.fail(new ScriptProgressSetupError({ operation: "listen", cause })));
273 server.once("error", onError);
274 server.listen(0, "127.0.0.1", () => {
275 server.off("error", onError);
276 const address = server.address();
277 if (address && typeof address !== "string") resume(Effect.succeed(address));
278 });
279 return Effect.sync(() => server.close());
280 }),
281 () =>
282 Effect.sync(() => {
283 closed = true;
284 for (const socket of sockets) socket.destroy();
285 for (const capture of captures.values()) capture.decoder.cancel();
286 tokens.clear();
287 tokenThreads.clear();
288 server.close();
289 }),
290 );
291
292 return ScriptProgress.ScriptProgress.of({
293 environmentForThread: (threadId) =>
294 Effect.sync(() => {
295 let token = tokens.get(threadId);
296 if (!token) {
297 token = NodeCrypto.randomBytes(32).toString("hex");
298 tokens.set(threadId, token);
299 tokenThreads.set(token, threadId);
300 }
301 return {
302 T3_SCRIPT_PROGRESS_ENDPOINT: `127.0.0.1:${address.port}`,
303 T3_SCRIPT_PROGRESS_TOKEN: token,
304 NODE_OPTIONS: `--require=${encodePreloadPath(preloadPath)}`,
305 };
306 }),
307 subscribe: (threadId) =>
308 Stream.callback<ScriptProgressEvent, ScriptProgressStreamError>(
309 (queue) =>
310 Effect.acquireRelease(
311 Effect.sync(() => {
312 const readers = new Map<string, ReadableStreamDefaultReader<StreamEvent>>();
313 let disposed = false;
314 const send = (event: ScriptProgressEvent) => {
315 if (disposed) return false;
316 if (Queue.offerUnsafe(queue, event)) return true;
317 Queue.failCauseUnsafe(queue, Cause.fail(new ScriptProgressStreamError({})));
318 return false;
319 };
320 const subscriber: Subscriber = {
321 threadId,
322 send,
323 attach: (capture) => {
324 const reader = encodeEventStream(capture.root, { throttleMs: 100 }).getReader();
325 readers.set(capture.id, reader);
326 void (async () => {
327 try {
328 while (readers.get(capture.id) === reader) {
329 const result = await reader.read();
330 if (result.done) break;
331 if (
332 !send({
333 type: "progress",
334 id: capture.id,
335 event: decodePacket(JSON.stringify(result.value)),
336 summary: describe(capture).summary,
337 })
338 )
339 break;
340 }
341 } catch {
342 // Disconnect is represented by the descriptor, never success.
343 } finally {
344 await reader.cancel().catch(() => {});
345 reader.releaseLock();
346 }
347 })();
348 },
349 detach: (id) => {
350 const reader = readers.get(id);
351 readers.delete(id);
352 void reader?.cancel().catch(() => {});
353 },
354 };
355 subscribers.add(subscriber);
356 const existing = [...captures.values()].filter(
357 (capture) => capture.threadId === threadId,
358 );
359 send({ type: "snapshot", streams: existing.map(describe) });
360 for (const capture of existing) subscriber.attach(capture);
361 return () => {
362 disposed = true;
363 subscribers.delete(subscriber);
364 for (const id of readers.keys()) subscriber.detach(id);
365 };
366 }),
367 (dispose) => Effect.sync(dispose),
368 ),
369 { bufferSize: 256 },
370 ),
371 });
372});
373
374export const layer = Layer.effect(ScriptProgress.ScriptProgress, make);
apps/server/src/provider/Drivers/AntigravityDriver.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import {4import {
...@@ -267,7 +268,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {...@@ -267,7 +268,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {
267 ),268 ),
268 Layer.provideMerge(ModelManifest.layerTest),269 Layer.provideMerge(ModelManifest.layerTest),
269 Layer.provideMerge(IdAllocator.layer),270 Layer.provideMerge(IdAllocator.layer),
270 Layer.provideMerge(McpProviderSessions.layer),271 Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
271);272);
272const layerTest = ProviderHostLive.layer.pipe(273const layerTest = ProviderHostLive.layer.pipe(
273 Layer.provideMerge(ServerSecretStore.layer),274 Layer.provideMerge(ServerSecretStore.layer),
apps/server/src/provider/Drivers/AntigravityDriver.ts+6-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession";2import { withAgentDeviceEnvironment } from "@t3tools/provider-core/server/mcpSession";
2import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";3import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions";
3import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";4import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts";
...@@ -78,6 +79,7 @@ export type AntigravityDriverEnv =...@@ -78,6 +79,7 @@ export type AntigravityDriverEnv =
78 | FileSystem.FileSystem79 | FileSystem.FileSystem
79 | HttpClient.HttpClient80 | HttpClient.HttpClient
80 | IdAllocator.IdAllocatorV281 | IdAllocator.IdAllocatorV2
82 | ScriptProgress.ScriptProgress
81 | McpProviderSessions.McpProviderSessions83 | McpProviderSessions.McpProviderSessions
82 | ModelCatalog.ModelCatalog84 | ModelCatalog.ModelCatalog
83 | Path.Path85 | Path.Path
...@@ -249,7 +251,10 @@ export const AntigravityDriver: ProviderDriver<...@@ -249,7 +251,10 @@ export const AntigravityDriver: ProviderDriver<
249 installation: executable,251 installation: executable,
250 profile,252 profile,
251 cwd: input.cwd,253 cwd: input.cwd,
252 baseEnv: withAgentDeviceEnvironment(processEnvironment, input),254 baseEnv: ScriptProgress.withScriptProgressEnvironment(
255 withAgentDeviceEnvironment(processEnvironment, input),
256 input.processEnvironment ?? {},
257 ),
253 auth,258 auth,
254 runtimeTempDirectory,259 runtimeTempDirectory,
255 }),260 }),
apps/server/src/provider/Drivers/CodexDriver.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as CodexInstallation from "../CodexInstallation.ts";2import * as CodexInstallation from "../CodexInstallation.ts";
2import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";3import * as ServerSecretStore from "../../auth/ServerSecretStore.ts";
3import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";4import * as ServerEnvironment from "../../environment/ServerEnvironment.ts";
...@@ -46,7 +47,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {...@@ -46,7 +47,7 @@ const layerDeps = ServerConfig.layerTest(process.cwd(), {
46}).pipe(47}).pipe(
47 Layer.provideMerge(NodeServices.layer),48 Layer.provideMerge(NodeServices.layer),
48 Layer.provideMerge(IdAllocator.layer),49 Layer.provideMerge(IdAllocator.layer),
49 Layer.provideMerge(McpProviderSessions.layer),50 Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
50 Layer.provideMerge(51 Layer.provideMerge(
51 Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({52 Layer.mock(CodexAdapterV2.CodexAppServerClientFactory)({
52 open: () => Effect.die("Maintenance resolution must not open a Codex session"),53 open: () => Effect.die("Maintenance resolution must not open a Codex session"),
apps/server/src/provider/ProviderInstanceRegistry.test.ts+3-2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";2import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "./CodexInstallation.ts";3import * as CodexInstallation from "./CodexInstallation.ts";
3import * as ServerEnvironment from "../environment/ServerEnvironment.ts";4import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
...@@ -233,7 +234,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {...@@ -233,7 +234,7 @@ describe("ProviderInstanceRegistry — multi-instance codex slice", () => {
233 ),234 ),
234 ),235 ),
235 Layer.provideMerge(ProviderLatestVersions.layer),236 Layer.provideMerge(ProviderLatestVersions.layer),
236 Layer.provideMerge(McpProviderSessions.layer),237 Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
237 Layer.provideMerge(ModelManifest.layerTest),238 Layer.provideMerge(ModelManifest.layerTest),
238 Layer.provideMerge(ResetCreditCoordinator.layerTest),239 Layer.provideMerge(ResetCreditCoordinator.layerTest),
239 );240 );
...@@ -652,7 +653,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => {...@@ -652,7 +653,7 @@ describe("ProviderInstanceRegistry — all drivers slice", () => {
652 ),653 ),
653 ),654 ),
654 Layer.provideMerge(ProviderLatestVersions.layer),655 Layer.provideMerge(ProviderLatestVersions.layer),
655 Layer.provideMerge(McpProviderSessions.layer),656 Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
656 Layer.provideMerge(ModelManifest.layerTest),657 Layer.provideMerge(ModelManifest.layerTest),
657 Layer.provideMerge(ResetCreditCoordinator.layerTest),658 Layer.provideMerge(ResetCreditCoordinator.layerTest),
658 );659 );
apps/server/src/provider/ProviderRegistry.test.ts+13-4
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as ServerSecretStore from "../auth/ServerSecretStore.ts";2import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
2import * as CodexInstallation from "./CodexInstallation.ts";3import * as CodexInstallation from "./CodexInstallation.ts";
3import * as ServerEnvironment from "../environment/ServerEnvironment.ts";4import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
...@@ -2762,7 +2763,9 @@ it.layer(...@@ -2762,7 +2763,9 @@ it.layer(
2762 ),2763 ),
2763 ),2764 ),
2764 Layer.provideMerge(ProviderLatestVersions.layer),2765 Layer.provideMerge(ProviderLatestVersions.layer),
2765 Layer.provideMerge(McpProviderSessions.layer),2766 Layer.provideMerge(
2767 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
2768 ),
2766 Layer.provideMerge(ModelManifest.layerTest),2769 Layer.provideMerge(ModelManifest.layerTest),
2767 Layer.provideMerge(ResetCreditCoordinator.layerTest),2770 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2768 Layer.provideMerge(2771 Layer.provideMerge(
...@@ -2867,7 +2870,9 @@ it.layer(...@@ -2867,7 +2870,9 @@ it.layer(
2867 ),2870 ),
2868 ),2871 ),
2869 Layer.provideMerge(ProviderLatestVersions.layer),2872 Layer.provideMerge(ProviderLatestVersions.layer),
2870 Layer.provideMerge(McpProviderSessions.layer),2873 Layer.provideMerge(
2874 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
2875 ),
2871 Layer.provideMerge(ModelManifest.layerTest),2876 Layer.provideMerge(ModelManifest.layerTest),
2872 Layer.provideMerge(ResetCreditCoordinator.layerTest),2877 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2873 Layer.provideMerge(2878 Layer.provideMerge(
...@@ -2987,7 +2992,9 @@ it.layer(...@@ -2987,7 +2992,9 @@ it.layer(
2987 ),2992 ),
2988 ),2993 ),
2989 Layer.provideMerge(ProviderLatestVersions.layer),2994 Layer.provideMerge(ProviderLatestVersions.layer),
2990 Layer.provideMerge(McpProviderSessions.layer),2995 Layer.provideMerge(
2996 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
2997 ),
2991 Layer.provideMerge(ModelManifest.layerTest),2998 Layer.provideMerge(ModelManifest.layerTest),
2992 Layer.provideMerge(ResetCreditCoordinator.layerTest),2999 Layer.provideMerge(ResetCreditCoordinator.layerTest),
2993 Layer.provideMerge(3000 Layer.provideMerge(
...@@ -3047,7 +3054,9 @@ it.layer(...@@ -3047,7 +3054,9 @@ it.layer(
3047 ),3054 ),
3048 ),3055 ),
3049 Layer.provideMerge(ProviderLatestVersions.layer),3056 Layer.provideMerge(ProviderLatestVersions.layer),
3050 Layer.provideMerge(McpProviderSessions.layer),3057 Layer.provideMerge(
3058 Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable),
3059 ),
3051 Layer.provideMerge(ModelManifest.layerTest),3060 Layer.provideMerge(ModelManifest.layerTest),
3052 Layer.provideMerge(ResetCreditCoordinator.layerTest),3061 Layer.provideMerge(ResetCreditCoordinator.layerTest),
3053 Layer.provideMerge(3062 Layer.provideMerge(
apps/server/src/provider/acp/AntigravityAcpSupport.ts+1
...@@ -36,6 +36,7 @@ export interface AntigravityAcpRuntimeInput extends Omit<...@@ -36,6 +36,7 @@ export interface AntigravityAcpRuntimeInput extends Omit<
36 | "transformSessionUpdate"36 | "transformSessionUpdate"
37 | "transformStdout"37 | "transformStdout"
38> {38> {
39 readonly processEnvironment?: NodeJS.ProcessEnv;
39 /** Device CLI environment supplied for this provider session. */40 /** Device CLI environment supplied for this provider session. */
40 readonly agentDeviceEnvironment?: Readonly<Record<string, string>>;41 readonly agentDeviceEnvironment?: Readonly<Record<string, string>>;
41 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];42 readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"];
apps/server/src/server.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "./progress/ScriptProgress.ts";
1import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";2import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers";
2import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";3import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
3import * as Clock from "effect/Clock";4import * as Clock from "effect/Clock";
...@@ -1116,6 +1117,7 @@ const layerMakeServer = Layer.unwrap(...@@ -1116,6 +1117,7 @@ const layerMakeServer = Layer.unwrap(
1116 Layer.provide(VcsProcess.layer),1117 Layer.provide(VcsProcess.layer),
1117 // Every agent and terminal spawn reads this, so it sits below everything.1118 // Every agent and terminal spawn reads this, so it sits below everything.
1118 Layer.provideMerge(AgentScopeLive.layer),1119 Layer.provideMerge(AgentScopeLive.layer),
1120 Layer.provideMerge(ScriptProgress.layer),
1119 Layer.provideMerge(layerPlatformServices),1121 Layer.provideMerge(layerPlatformServices),
1120 );1122 );
1121 }),1123 }),
apps/server/src/terminal/Manager.progress.test.ts created+114
...@@ -0,0 +1,114 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { DEFAULT_TERMINAL_ID, ThreadId } from "@t3tools/contracts";
4import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
5import * as PtyAdapter from "@t3tools/shared/PtyAdapter";
6import * as Deferred from "effect/Deferred";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Layer from "effect/Layer";
10import * as Path from "effect/Path";
11import * as Stream from "effect/Stream";
12import * as ProcessRunner from "../processRunner.ts";
13import * as Manager from "./Manager.ts";
14import * as NodePtyAdapter from "./NodePtyAdapter.ts";
15
16it.live(
17 "injects progress after terminal filtering and keeps concurrent shells and their children isolated",
18 () =>
19 Effect.gen(function* () {
20 const fs = yield* FileSystem.FileSystem;
21 const path = yield* Path.Path;
22 const directory = yield* fs.makeTempDirectoryScoped({ prefix: "t3-progress-terminal-" });
23 const preload = path.join(directory, "progress preload.mjs");
24 const userPreload = path.join(directory, "user.mjs");
25 const script = path.join(directory, "probe.mjs");
26 yield* fs.writeFileString(preload, "globalThis.progressPreloaded = true;");
27 yield* fs.writeFileString(userPreload, "globalThis.userPreloaded = true;");
28 yield* fs.writeFileString(
29 script,
30 `
31 import { execFileSync } from 'node:child_process';
32 const child = execFileSync(process.execPath, ['--input-type=module', '-e',
33 'console.log(JSON.stringify({ token: process.env.T3_SCRIPT_PROGRESS_TOKEN, nativeThread: process.env.CODEX_THREAD_ID, progress: globalThis.progressPreloaded, user: globalThis.userPreloaded }))'
34 ], { encoding: 'utf8' });
35 console.log('PROGRESS_RESULT:' + child.trim());
36 `,
37 );
38 const originalOptions = process.env.NODE_OPTIONS;
39 const completed = new Map([
40 ["progress-terminal-one", yield* Deferred.make<string>()],
41 ["progress-terminal-two", yield* Deferred.make<string>()],
42 ]);
43 const output = new Map<string, string>();
44 const manager = yield* Manager.makeWithOptions({
45 logsDir: path.join(directory, "logs"),
46 ptyAdapter: yield* PtyAdapter.PtyAdapter,
47 shellResolver: () => "/bin/sh",
48 env: { ...process.env, NODE_OPTIONS: `--import=${JSON.stringify(userPreload)}` },
49 subprocessInspector: () =>
50 Effect.succeed({ hasRunningSubprocess: false, childCommand: null, processIds: [] }),
51 }).pipe(
52 Effect.provideService(ScriptProgress.ScriptProgress, {
53 environmentForThread: (threadId) =>
54 Effect.succeed({
55 NODE_OPTIONS: `--import=${JSON.stringify(preload)}`,
56 T3_SCRIPT_PROGRESS_ENDPOINT: "http://127.0.0.1:1/progress",
57 T3_SCRIPT_PROGRESS_TOKEN: threadId,
58 }),
59 subscribe: () => Stream.empty,
60 }),
61 );
62 const unsubscribe = yield* manager.subscribe((event) => {
63 if (event.type !== "output") return Effect.void;
64 const data = (output.get(event.threadId) ?? "") + event.data;
65 output.set(event.threadId, data);
66 const result = /PROGRESS_RESULT:([^\r\n]+)[\r\n]/.exec(data);
67 const done = completed.get(event.threadId);
68 return result && done
69 ? Deferred.succeed(done, result[1]!).pipe(Effect.asVoid)
70 : Effect.void;
71 });
72 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
73 yield* Effect.all(
74 [...completed].map(([threadId, done]) =>
75 Effect.gen(function* () {
76 yield* manager.open({
77 threadId,
78 terminalId: DEFAULT_TERMINAL_ID,
79 cwd: directory,
80 cols: 120,
81 rows: 24,
82 env: { CODEX_THREAD_ID: "native-child" },
83 });
84 const quote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
85 yield* manager.write({
86 threadId,
87 terminalId: DEFAULT_TERMINAL_ID,
88 data: `${quote(process.execPath)} ${quote(script)}\n`,
89 });
90 const result = JSON.parse(yield* Deferred.await(done));
91 expect(result).toEqual({
92 token: ThreadId.make(threadId),
93 nativeThread: "native-child",
94 progress: true,
95 user: true,
96 });
97 yield* manager.close({ threadId });
98 }),
99 ),
100 { concurrency: "unbounded" },
101 );
102 expect(process.env.NODE_OPTIONS).toBe(originalOptions);
103 }).pipe(
104 Effect.timeout("15 seconds"),
105 Effect.scoped,
106 Effect.provide(
107 Layer.mergeAll(
108 NodeServices.layer,
109 NodePtyAdapter.layer.pipe(Layer.provide(NodeServices.layer)),
110 ProcessRunner.layer.pipe(Layer.provide(NodeServices.layer)),
111 ),
112 ),
113 ),
114);
apps/server/src/terminal/Manager.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
3import {4import {
...@@ -297,7 +298,7 @@ const createManager = (...@@ -297,7 +298,7 @@ const createManager = (
297 managedBinaryCacheDir: options.managedBinaryCacheDir,298 managedBinaryCacheDir: options.managedBinaryCacheDir,
298 managedBinaryToolsDir: options.managedBinaryToolsDir,299 managedBinaryToolsDir: options.managedBinaryToolsDir,
299 }),300 }),
300 });301 }).pipe(Effect.provide(ScriptProgress.layerUnavailable));
301 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);302 const eventsRef = yield* Ref.make<ReadonlyArray<TerminalEvent>>([]);
302 const unsubscribe = yield* manager.subscribe((event) =>303 const unsubscribe = yield* manager.subscribe((event) =>
303 Ref.update(eventsRef, (events) => [...events, event]),304 Ref.update(eventsRef, (events) => [...events, event]),
apps/server/src/terminal/Manager.ts+11-5
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1/**2/**
2 * TerminalManager - Terminal session orchestration service interface.3 * TerminalManager - Terminal session orchestration service interface.
3 *4 *
...@@ -9,6 +10,7 @@...@@ -9,6 +10,7 @@
9import { withWorkspaceLease } from "../workspace/workspaceLease.ts";10import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
10import {11import {
11 DEFAULT_TERMINAL_ID,12 DEFAULT_TERMINAL_ID,
13 ThreadId,
12 TerminalCwdError,14 TerminalCwdError,
13 TerminalCwdNotDirectoryError,15 TerminalCwdNotDirectoryError,
14 TerminalCwdNotFoundError,16 TerminalCwdNotFoundError,
...@@ -1491,6 +1493,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -1491,6 +1493,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
1491 const platform = yield* HostProcess.Platform;1493 const platform = yield* HostProcess.Platform;
1492 const architecture = yield* HostProcess.Architecture;1494 const architecture = yield* HostProcess.Architecture;
1493 const agentScope = yield* AgentScope;1495 const agentScope = yield* AgentScope;
1496 const scriptProgress = yield* ScriptProgress.ScriptProgress;
1494 // Terminals must inherit the user's full environment (minus the blocklist1497 // Terminals must inherit the user's full environment (minus the blocklist
1495 // applied in createTerminalSpawnEnv) — an allowlist here silently strips1498 // applied in createTerminalSpawnEnv) — an allowlist here silently strips
1496 // things like PSModulePath, DISPLAY, proxies, and toolchain variables.1499 // things like PSModulePath, DISPLAY, proxies, and toolchain variables.
...@@ -2263,11 +2266,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2263,11 +2266,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2263 Effect.andThen(2266 Effect.andThen(
2264 Effect.gen(function* () {2267 Effect.gen(function* () {
2265 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);2268 const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv);
2266 const terminalEnv = createTerminalSpawnEnv(2269 const terminalEnv = ScriptProgress.withScriptProgressEnvironment(
2267 baseEnv,2270 createTerminalSpawnEnv(
2268 session.runtimeEnv,2271 baseEnv,
2269 platform,2272 session.runtimeEnv,
2270 yield* HostProcess.HomeDirectory,2273 platform,
2274 yield* HostProcess.HomeDirectory,
2275 ),
2276 yield* scriptProgress.environmentForThread(ThreadId.make(session.threadId)),
2271 );2277 );
2272 // Append (never prepend) managed ACP agent install directories so2278 // Append (never prepend) managed ACP agent install directories so
2273 // `kimi login` and friends resolve by name without shadowing any2279 // `kimi login` and friends resolve by name without shadowing any
apps/server/src/ws.ts+3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { OrchestrationDispatchCommandError } from "@t3tools/contracts";2import { OrchestrationDispatchCommandError } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";3import * as Crypto from "effect/Crypto";
3import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";4import * as Orchestrator from "./orchestration-v2/Orchestrator.ts";
...@@ -1239,6 +1240,7 @@ const layerWsRpc = (...@@ -1239,6 +1240,7 @@ const layerWsRpc = (
1239 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;1240 const providerSessionManager = yield* ProviderSessionManager.ProviderSessionManagerV2;
1240 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;1241 const scheduledTasks = yield* ScheduledTasks.ScheduledTaskService;
1241 const secretRequests = yield* SecretRequests.SecretRequests;1242 const secretRequests = yield* SecretRequests.SecretRequests;
1243 const scriptProgress = yield* ScriptProgress.ScriptProgress;
1242 const pullRequests = yield* PullRequestService.PullRequestService;1244 const pullRequests = yield* PullRequestService.PullRequestService;
1243 const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;1245 const reviewGuides = yield* PullRequestReviewGuideService.PullRequestReviewGuideService;
1244 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;1246 const pullRequestSync = yield* PullRequestSyncReactor.PullRequestSyncReactor;
...@@ -2902,6 +2904,7 @@ const layerWsRpc = (...@@ -2902,6 +2904,7 @@ const layerWsRpc = (
2902 [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input),2904 [WS_METHODS.reviewGetDiffPreview]: (input) => review.getDiffPreview(input),
2903 [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input),2905 [WS_METHODS.reviewGetDiffFileContents]: (input) => review.getDiffFileContents(input),
2904 ...makeTerminalRpcHandlers(terminalManager),2906 ...makeTerminalRpcHandlers(terminalManager),
2907 [WS_METHODS.subscribeScriptProgress]: (input) => scriptProgress.subscribe(input.threadId),
2905 [WS_METHODS.subscribeTerminalMetadata]: (_input) =>2908 [WS_METHODS.subscribeTerminalMetadata]: (_input) =>
2906 Stream.callback<TerminalMetadataStreamEvent>((queue) =>2909 Stream.callback<TerminalMetadataStreamEvent>((queue) =>
2907 Effect.acquireRelease(2910 Effect.acquireRelease(
apps/web/package.json+1
...@@ -14,6 +14,7 @@...@@ -14,6 +14,7 @@
14 "@base-ui/react": "^1.4.1",14 "@base-ui/react": "^1.4.1",
15 "@clerk/electron": "catalog:",15 "@clerk/electron": "catalog:",
16 "@clerk/react": "catalog:",16 "@clerk/react": "catalog:",
17 "@clo/lib": "workspace:*",
17 "@clo/markdown-editor": "workspace:*",18 "@clo/markdown-editor": "workspace:*",
18 "@clo/terminal": "workspace:*",19 "@clo/terminal": "workspace:*",
19 "@codemirror/commands": "^6.8.1",20 "@codemirror/commands": "^6.8.1",
apps/web/src/components/ChatView.tsx+7
...@@ -275,6 +275,7 @@ import { addBrowserSurface } from "./preview/addBrowserSurface";...@@ -275,6 +275,7 @@ import { addBrowserSurface } from "./preview/addBrowserSurface";
275import { closePreviewSession } from "./preview/closePreviewSession";275import { closePreviewSession } from "./preview/closePreviewSession";
276import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";276import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer";
277import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator";277import { BrowserAudioIndicator } from "./preview/BrowserAudioIndicator";
278import { ScriptProgress } from "./chat/ScriptProgress";
278import { usePreviewSession } from "./preview/usePreviewSession";279import { usePreviewSession } from "./preview/usePreviewSession";
279import { subscribePreviewAction } from "./preview/previewActionBus";280import { subscribePreviewAction } from "./preview/previewActionBus";
280import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";281import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic";
...@@ -11751,6 +11752,12 @@ export default function ChatView(props: ChatViewProps) {...@@ -11751,6 +11752,12 @@ export default function ChatView(props: ChatViewProps) {
11751 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)"11752 className="group/composer-stack pointer-events-auto relative z-10 mx-auto w-full max-w-(--chat-content-max-width)"
11752 >11753 >
11753 <BrowserAudioIndicator />11754 <BrowserAudioIndicator />
11755 {!paintOnlyDisplayedTimeline ? (
11756 <ScriptProgress
11757 environmentId={activeThread.environmentId}
11758 threadId={activeThread.id}
11759 />
11760 ) : null}
11754 {isDraftHeroState ? (11761 {isDraftHeroState ? (
11755 <div className="absolute inset-x-0 bottom-full">11762 <div className="absolute inset-x-0 bottom-full">
11756 <div11763 <div
apps/web/src/components/chat/ScriptProgress.test.tsx created+191
...@@ -0,0 +1,191 @@
1// @vitest-environment jsdom
2
3import { act } from "react";
4import { createRoot, type Root } from "react-dom/client";
5import { AsyncResult, Atom } from "effect/reactivity";
6import * as Stream from "effect/Stream";
7import {
8 EnvironmentId,
9 ThreadId,
10 type ScriptProgressEvent,
11 type ScriptProgressStream,
12} from "@t3tools/contracts";
13import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
14
15import { appAtomRegistry } from "../../rpc/atomRegistry";
16import { ScriptProgress } from "./ScriptProgress";
17
18type ProgressResult = AsyncResult.AsyncResult<{
19 events: ReadonlyArray<ScriptProgressEvent>;
20 streams: ReadonlyArray<ScriptProgressStream>;
21}>;
22
23const state = vi.hoisted(() => ({ atom: undefined as unknown }));
24vi.mock("../../state/scriptProgress", () => ({
25 scriptProgressEnvironment: { watch: () => state.atom },
26}));
27
28let root: Root;
29let container: HTMLDivElement;
30let atom: Atom.Writable<ProgressResult, ProgressResult>;
31const descriptor: ScriptProgressStream = {
32 id: "script",
33 pid: 42,
34 label: "render.ts",
35 status: "connected",
36 summary: [{ text: "Rendering", value: 4, total: 10, detail: "4/10" }],
37};
38
39beforeEach(async () => {
40 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
41 const initial: ProgressResult = AsyncResult.success({
42 events: [{ type: "snapshot", streams: [] }],
43 streams: [],
44 });
45 atom = Atom.make<ProgressResult>(initial);
46 state.atom = atom;
47 container = document.createElement("div");
48 document.body.append(container);
49 root = createRoot(container);
50 await act(async () => {
51 root.render(
52 <ScriptProgress environmentId={EnvironmentId.make("test")} threadId={ThreadId.make("one")} />,
53 );
54 });
55});
56
57afterEach(async () => {
58 await act(async () => root.unmount());
59 container.remove();
60 vi.unstubAllGlobals();
61});
62
63async function emit(event: ScriptProgressEvent) {
64 await act(async () => {
65 appAtomRegistry.set(atom, AsyncResult.success({ events: [event], streams: [] }));
66 });
67}
68
69function progressbar() {
70 const tree = container.querySelector("[data-progress-motion]");
71 return tree?.shadowRoot?.querySelector('[role="progressbar"]');
72}
73
74it("renders actual progress values and resets to zero without resetting the tree", async () => {
75 expect(container.querySelector("section")?.hidden).toBe(true);
76 await emit({ type: "snapshot", streams: [descriptor] });
77 await emit({
78 type: "progress",
79 id: descriptor.id,
80 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
81 summary: descriptor.summary,
82 });
83 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
84 expect(progressbar()?.getAttribute("aria-valuemax")).toBe("10");
85 expect(container.querySelector("section")?.hidden).toBe(false);
86
87 await emit({
88 type: "progress",
89 id: descriptor.id,
90 event: [0, { k: 1, v: 0 }],
91 summary: [{ text: "Rendering", value: 0, total: 10, detail: "0/10" }],
92 });
93 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("0"));
94});
95
96it("removes the old chat's decoder and DOM before displaying another chat", async () => {
97 await emit({ type: "snapshot", streams: [descriptor] });
98 await emit({
99 type: "progress",
100 id: descriptor.id,
101 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
102 summary: descriptor.summary,
103 });
104 await vi.waitFor(() => expect(progressbar()).toBeTruthy());
105 await act(async () => {
106 root.render(
107 <ScriptProgress environmentId={EnvironmentId.make("test")} threadId={ThreadId.make("two")} />,
108 );
109 });
110 expect(progressbar()).toBeUndefined();
111 expect(container.querySelector("section")?.hidden).toBe(true);
112});
113
114it("requests a fresh tree when returning to a chat whose live atom cached a delta", async () => {
115 let connections = 0;
116 const snapshot: ScriptProgressEvent = { type: "snapshot", streams: [descriptor] };
117 const update: ScriptProgressEvent = {
118 type: "progress",
119 id: descriptor.id,
120 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
121 summary: descriptor.summary,
122 };
123 state.atom = Atom.make(
124 Stream.suspend(() => {
125 connections++;
126 return Stream.concat(
127 Stream.fromArray([{ events: [snapshot, update], streams: [descriptor] }]),
128 Stream.never,
129 );
130 }),
131 ).pipe(Atom.keepAlive);
132
133 await act(async () => {
134 root.render(
135 <ScriptProgress
136 environmentId={EnvironmentId.make("test")}
137 threadId={ThreadId.make("cached")}
138 />,
139 );
140 });
141 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
142 const initialConnections = connections;
143 await act(async () => root.unmount());
144 root = createRoot(container);
145 await act(async () => {
146 root.render(
147 <ScriptProgress
148 environmentId={EnvironmentId.make("test")}
149 threadId={ThreadId.make("cached")}
150 />,
151 );
152 });
153
154 await vi.waitFor(() => expect(progressbar()?.getAttribute("aria-valuenow")).toBe("4"));
155 expect(connections).toBeGreaterThan(initialConnections);
156});
157
158it("hides the panel when a passive grouping node has no visible tasks left", async () => {
159 await emit({ type: "snapshot", streams: [descriptor] });
160 await emit({
161 type: "progress",
162 id: descriptor.id,
163 event: [-1, { k: 1, t: "Group", p: true, c: [2] }, { k: 2, t: "Compiling", v: 1, e: 4 }, [1]],
164 summary: descriptor.summary,
165 });
166 await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(false));
167 await emit({
168 type: "progress",
169 id: descriptor.id,
170 event: [1, 2, { k: 1, c: [] }],
171 summary: [],
172 });
173 await vi.waitFor(() => expect(container.querySelector("section")?.hidden).toBe(true));
174});
175
176it("hides an empty disconnected script while another script is running", async () => {
177 const empty = { ...descriptor, id: "empty", status: "disconnected" as const, summary: [] };
178 await emit({ type: "snapshot", streams: [empty, descriptor] });
179 await emit({ type: "progress", id: empty.id, event: [-1, []], summary: [] });
180 await emit({
181 type: "progress",
182 id: descriptor.id,
183 event: [-1, { k: 1, t: "Rendering", v: 4, e: 10 }, [1]],
184 summary: descriptor.summary,
185 });
186
187 const section = container.querySelector("section");
188 await vi.waitFor(() => expect(section?.hidden).toBe(false));
189 expect(section?.children[0]?.getAttribute("hidden")).toBe("");
190 expect(section?.children[1]?.hasAttribute("hidden")).toBe(false);
191});
apps/web/src/components/chat/ScriptProgress.tsx created+162
...@@ -0,0 +1,162 @@
1import * as progress from "@clo/lib/progress";
2import { attachToDocument } from "@clo/lib/progress/browser";
3import type {
4 EnvironmentId,
5 ScriptProgressEvent,
6 ScriptProgressStream,
7 ThreadId,
8} from "@t3tools/contracts";
9import { AsyncResult } from "effect/reactivity";
10import { useEffect, useRef } from "react";
11
12import { appAtomRegistry } from "../../rpc/atomRegistry";
13import { scriptProgressEnvironment } from "../../state/scriptProgress";
14
15export function ScriptProgress({
16 environmentId,
17 threadId,
18}: {
19 environmentId: EnvironmentId;
20 threadId: ThreadId;
21}) {
22 const container = useRef<HTMLElement>(null);
23
24 useEffect(() => {
25 const host = container.current;
26 if (!host) return;
27
28 const streams = new Map<
29 string,
30 {
31 root: progress.Root;
32 input: ReadableStreamDefaultController<progress.StreamEvent>;
33 element: HTMLElement;
34 status: HTMLElement;
35 dispose: () => void;
36 }
37 >();
38
39 const updateVisibility = () => {
40 let visible = false;
41 for (const stream of streams.values()) {
42 stream.element.hidden = !stream.root.active.some(progress.isVisible);
43 visible ||= !stream.element.hidden;
44 }
45 host.hidden = !visible;
46 };
47
48 const remove = (id: string) => {
49 const stream = streams.get(id);
50 if (!stream) return;
51 streams.delete(id);
52 stream.dispose();
53 stream.element.remove();
54 updateVisibility();
55 };
56
57 const upsert = (descriptor: ScriptProgressStream) => {
58 let stream = streams.get(descriptor.id);
59 if (!stream) {
60 const root = new progress.Root();
61 const element = document.createElement("div");
62 element.hidden = true;
63 const status = document.createElement("div");
64 status.className = "text-xs text-muted-foreground";
65 const tree = document.createElement("div");
66 tree.dataset.progressMotion = "static";
67 element.append(status, tree);
68 host.append(element);
69
70 const detach = attachToDocument(root, tree);
71 const off = root.on("change", updateVisibility);
72 let input: ReadableStreamDefaultController<progress.StreamEvent> | undefined;
73 const readable = new ReadableStream<progress.StreamEvent>({
74 start(controller) {
75 input = controller;
76 },
77 });
78 if (!input) throw new Error("Progress stream did not initialize.");
79
80 const decoder = progress.decodeEventStream(readable, root);
81 void decoder.catch(() => {
82 status.textContent = "Progress unavailable";
83 status.hidden = false;
84 updateVisibility();
85 });
86 stream = {
87 root,
88 input,
89 element,
90 status,
91 dispose: () => {
92 off();
93 decoder.cancel();
94 detach();
95 },
96 };
97 streams.set(descriptor.id, stream);
98 }
99
100 stream.status.textContent =
101 descriptor.status === "disconnected" ? `${descriptor.label} · Disconnected` : "";
102 stream.status.hidden = descriptor.status === "connected";
103 return stream;
104 };
105
106 const subscription = scriptProgressEnvironment.watch({
107 environmentId,
108 input: { threadId },
109 });
110 appAtomRegistry.refresh(subscription);
111 let previousEvents: ReadonlyArray<ScriptProgressEvent> | undefined;
112 const unsubscribe = appAtomRegistry.subscribe(
113 subscription,
114 (result) => {
115 if (!AsyncResult.isSuccess(result)) {
116 previousEvents = undefined;
117 for (const id of streams.keys()) remove(id);
118 return;
119 }
120
121 const { events } = result.value;
122 if (events === previousEvents) return;
123 previousEvents = events;
124 for (const event of events) {
125 switch (event.type) {
126 case "snapshot":
127 for (const id of streams.keys()) remove(id);
128 for (const descriptor of event.streams) upsert(descriptor);
129 break;
130 case "upsert":
131 upsert(event.stream);
132 break;
133 case "progress": {
134 const stream = streams.get(event.id);
135 // The shared decoder owns the stable wire format; RPC validates JSON at its boundary.
136 stream?.input.enqueue(event.event as progress.StreamEvent);
137 break;
138 }
139 case "remove":
140 remove(event.id);
141 break;
142 }
143 }
144 },
145 { immediate: true },
146 );
147
148 return () => {
149 unsubscribe();
150 for (const id of streams.keys()) remove(id);
151 };
152 }, [environmentId, threadId]);
153
154 return (
155 <section
156 ref={container}
157 hidden
158 aria-label="Script progress"
159 className="mb-2 max-h-56 space-y-2 overflow-auto rounded-xl border border-border bg-card p-3 [--progress-accent:var(--primary)] [--progress-background:var(--card)] [--progress-border:var(--border)] [--progress-foreground:var(--foreground)] [--progress-muted:var(--muted-foreground)] [--progress-secondary:var(--secondary)]"
160 />
161 );
162}
apps/web/src/state/scriptProgress.ts created+6
...@@ -0,0 +1,6 @@
1import { createScriptProgressEnvironmentAtoms } from "@t3tools/client-runtime/state/script-progress";
2
3import { connectionAtomRuntime } from "../connection/runtime";
4
5export const scriptProgressEnvironment =
6 createScriptProgressEnvironmentAtoms(connectionAtomRuntime);
apps/web/tsconfig.json+1-1
...@@ -7,7 +7,7 @@...@@ -7,7 +7,7 @@
7 "erasableSyntaxOnly": false,7 "erasableSyntaxOnly": false,
8 "verbatimModuleSyntax": false,8 "verbatimModuleSyntax": false,
9 "jsx": "react-jsx",9 "jsx": "react-jsx",
10 "lib": ["ES2023", "DOM", "DOM.Iterable"],10 "lib": ["ES2025", "DOM", "DOM.Iterable"],
11 "paths": {11 "paths": {
12 "~/*": ["./src/*"]12 "~/*": ["./src/*"]
13 },13 },
docs/user/activity-log.md+14
...@@ -16,3 +16,17 @@ Failed calls do not count as successful messages or creations. Waiting on a thre...@@ -16,3 +16,17 @@ Failed calls do not count as successful messages or creations. Waiting on a thre
16it finished, and an interrupt or cancellation request does not mean the thread stopped. When tool16it finished, and an interrupt or cancellation request does not mean the thread stopped. When tool
17details are unavailable, summaries use a broader description instead of guessing how many threads17details are unavailable, summaries use a broader description instead of guessing how many threads
18were affected.18were affected.
19
20## Script progress
21
22Node scripts using `@clo/lib/progress` show their live tasks above the composer
23when launched by Codex, Claude, Pi, Muse, an ACP agent, or the chat's terminal.
24Web and desktop show
25the nested task tree; mobile shows task counts. Command output stays available
26as usual. Progress remains visible when you reconnect to the chat remotely.
27
28Disconnected progress preserves the last reported values without claiming the
29command succeeded. The command's result determines whether it completed.
30
31Capture needs Node's synchronous module hooks and a separately loaded progress
32module. A progress library bundled into a script is not captured automatically.
packages/client-runtime/package.json+4
...@@ -418,6 +418,10 @@...@@ -418,6 +418,10 @@
418 "./pull-request-markdown": {418 "./pull-request-markdown": {
419 "types": "./src/pullRequestMarkdown.ts",419 "types": "./src/pullRequestMarkdown.ts",
420 "default": "./src/pullRequestMarkdown.ts"420 "default": "./src/pullRequestMarkdown.ts"
421 },
422 "./state/script-progress": {
423 "types": "./src/state/scriptProgress.ts",
424 "default": "./src/state/scriptProgress.ts"
421 }425 }
422 },426 },
423 "scripts": {427 "scripts": {
packages/client-runtime/src/rpc/client.ts+1
...@@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;...@@ -48,6 +48,7 @@ export type EnvironmentRpcTag = keyof WsRpcProtocolClient & string;
48type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];48type RpcMethod<TTag extends EnvironmentRpcTag> = WsRpcProtocolClient[TTag];
4949
50export type EnvironmentSubscriptionRpcTag =50export type EnvironmentSubscriptionRpcTag =
51 | typeof WS_METHODS.subscribeScriptProgress
51 | typeof WS_METHODS.codexAuthCallbackSubscribe52 | typeof WS_METHODS.codexAuthCallbackSubscribe
52 | typeof WS_METHODS.providerAuthSubscribe53 | typeof WS_METHODS.providerAuthSubscribe
53 | typeof WS_METHODS.providerInstallSubscribe54 | typeof WS_METHODS.providerInstallSubscribe
packages/client-runtime/src/state/scriptProgress.test.ts created+101
...@@ -0,0 +1,101 @@
1import { describe, expect, vi } from "vite-plus/test";
2import { it } from "@effect/vitest";
3import {
4 EnvironmentId,
5 ThreadId,
6 type ScriptProgressEvent,
7 type ScriptProgressStream,
8} from "@t3tools/contracts";
9import * as Effect from "effect/Effect";
10import * as Layer from "effect/Layer";
11import * as Stream from "effect/Stream";
12import { Atom, AtomRegistry } from "effect/reactivity";
13
14import { EnvironmentRegistry } from "../connection/registry.ts";
15import {
16 applyScriptProgressEvent,
17 createScriptProgressEnvironmentAtoms,
18} from "./scriptProgress.ts";
19
20const source = vi.hoisted(() => ({ stream: undefined as unknown }));
21vi.mock("../rpc/client.ts", async (original) => ({
22 ...(await original<typeof import("../rpc/client.ts")>()),
23 subscribe: () => source.stream,
24}));
25
26const build: ScriptProgressStream = {
27 id: "build",
28 pid: 42,
29 label: "build.ts",
30 status: "connected",
31 summary: [{ text: "Building", value: 4, total: 10, detail: "4/10" }],
32};
33
34describe("script progress summaries", () => {
35 it.effect("preserves every protocol event when RPC delivers a chunk in one atom update", () =>
36 Effect.scoped(
37 Effect.gen(function* () {
38 const zero = [{ text: "Building", value: 0, total: 10, detail: "0/10" }];
39 const events: ScriptProgressEvent[] = [
40 { type: "snapshot", streams: [] },
41 { type: "upsert", stream: build },
42 {
43 type: "progress",
44 id: build.id,
45 event: [-1, { k: 1, t: "Building", v: 4, e: 10 }, [1]],
46 summary: build.summary,
47 },
48 { type: "progress", id: build.id, event: [0, { k: 1, v: 0 }], summary: zero },
49 ];
50 source.stream = Stream.fromArray(events);
51 const runtime = Atom.runtime(
52 Layer.succeed(EnvironmentRegistry, {
53 followStream: (_id, stream) => stream,
54 } as EnvironmentRegistry["Service"]),
55 );
56 const progress = createScriptProgressEnvironmentAtoms(runtime);
57 const registry = AtomRegistry.make();
58 yield* Effect.addFinalizer(() => Effect.sync(() => registry.dispose()));
59 const result = yield* AtomRegistry.getResult(
60 registry,
61 progress.watch({
62 environmentId: EnvironmentId.make("chunk-proof"),
63 input: { threadId: ThreadId.make("thread") },
64 }),
65 );
66 expect(result.events).toEqual(events);
67 expect(result.streams).toEqual([{ ...build, summary: zero }]);
68 }),
69 ),
70 );
71
72 it("replaces a prior connection's streams with the reconnect snapshot", () => {
73 expect(applyScriptProgressEvent([build], { type: "snapshot", streams: [] })).toEqual([]);
74 });
75
76 it("keeps independent scripts and accepts progress resets to zero", () => {
77 const other = { ...build, id: "other", pid: 43 };
78 const summaries = applyScriptProgressEvent([build], { type: "upsert", stream: other });
79 const zero = [{ text: "Building", value: 0, total: 10, detail: "0/10" }];
80
81 expect(
82 applyScriptProgressEvent(summaries, {
83 type: "progress",
84 id: build.id,
85 event: [0, { k: 1, v: 0 }],
86 summary: zero,
87 }),
88 ).toEqual([{ ...build, summary: zero }, other]);
89 });
90
91 it("preserves the last summary when disconnected without reporting success", () => {
92 const disconnected = { ...build, status: "disconnected" as const };
93 const summaries = applyScriptProgressEvent([build], {
94 type: "upsert",
95 stream: disconnected,
96 });
97
98 expect(summaries).toEqual([disconnected]);
99 expect(applyScriptProgressEvent(summaries, { type: "remove", id: build.id })).toEqual([]);
100 });
101});
packages/client-runtime/src/state/scriptProgress.ts created+54
...@@ -0,0 +1,54 @@
1import {
2 type ScriptProgressEvent,
3 type ScriptProgressStream,
4 WS_METHODS,
5} from "@t3tools/contracts";
6import * as Stream from "effect/Stream";
7import { Atom } from "effect/reactivity";
8
9import type { EnvironmentRegistry } from "../connection/registry.ts";
10import { createEnvironmentRpcSubscriptionAtomFamily } from "./runtime.ts";
11
12export function applyScriptProgressEvent(
13 streams: ReadonlyArray<ScriptProgressStream>,
14 event: ScriptProgressEvent,
15): ReadonlyArray<ScriptProgressStream> {
16 switch (event.type) {
17 case "snapshot":
18 return event.streams;
19 case "upsert": {
20 const index = streams.findIndex((stream) => stream.id === event.stream.id);
21 return index < 0
22 ? [...streams, event.stream]
23 : streams.map((stream) => (stream.id === event.stream.id ? event.stream : stream));
24 }
25 case "progress":
26 return streams.map((stream) =>
27 stream.id === event.id ? { ...stream, summary: event.summary } : stream,
28 );
29 case "remove":
30 return streams.filter((stream) => stream.id !== event.id);
31 }
32}
33
34export function createScriptProgressEnvironmentAtoms<R, E>(
35 runtime: Atom.AtomRuntime<EnvironmentRegistry | R, E>,
36) {
37 return {
38 watch: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
39 label: "environment-data:script-progress:watch",
40 tag: WS_METHODS.subscribeScriptProgress,
41 idleTtlMs: 0,
42 transform: (stream) =>
43 Stream.suspend(() => {
44 let streams: ReadonlyArray<ScriptProgressStream> = [];
45 return stream.pipe(
46 Stream.mapArray((events) => {
47 for (const event of events) streams = applyScriptProgressEvent(streams, event);
48 return [{ events, streams }];
49 }),
50 );
51 }),
52 }),
53 };
54}
packages/clo-lib/Events.ts created+107
...@@ -0,0 +1,107 @@
1/**
2 * type-safe, browser compatible, mini event emitter.
3 * this module is intended to be imported via the main class.
4 *
5 * ```ts
6 * import { Events } from '@clo/lib/Events';
7 * const emitter = new Events<{ change: [string] }>();
8 * ```
9 *
10 * @module
11 */
12
13/**
14 * minimal event emitter supporting typed events. Unlike `EventTarget` and
15 * `node:events`, this supports an `onAny`, which is used by `lib/progress.ts`
16 *
17 * beware that the type parameter cannot be an `interface`.
18 */
19export class Events<Map extends Events.Map> {
20 #channels: { [C in keyof Map]?: Array<(...args: Map[C]) => void> } = {};
21 #global: Array<(channel: keyof Map, args: unknown[]) => void> = [];
22
23 /**
24 * Listen for events on the specified channel
25 * ```ts
26 * using dispose = events.on("change", () => { ... });
27 * // disposable will remove the listener on scope exit.
28 * // alternatively, can use `const` and call `dispose()`.
29 * ```
30 */
31 on<C extends keyof Map>(channel: C, cb: Events.Handler<Map[C]>): ts.Dispose {
32 const list = (this.#channels[channel] ??= []);
33 list.push(cb);
34 return ts.defer(() => remove(list, cb));
35 }
36 /**
37 * listen for one event on the specified channel
38 * ```ts
39 * // can be awaited
40 * const [data] = await events.once("something");
41 * // can be given a callback
42 * events.once("something", (data) => { ... });
43 * // can be cancelled
44 * const cancelable = events.once("something");
45 * cancelable.then(() => assert(false));
46 * cancelable.cancel();
47 * ```
48 */
49 once<C extends keyof Map>(channel: C, cb?: Events.Handler<Map[C]>): async.Cancelable<Map[C]> {
50 let disposeEvent: ts.Dispose | null = null;
51 let disposeError: ts.Dispose | null = null;
52 function dispose() {
53 if (!disposeEvent) return;
54 disposeEvent?.();
55 UNWRAP(disposeError)();
56 disposeEvent = disposeError = null;
57 }
58 return async.makeCancelable(
59 new Promise((resolve, reject) => {
60 disposeEvent = this.on(channel, (...args) => {
61 dispose();
62 resolve(args);
63 cb?.(...args);
64 });
65 disposeError = this.on(channel, (...args) => {
66 dispose();
67 reject(args);
68 cb?.(...args);
69 });
70 }),
71 () => dispose(),
72 );
73 }
74 /** listen for any event */
75 onAny(cb: (channel: keyof Map, args: unknown[]) => void): ts.Dispose {
76 this.#global.push(cb);
77 return ts.defer(() => remove(this.#global, cb));
78 }
79 /** emit an event on the specified channel */
80 emit<C extends keyof Map>(channel: C, ...args: Map[C]): void {
81 const handlers = this.#channels[channel];
82 if (handlers) handlers.forEach((cb) => cb(...args));
83 else if (channel === "error") {
84 this.#global?.forEach((cb) => cb(channel, args));
85 throw args.length === 0
86 ? new Error("Error event emitted")
87 : args.length === 1
88 ? args[0]
89 : new AggregateError(args);
90 }
91 this.#global?.forEach((cb) => cb(channel, args));
92 }
93}
94
95function remove<T>(list: T[], item: T) {
96 const i = list.indexOf(item);
97 if (i !== -1) list.splice(i, 1);
98}
99
100export namespace Events {
101 export type Map = { [key: string]: unknown[] };
102 export type Handler<Args extends unknown[]> = (...args: Args) => void;
103}
104
105import { UNWRAP } from "./assert.ts";
106import * as async from "./async.ts";
107import * as ts from "./ts.ts";
packages/clo-lib/assert.ts created+50
...@@ -0,0 +1,50 @@
1/**
2 * assertions and type narrowing helpers. intended to be imported per symbol.
3 * functions are capitalized to make them stand out in a codebase.
4 *
5 * @module
6 */
7/* node:coverage disable */
8
9/** assert that `condition` is truthy. */
10export function ASSERT(condition: unknown, msg?: Message): asserts condition {
11 if (!condition) {
12 throw new Error(msg ? messageToString(msg) : "Assertion Error");
13 }
14}
15
16/** assert that `nullable` is non-null, returning a non-nullable T. */
17export function UNWRAP<T>(nullable: T | null | undefined, msg?: Message): T {
18 if (nullable == null) {
19 throw new Error(`Unexpected ${nullable}${msg ? `: ${messageToString(msg)}` : ""}`);
20 }
21 return nullable;
22}
23
24/** mark an incomplete feature. */
25export function TODO(msg?: Message): never {
26 throw new Error(`TODO${msg ? `: ${messageToString(msg)}` : ""}`);
27}
28
29/** assert that a `default` case in a switch is never reached. */
30export function unreachableCase(value: never): never {
31 throw new Error(`Unexpected value in switch: ${inspect(value as unknown)}`);
32}
33
34/** message called if function; then string printed as is or util inspectified */
35type Message = string | (() => string | unknown) | unknown;
36
37function messageToString(msg: Message) {
38 if (typeof msg === "function") msg = (msg as () => Message)();
39 return typeof msg === "string" ? msg : inspect(msg);
40}
41
42function inspect(object: unknown) {
43 try {
44 return node.builtin("util")?.inspect(object) ?? typeof object;
45 } catch {
46 return typeof object;
47 }
48}
49
50import * as node from "./node.ts";
packages/clo-lib/async.ts created+297
...@@ -0,0 +1,297 @@
1// @effect-diagnostics globalTimers:off - Vendored @clo/lib has no Effect dependency.
2/**
3 * helpers to deal with promises and asynchronous execution.
4 *
5 * @module
6 */
7
8/*** @deprecated */
9interface ARCEValue<T> {
10 value: T;
11 [Symbol.dispose]: () => void;
12}
13
14/*** @deprecated */
15export function RefCountedExpirable<T>(
16 init: () => Promise<T>,
17 deinit: (value: T) => void,
18 expire: number = 5 * 60 * 1000,
19): () => Promise<ARCEValue<T>> {
20 let refs = 0;
21 let item: ARCEValue<T> | null = null;
22 let loading: Promise<ARCEValue<T>> | null = null;
23 let timer: ReturnType<typeof setTimeout> | null = null;
24
25 function deref() {
26 ASSERT(item !== null);
27 if (--refs !== 0) return;
28 ASSERT(timer === null);
29 timer = setTimeout(() => {
30 ASSERT(refs === 0);
31 ASSERT(loading === null);
32 ASSERT(item !== null);
33 deinit(item.value);
34 item = null;
35 timer = null;
36 }, expire);
37 }
38
39 return async function (): Promise<ARCEValue<T>> {
40 if (timer !== null) {
41 clearTimeout(timer);
42 timer = null;
43 }
44 if (item !== null) {
45 refs++;
46 return item;
47 }
48 if (loading !== null) {
49 refs++;
50 return loading;
51 }
52 const p = Promise.withResolvers<ARCEValue<T>>();
53 loading = p.promise;
54 try {
55 const value = await init();
56 item = { value, [Symbol.dispose]: deref };
57 refs++;
58 p.resolve(item);
59 return item;
60 } catch (e) {
61 p.reject(e);
62 throw e;
63 } finally {
64 loading = null;
65 }
66 };
67}
68
69/** evaluates `fn` one time, caching the result forever. */
70export function once<T>(fn: () => Promise<T>): () => Promise<T> {
71 let result: T | Promise<T> | null = null;
72 return async () => {
73 if (result) return result;
74 try {
75 result = await (result = fn());
76 } catch (err) {
77 result = null;
78 throw err;
79 }
80 return result;
81 };
82}
83
84/** a variable that can be watched for changes */
85export class Watch<T> {
86 #value: T;
87 #observers = new Set<WatchCallback<T>>();
88 constructor(value: T) {
89 this.#value = value;
90 }
91
92 on(cb: WatchCallback<T>): () => void {
93 this.#observers.add(cb);
94 return () => void this.#observers.delete(cb);
95 }
96 once(cb: WatchCallback<T>): void {
97 const once = (n: T, p: T) => (cb(n, p), this.#observers.delete(once));
98 this.#observers.add(once);
99 }
100
101 next(): Promise<T> {
102 return new Promise((resolve) => this.once(resolve));
103 }
104
105 until(condition: (v: T) => boolean): Promise<T> {
106 if (condition(this.#value)) return Promise.resolve(this.#value);
107 return new Promise<T>((resolve) => {
108 function check(next: T) {
109 if (condition(next)) (resolve(next), release());
110 }
111 const release = this.on(check);
112 });
113 }
114
115 get value(): T {
116 return this.#value;
117 }
118 set value(next: T) {
119 const prev = this.#value;
120 if (prev !== next) {
121 this.#value = next;
122 this.#observers.forEach((cb) => cb(next, prev));
123 }
124 }
125}
126export type WatchCallback<T> = (next: T, prev: T) => void;
127
128/**
129 * when two requests with the same args come in at the same time, the response
130 * from them is shared. after returning, the data is no longer stored.
131 */
132export class DedupeConcurrent<Args extends unknown[], Ret> {
133 pending: Map<unknown, Promise<Ret>> = new Map();
134 fn: (...args: Args) => Promise<Ret>;
135 keyFn: (args: Args) => unknown = JSON.stringify;
136
137 constructor(
138 fn: (...args: Args) => Promise<Ret>,
139 keyFn: (args: Args) => unknown = JSON.stringify,
140 ) {
141 this.fn = fn;
142 this.keyFn = keyFn;
143 }
144
145 isPending(...args: Args): boolean {
146 return this.pending.has(this.keyFn(args));
147 }
148
149 /** Unbound to allow easily exporting this callback */
150 get = (...args: Args): Promise<Ret> => {
151 const k = this.keyFn(args);
152 let promise = this.pending.get(k);
153 if (promise) return promise;
154 promise = this.fn(...args);
155 this.pending.set(k, promise);
156 void promise.finally(() => void this.pending.delete(k)).catch(() => {});
157 return promise;
158 };
159}
160
161/**
162 * when two requests with the same args come, the result
163 * of the first is memoized.
164 */
165export class OnceMap<Args extends unknown[], Ret> {
166 cache: Map<unknown, Promise<Ret>> = new Map();
167 fn: (...args: Args) => Promise<Ret>;
168 keyFn: (args: Args) => unknown = JSON.stringify;
169
170 constructor(
171 fn: (...args: Args) => Promise<Ret>,
172 keyFn: (args: Args) => unknown = JSON.stringify,
173 ) {
174 this.fn = fn;
175 this.keyFn = keyFn;
176 }
177
178 has(...args: Args): boolean {
179 return this.cache.has(this.keyFn(args));
180 }
181
182 /** Unbound to allow easily exporting this callback */
183 getOrRun = (...args: Args): Promise<Ret> => {
184 const k = this.keyFn(args);
185 let promise = this.cache.get(k);
186 if (promise) return promise;
187 promise = this.fn(...args);
188 this.cache.set(k, promise);
189 return promise;
190 };
191}
192
193export class PromiseAggregator {
194 failures: unknown[] = [];
195 promises: Set<Promise<unknown>> = new Set();
196
197 push(p: Promise<unknown>) {
198 p = p.then(
199 () => {
200 this.promises.delete(p);
201 },
202 (err) => {
203 this.failures.push(err);
204 },
205 );
206 this.promises.add(p);
207 }
208
209 async all() {
210 while (this.promises.size > 0) {
211 const wait = [...this.promises];
212 this.promises.clear();
213 await Promise.all(wait);
214 }
215 if (this.failures.length > 0) {
216 const agg = new AggregateError(this.failures);
217 this.failures = [];
218 throw agg;
219 }
220 }
221
222 [Symbol.asyncDispose](): Promise<void> {
223 return this.all();
224 }
225}
226
227/** a promise that can be cancelled. */
228export type Cancelable<T> = Promise<T> &
229 Disposable & {
230 cancel: (reason?: unknown) => void;
231 };
232
233/** make an existing promise cancelable. */
234export function makeCancelable<T>(
235 promise: Promise<T>,
236 cancel: (reason?: unknown) => void,
237): Cancelable<T> {
238 const p = promise as Cancelable<T>;
239 p[Symbol.dispose] = cancel;
240 p.cancel = cancel;
241 return p;
242}
243
244/** wait `ms` milliseconds, then resolve. can be cancelled. */
245export function delay(ms: number): Cancelable<void> {
246 if (ms === Infinity) return makeCancelable(new Promise(() => {}), () => {});
247 let t: ts.Timer | null = null;
248 const maxTimer = 0x7fffffff;
249 return makeCancelable(
250 new Promise(function tick(resolve) {
251 if (ms <= maxTimer) {
252 t = setTimeout(() => ((t = null), resolve()), ms);
253 } else {
254 t = setTimeout(() => {
255 ms -= maxTimer;
256 tick(resolve);
257 }, maxTimer);
258 }
259 }),
260 () => {
261 if (t != null) clearTimeout(t);
262 },
263 );
264}
265
266export interface Deferred<T> extends AsyncDisposable, PromiseLike<T> {}
267
268/**
269 * Return something that can be awaited later without worry of an unhandled
270 * rejection. By disposing it, the promise exception will always be reported.
271 *
272 * ```ts
273 * // incorrect, possible unhandled rejection between a and b.
274 * const a = promise();
275 * const b = await promise();
276 * const c = await promise(b, await a);
277 * // correct
278 * await using a = async.deferred(promise());
279 * const b = await promise();
280 * const c = await promise(b, await a);
281 * ```
282 */
283export function deferred<T>(promise: Promise<T>): Deferred<T> {
284 let handled = false;
285 promise.catch(() => {}); // potential error is re-thrown on asyncDispose
286 return {
287 async [Symbol.asyncDispose]() {
288 if (!handled) await promise;
289 },
290 then(onFufilled, onRejected) {
291 return promise.finally(() => (handled = true)).then(onFufilled, onRejected);
292 },
293 };
294}
295
296import { ASSERT } from "./assert.ts";
297import * as ts from "./ts.ts";
packages/clo-lib/bytes.ts created+12
...@@ -0,0 +1,12 @@
1/**
2 * helpers to deal with `Uint8Array` and other `ArrayBufferView`s
3 *
4 * @module
5 */
6
7export function eql<T extends ArrayBufferView & { [n: number]: number }>(a: T, b: T): boolean {
8 const l = a.byteLength;
9 if (l !== b.byteLength) return false;
10 for (let i = 0; i < l; i++) if (a[i] !== b[i]) return false;
11 return true;
12}
packages/clo-lib/exception.ts created+51
...@@ -0,0 +1,51 @@
1/**
2 * helper functions for dealing with the `unknown` type, mainly in `catch`
3 * blocks or promise rejection callbacks.
4 *
5 * @module
6 */
7
8/** extra fields on errors are extremely helpful for debugging. */
9export interface Error extends globalThis.Error {
10 [metadata: PropertyKey]: unknown;
11}
12
13/** retrieve an error message from any value */
14export function message(error: unknown): string {
15 let message = (error as { message: unknown })?.message ?? error;
16 try {
17 message =
18 typeof message === "string"
19 ? message
20 : // NOTE: typescript standard library lies here (https://github.com/mattpocock/ts-reset/pull/190)
21 String(JSON.stringify(message) as string | undefined);
22 } catch {}
23 try {
24 return String(message);
25 } catch {}
26 return `Could not stringify error message ${typeof message}`;
27}
28
29/** retrieve an error-like object from any value. Useful to check fields. */
30export function obj(error: unknown): { message: string } & Partial<Error> {
31 if (error instanceof Error) return error as Error;
32 return {
33 message: message(error),
34 ...(typeof error === "object" && error),
35 };
36}
37
38/** node.js error codes are string values on the `.code` property. */
39export function code(error: unknown): node.ErrorCode | null {
40 const code = (error as { code: unknown })?.code;
41 return typeof code === "string" ? code : null;
42}
43
44/** attach extra fields and rethrow a value. */
45export function rethrowWithMetadata(err: unknown, meta: Record<string, unknown>): never {
46 const error = err && typeof err === "object" ? err : new Error(message(err)); // no stack trace :/
47 Object.assign(error, meta);
48 throw error;
49}
50
51import type * as node from "./node.ts";
packages/clo-lib/license.txt created+13
...@@ -0,0 +1,13 @@
1Copyright 2025-2026 clover caruso
2
3Permission to use, copy, modify, and/or distribute this software for any
4purpose with or without fee is hereby granted, provided that the above
5copyright notice and this permission notice appear in all copies.
6
7THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
8REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
9FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
10INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
11LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
12OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
13PERFORMANCE OF THIS SOFTWARE.
packages/clo-lib/log.ts created+2070
...@@ -0,0 +1,2070 @@
1// @effect-diagnostics globalDate:off globalConsole:off - Vendored @clo/lib has no Effect dependency.
2/**
3 * by using `@clo/lib/log`, an application gets easy scoped logging as well
4 * as integration with terminal widgets such as `progress.ts`. even when these
5 * widgets are active, using the logging interface is optional; global I/O with
6 * `console.*` and `process.std{out/err}` are automatically patched to play nice
7 * so nearly any attempts at writing to the terminal should display fine.
8 * additionally, the clover library writes to a global symbol to communicate with
9 * other copies of this library (even across versions) to coordinate drawing.
10 *
11 * the pattern for using this module is to shadow the global `console` with a
12 * per-file logging scope, which makes it impossible to use the wrong logger.
13 *
14 * ```ts
15 * import * as log from "@clo/lib/log";
16 * const console = log.scoped("http");
17 * console.info("Hello world!"); // info message
18 * console.log("hi!"); // debug message only
19 * ```
20 *
21 * or to use the global scope, import the module's namespace as `console`.
22 *
23 * ```ts
24 * import * as console from "@clo/lib/log";
25 * ```
26 *
27 * now code reads familiarly (`console.log` is universally understood),
28 * but the output is organized into relevant scopes.
29 *
30 * in addition to static log messages, a system for interactive I/O via the
31 * {@linkcode Widget} interface can be started with {@linkcode startWidget}.
32 * these allow showing temporary or interactive information, such as program
33 * status or input prompts. a powerful example of this system in action is
34 * `lib/progress.ts`, which uses a widget as it's default rendering backend.
35 * alongside this, a very complicated locking system is provided to ensure that
36 * widgets to not interfere with globals like `process.stdout.write`. a widget
37 * that defines `input` receives the keyboard, which `lib/repl.ts` builds a
38 * line editor on.
39 *
40 * `lib/log.ts` offers two environment integrations:
41 * - in node.js, log messages are colored depending on the level and show
42 * widgets directly under the long using ANSI cursor controls. when the
43 * terminal is not a TTY, widgets are silent.
44 * - in browsers and other, logs are surfaced using the global `console` API
45 * and widgets are disabled.
46 *
47 * custom log integrations can be built on top of this module by calling
48 * `log.tee()` to duplicate all messages elsewhere. for example, a project may
49 * configure logs to upload to a telemetry service. these logs can carry custom
50 * metadata as well as source code traces -- amazing tools for debugging.
51 *
52 * if bundling for the browser, see the details in `./node.ts` on how to
53 * trigger tree-shaking to eliminate the node.js bindings in your web bundle.
54 *
55 * @module
56 */
57
58/**
59 * logging scopes implement part of the `Console` API. the `log` module itself
60 * satisfies this interface.
61 */
62export interface Scope {
63 /** emit an informational message */
64 info(...args: unknown[]): void;
65 /** emit an advisory message */
66 warn(...args: unknown[]): void;
67 /** emit a failure message */
68 error(...args: unknown[]): void;
69
70 /**
71 * emit a debugging message.
72 */
73 log(...args: unknown[]): void;
74 /**
75 * emit a debugging message. this method is in place for compatibility with
76 * the `console` API. prefer calling `.log` directly.
77 * @internal
78 */
79 debug(...args: unknown[]): void;
80
81 /** write a Message object directly. */
82 writeMessage(message: Message): void;
83
84 /** create a nested sub-scope */
85 scoped(name: string, custom?: Message["custom"]): Scope;
86 /** redirect the logging output of this scope somewhere else */
87 tee(writer: (message: Message) => void): ts.Dispose;
88}
89
90export interface RootScope extends Scope {
91 /** write a partial line to the output. */
92 write(text: string): void;
93}
94
95export const originalLogArgs = Symbol("originalLogArgs");
96
97/**
98 * a message written in a log scope.
99 * this API will never be altered in a breaking way.
100 */
101export interface Message {
102 /** ANSI-styled text */
103 text: string;
104 /** datetime in milliseconds since UNIX epoch */
105 time: number;
106 /**
107 * type of message, if known.
108 * @default info
109 */
110 level?: MessageLevel | undefined;
111 /** scope name */
112 scope?: string | null | undefined;
113 /** captured stack, if available */
114 stack?: stack.Frame[] | undefined;
115 /** arbitrary data from the logging source. */
116 custom?: Partial<Record<string, ts.Json>> | undefined;
117 /**
118 * if there is a newline at the end of this log line.
119 * @default true
120 */
121 newline?: boolean | undefined;
122 /**
123 * original logging arguments, if present. this field is indexed by a symbol
124 * so that it is lost during JSON serialization, as callers are allowed to log
125 * non-serializable data.
126 */
127 [originalLogArgs]?: unknown[];
128}
129/** this API will never be altered in a breaking way. */
130type MessageLevel = "error" | "warn" | "info" | "debug";
131
132// these functions implement `Scope` for the module's namespace. that means if
133// a function takes in `Scope`, this file's namespace satisfies that.
134
135/** emit an informational message on the global log scope */
136export function info(...args: unknown[]) {
137 globalLog.info(...args);
138}
139/** emit an advisory message on the global log scope */
140export function warn(...args: unknown[]) {
141 globalLog.warn(...args);
142}
143/** emit an failure message on the global log scope */
144export function error(...args: unknown[]) {
145 globalLog.error(...args);
146}
147/** emit an debug message on the global log scope */
148export function log(...args: unknown[]) {
149 globalLog.log(...args);
150}
151/**
152 * emit an debug message on the global log scope. this method is in place for
153 * compatibility with the `console` API. prefer calling `.log` directly.
154 * @internal
155 */
156export function debug(...args: unknown[]) {
157 globalLog.debug(...args);
158}
159/** create a named logging scope, optionally carrying custom message fields */
160export function scoped(name: string, custom?: Message["custom"]): Scope {
161 return globalLog.scoped(name, custom);
162}
163/** redirect all log messages to another writer */
164export function tee(destination: DispatchFunction): ts.Dispose {
165 global.tees.add(destination);
166 return ts.defer(() => global.tees.delete(destination));
167}
168
169/** replace the default message writer */
170export function replaceGlobalMessageDestination(destination: DispatchFunction) {
171 global.writeMessage = destination;
172}
173
174/**
175 * replace the default interactive widget host. this applies for all separately
176 * installed copies of the library, across versions. once the widget host has
177 * been activated, it must be preserved forever.
178 */
179export function replaceGlobalWidgetHost(host: WidgetHost) {
180 if (global.widget?.frozen) {
181 throw new Error(
182 `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`,
183 );
184 }
185 global.widget = {
186 frozen: false,
187 source:
188 stack.capture()[0]?.file ??
189 "untracable call to replaceGlobalWidgetHost in " + import.meta.url,
190 host,
191 version: 0,
192 };
193}
194
195/**
196 * replace the default message formatter. does not affect browsers because that
197 * code path does not use `formatAnsiMessage`
198 */
199export function replaceGlobalFormatAnsiMessage(format: (msg: Message, colors: boolean) => string) {
200 global.formatAnsiMessage = format;
201}
202
203/** includes the trailing newline for standard log messages */
204export function formatAnsiMessage(msg: Message, colors: boolean): string {
205 return (global.formatAnsiMessage ?? defaultFormatAnsiMessage)(msg, colors);
206}
207
208/**
209 * a widget is an interactive display that persists at the end
210 * of the log. this can be used to implement status bars, progress
211 * indicators, and other human I/O. only 'format' is required.
212 *
213 * returns `null` if the terminal is not interactive or the global renderer is
214 * incapable of displaying this log (such as in a browser)
215 *
216 * ```ts
217 * using _ = log.startWidget({
218 * format: (now) => `It is ${new Date().toString()} right now\n`
219 * + `A random number: ${Math.random()}`,
220 * // no trailing '\n' is needed.
221 * });
222 * await async.delay(10000);
223 *
224 * import * as log from "@clo/lib/log";
225 * import * as async from "@clo/lib/async";
226 * ```
227 */
228export function startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null {
229 return globalWidgetHost().startWidget(widget);
230}
231
232/**
233 * no built-in prefix, formatting, or newlline. ensures the text does not interweave.
234 * data will be flushed in the next frame or when drawing is {@link getDrawLock|unlocked}.
235 */
236export function writeOutput(text: string) {
237 globalWidgetHost().writeOutput(text);
238}
239
240/**
241 * like {@linkcode writeOutput}, but the text is written to the error stream
242 * (stderr in node.js). relative ordering between output and error text is
243 * preserved through the shared flush buffer.
244 */
245export function writeError(text: string) {
246 globalWidgetHost().writeError(text);
247}
248
249/** write a {@linkcode Message} object directly. */
250export function writeMessage(m: Message) {
251 globalLog.writeMessage(m);
252}
253
254/**
255 * hold a lock that stops `@clo/lib` from drawing to the terminal. this is an
256 * advanced api that is likely unneeded unless messing with external APIs that
257 * freely write to the terminal.
258 *
259 * NOTE: if the lock will be held for an extremely short amount of time, pass
260 * `"short"` which will allow more optimized use of ansi synchronization codes.
261 */
262export function getDrawLock(mode: "long" | "short"): DrawLock {
263 return globalWidgetHost().getDrawLock(mode);
264}
265
266/**
267 * call this if you are doing silly things with `process.stderr` and you want to
268 * make sure that starting a log widget (such as `@clo/lib/progress`) doesn't
269 * have interweaving bugs. in particular, you need this if your program directly
270 * writes incomplete lines to the terminal (missing a trailing `\n`).
271 *
272 * the globals are patched to track if widget text must move to a fresh line
273 * before drawing, as well as knowing to return to the original position
274 * afterwards.
275 *
276 * you most likely do not need to call this API.
277 */
278export function ensureGlobalsArePatched(): ts.Dispose {
279 if (global.widget?.frozen && global.widget?.version !== version) {
280 throw new Error(
281 `Cannot change widget host implementation, it is locked by another implementation: ${global.widget.source}`,
282 );
283 }
284 const w = globalWidgetHost().startWidget?.({ format: () => "" });
285 return ts.defer(() => w?.stop());
286}
287
288/**
289 * a non-exclusive lock to drawing
290 * this API will never be altered in a breaking way.
291 */
292export interface DrawLock {
293 /**
294 * decides if widget drawing requires an extra newline, which is needed if
295 * there is extra text on the line that the lock is being released on.
296 */
297 release(endState: "cursor-start-of-line" | "cursor-middle-of-line"): void;
298 /** Assumes worst case `cursor-middle-of-line` */
299 [Symbol.dispose](): void;
300}
301
302export function headlessScope(dispatch: DispatchFunction): RootScope {
303 return new ScopeImpl(dispatch);
304}
305
306/**
307 * see {@linkcode startWidget}
308 * this API will never be altered in a breaking way.
309 */
310export interface WidgetOptions {
311 /**
312 * return the widget's text. return null to detach the widget.
313 * may get called more often than the specified `fps`.
314 * supports color codes but not ansi cursor movements.
315 */
316 format(ctx: WidgetFormatContext): string | WidgetFrame | null;
317 /** 'null' to never update automatically */
318 fps?: number | null;
319 /**
320 * receive keyboard input. the terminal is only read while some widget
321 * defines this, and events go to the most recently started one. reading
322 * puts the terminal in raw mode, so `ctrl+c` arrives here as an event
323 * instead of interrupting the process.
324 */
325 input?(event: WidgetInput): void;
326}
327
328/**
329 * one key press or paste, as given to {@linkcode WidgetOptions.input}.
330 * this API will never be altered in a breaking way.
331 */
332export interface WidgetInput {
333 /**
334 * the key, prefixed by its modifiers in the order
335 * `ctrl+alt+shift+super+hyper+meta+`. printable keys are named by the
336 * grapheme they type (`"a"`, `"A"`, `"é"`), except `"space"`. other keys
337 * are named in lowercase: `"enter"`, `"tab"`, `"backspace"`, `"delete"`,
338 * `"escape"`, `"up"`, `"down"`, `"left"`, `"right"`, `"home"`, `"end"`,
339 * `"pageup"`, `"pagedown"`, `"insert"`, `"f1"` and up. a letter combined
340 * with `ctrl`, `alt`, or `super` is lowercase with an explicit `shift+`.
341 * a bracketed paste is the key `"paste"`.
342 */
343 key: string;
344 /** what this input types, with `"\n"` for line breaks. `""` if nothing. */
345 text: string;
346}
347
348/**
349 * the object form of a widget's text.
350 * this API will never be altered in a breaking way.
351 */
352export interface WidgetFrame {
353 text: string;
354 /**
355 * where the terminal cursor rests: `line` indexes the lines of `text`, and
356 * `column` counts cells. honored for the widget receiving input.
357 */
358 cursor?: { line: number; column: number };
359}
360
361/**
362 * control for a widget (see {@linkcode startWidget})
363 * this API will never be altered in a breaking way.
364 */
365export interface WidgetInstance<T extends WidgetOptions = WidgetOptions> {
366 /** the provided widget options */
367 options: T;
368 get fps(): number | null;
369 set fps(fps: number | null);
370 /** schedules a new frame to be drawn as soon as possible */
371 redraw(): void;
372 /** remove the widget from the screen */
373 stop(): void;
374 /** alias of `stop` */
375 [Symbol.dispose](): void;
376}
377
378/**
379 * this API will never be altered in a breaking way.
380 */
381export interface WidgetFormatContext {
382 /** the current time according to the widget host */
383 now: ReturnType<typeof performance.now>;
384 /** advisory */
385 width: number;
386 /** advisory */
387 height: number;
388 host?: WidgetHost;
389 /** whether this widget is the one receiving keyboard input */
390 focused?: boolean;
391}
392
393/** {@linkcode widgetHost}'s input takes terminal I/O as well as timing APIs */
394export interface TerminalWidgetHostOptions {
395 /**
396 * an exclusive lock on the terminal is held whenever widgets are active. a
397 * secondary purpose of this is to instrument/deinstrument other code to
398 * integrate with `log.ts`'s widget lock. for example, the node.js adapter
399 * will patch `process.std{out,err}` to ensure write calls properly get a
400 * draw lock.
401 *
402 * this lock can be cleared by deactiving all widgets, or by calling
403 * {@linkcode getDrawLock} to force `@clo/lib` to stop drawing.
404 *
405 * currently, this lock must be able to be synchronously aquired at any
406 * point. if you desire an async locking function, please contact me so we
407 * can design how it would work.
408 */
409 lockTerminal: () => TerminalLock;
410 /** fast path for writing output without widgets */
411 writeOutputTemporaryLock?: (buffer: string) => void;
412 /** monotonic milliseconds */
413 now: () => ReturnType<typeof performance.now>;
414 /** after resolving, `now()` should have increased by the delay time */
415 delay: typeof async.delay;
416 /** is there color support? */
417 color: boolean;
418 /**
419 * whether text passed to `writeOutput` lands on the same screen as the
420 * interactive output. pass `false` when output is redirected to a file or
421 * pipe while the interactive stream remains a terminal; the widget cursor
422 * math then ignores log output entirely, since written rows do not
423 * displace the widget block.
424 * @default true
425 */
426 outputSharesScreen?: boolean;
427 /**
428 * read the keyboard until the returned function is called, passing along
429 * what the terminal sends as decoded text. only called while the terminal
430 * is locked. the terminal must not echo or line-buffer in the meantime
431 * (raw mode). leave undefined if there is no keyboard to read.
432 */
433 observeInput?: ((callback: (data: string) => void) => () => void) | undefined;
434}
435
436/**
437 * when `@clo/lib` requests a lock on the terminal, the adapter provides this
438 * interface to communicate everything about the terminal state correctly.
439 */
440export interface TerminalLock {
441 /**
442 * recieves ANSI escape sequences for interactive data, as well as error log
443 * content from `writeError` (should flush immediately). the interactive
444 * stream and the error stream are the same: stderr.
445 */
446 writeInteractive(text: string): void;
447 /** recieves log content from `write` (pre-buffered; should flush immediately) */
448 writeOutput(text: string): void;
449 /**
450 * subscribe to the terminal size. the callback must fire synchronously
451 * with the current size before this function returns, and again whenever
452 * the size changes (SIGWINCH). returns an unsubscribe function. on a size
453 * change, the widget host erases its stale drawing and repaints, since a
454 * resize rewraps previously drawn rows and invalidates all relative
455 * cursor math.
456 */
457 observeSize(callback: (size: { columns: number; rows: number }) => void): () => void;
458 /** temporarily free the lock */
459 temporaryUnlock?(): () => void;
460 /** completely free the lock */
461 close(): void;
462}
463
464/**
465 * an implementation of an ANSI-based widget host.
466 * this structure must not be altered in a breaking way.
467 */
468export interface WidgetHost {
469 /** see the top-level {@linkcode writeOutput} function */
470 writeOutput(text: string): void;
471 /** see the top-level {@linkcode writeError} function */
472 writeError(text: string): void;
473 /** see the top-level {@linkcode getDrawLock} function */
474 getDrawLock(mode: "long" | "short"): DrawLock;
475 /** see the top-level {@linkcode startWidget} function */
476 startWidget<T extends WidgetOptions>(widget: T): WidgetInstance<T> | null;
477 /** stop all widgets and remove all timers. */
478 cancel(): void;
479 /** generic delay function */
480 delay?: typeof async.delay;
481 /** generic now function */
482 now?: typeof performance.now;
483 /**
484 * what does this widget host support?
485 * - color: ANSI escape sequences are displayed and not stripped
486 * - widget: `startWidget` can meaninglyful display the widget
487 */
488 capabilities: ReadonlyArray<"widget" | "color">;
489}
490
491/** @internal state */
492interface WidgetState {
493 frameTime: number;
494}
495
496// thresholds for flushing the log buffer outside the redraw timer. the 0ms
497// timer batches a synchronous burst of writes into one flush, but waiting on
498// it is wrong in two situations: the buffer growing toward the engine string
499// length limit, and a timer starved by cpu-bound work blocking the event
500// loop (it may never fire).
501const flushSyncBytes = 65536;
502const flushSyncMs = 50;
503
504/** how long a lone escape byte may wait to become the start of a sequence */
505const escapeMs = 50;
506
507/**
508 * terminal widget rendering is done by specifying all system APIs up front in
509 * an interface, creating an instance of the "widget host".
510 */
511export function createTerminalWidgetHost(env: TerminalWidgetHostOptions): WidgetHost {
512 const { lockTerminal, now, delay, writeOutputTemporaryLock, color, observeInput } = env;
513 const outputOnScreen = env.outputSharesScreen ?? true;
514
515 let timer: async.Cancelable<void> | null = null;
516
517 let rendering = false;
518 let pendingRedraw = false;
519 let locks = 0;
520 let redrawTime = 0;
521 let lastFlush = 0;
522 // log text awaiting a flush. error text shares the queue so that relative
523 // write order is preserved, but flushes to the interactive stream (stderr)
524 // and always lands on the widget screen, while plain output rows only
525 // count when `outputSharesScreen`. consecutive same-stream writes merge
526 // into one chunk.
527 let buffer: { text: string; err: boolean }[] = [];
528 // visible width of the trailing partial log line (text since the last "\n"
529 // written to output). the cursor column is derived as `partialWidth %
530 // columns` at draw time, so the value survives resizes and wrapped lines.
531 let partialWidth = 0;
532 let needsToSaveCursor = false;
533 let needsToRestoreCursor = false;
534 const widgets: WidgetOptions[] = [];
535 const internals: WidgetState[] = [];
536 let lines: string[] = [];
537 let hasSyncStart = false;
538 let terminal: TerminalLock | null = null;
539 let size: { columns: number; rows: number } | null = null;
540 let unwatchSize: (() => void) | null = null;
541 let tempUnlock: (() => void) | null = null;
542 // how many rows above its resting place (the line under the widgets) the
543 // cursor was left by a widget's `cursor`, and in which column. all other
544 // cursor math assumes the resting place, so every write begins with
545 // `unpark()`.
546 let parked = 0;
547 let parkedColumn = 0;
548 let unwatchInput: (() => void) | null = null;
549 let escapeTimer: async.Cancelable<void> | null = null;
550 // the terminal is asked once, since an answer that comes after the
551 // keyboard was given back is typed into whatever reads it next
552 let keyboard: "unknown" | "asked" | "enhanced" = "unknown";
553 let xtermjs = false;
554
555 // the size subscription lives exactly as long as the terminal lock, so
556 // acquisition and closing are funneled through these two functions.
557 function acquireTerminal(): TerminalLock {
558 if (!terminal) {
559 let initial = true;
560 size = null;
561 terminal = lockTerminal();
562 unwatchSize = terminal.observeSize((next) => {
563 size = next;
564 if (!initial) handleResize();
565 });
566 ASSERT(size, "TerminalLock.observeSize must call back synchronously with the current size");
567 initial = false;
568 }
569 return terminal;
570 }
571 function closeTerminal() {
572 endInput();
573 unwatchSize?.();
574 unwatchSize = null;
575 terminal?.close();
576 terminal = null;
577 }
578
579 function unpark(): string {
580 const text = parked ? ansi.cursorDown(parked) + "\r" : "";
581 parked = 0;
582 return text;
583 }
584
585 function dispatchInput(events: WidgetInput[]) {
586 for (const event of events) {
587 widgets.findLast((widget) => widget.input)?.input?.(event);
588 }
589 }
590
591 function startInput(term: TerminalLock) {
592 const decoder = new InputDecoder((params, final) => {
593 if (keyboard === "asked" && final === "u" && params.startsWith("?")) {
594 keyboard = "enhanced";
595 term.writeInteractive(keyboardEnhance);
596 }
597 if (final === "c" && params === xtermjsVersion) xtermjs = true;
598 });
599 const unwatch = UNWRAP(observeInput)((data) => {
600 escapeTimer?.cancel();
601 escapeTimer = null;
602 dispatchInput(decoder.push(data));
603 // a widget may have given up the keyboard in response
604 if (decoder.pending && unwatchInput === unwatch) {
605 (escapeTimer = delay(escapeMs)).then(() => {
606 escapeTimer = null;
607 dispatchInput(decoder.flush());
608 });
609 }
610 });
611 unwatchInput = unwatch;
612
613 term.writeInteractive(
614 pasteEnable +
615 (keyboard === "enhanced" ? keyboardEnhance : "") +
616 (keyboard === "unknown" ? keyboardQuery + versionQuery : ""),
617 );
618 if (keyboard === "unknown") keyboard = "asked";
619 }
620
621 function endInput() {
622 if (!unwatchInput) return;
623 escapeTimer?.cancel();
624 escapeTimer = null;
625 UNWRAP(terminal).writeInteractive(
626 (keyboard === "enhanced" ? keyboardUnenhance : "") + pasteDisable,
627 );
628 unwatchInput();
629 unwatchInput = null;
630 }
631
632 function handleResize() {
633 if (lines.length > 0 && terminal) {
634 // the terminal has wrapped what was drawn to its new width. the widths
635 // that were drawn say how many rows that put between the cursor and
636 // the first row. should that row have left the screen, this stops at
637 // the screen's.
638 const { columns } = UNWRAP(size);
639 const wrapped = lines.map((line) =>
640 Math.max(1, Math.ceil(ansi.widthInTerminal(line) / columns)),
641 );
642 const at = lines.length - parked;
643 let up = wrapped.slice(0, at).reduce((sum, rows) => sum + rows, 0);
644 if (parked && !xtermjs) {
645 // the cursor is kept with the text that it was on
646 up += Math.floor(parkedColumn / columns);
647 } else if (parked) {
648 // xterm.js cuts the cursor's line off instead, and moves the cursor
649 // down for every row that it adds, including those under the cursor
650 up += wrapped.slice(at + 1).reduce((sum, rows) => sum + rows - 1, 0);
651 }
652 terminal.writeInteractive(
653 ansi.cursorUp(up) +
654 "\r" +
655 // short of the first cell, since tmux moves a screen that is
656 // cleared from its top left corner into the scrollback
657 ansi.clearFullLine +
658 ansi.cursorRight(1) +
659 ansi.clearToEndOfScreen +
660 "\r",
661 );
662 lines = [];
663 parked = 0;
664 }
665 // the save register holds pre-resize coordinates; restoring it would
666 // jump somewhere unrelated.
667 needsToRestoreCursor = false;
668 requestRedraw();
669 }
670
671 function redrawCallback() {
672 timer = null;
673 ASSERT(!rendering);
674 rendering = true;
675 // the finally is load-bearing: if a render callback or terminal write
676 // throws, `rendering` must reset, or the exit path's cancel() asserts
677 // and masks the original error.
678 try {
679 redrawCallbackInner();
680 } finally {
681 rendering = false;
682 if (pendingRedraw) {
683 pendingRedraw = false;
684 redrawSoon(0);
685 }
686 // cancel() during this render pass (an exit handler unwinding through
687 // a crashed format callback) skips its teardown; finish it here.
688 if (widgets.length === 0 && terminal && !timer && !buffer.length) {
689 lines = [];
690 closeTerminal();
691 }
692 }
693 }
694
695 function redrawCallbackInner() {
696 redrawTime = (lastFlush = now()) - 0.00001; // windows time precision workaround
697
698 // trivial path when not using widgets
699 if (!lines.length && !widgets.length) {
700 if (buffer.length) {
701 needsToRestoreCursor = false;
702 needsToSaveCursor = false;
703 if (!terminal && writeOutputTemporaryLock && !buffer.some((c) => c.err)) {
704 const text = buffer.map((c) => c.text).join("");
705 buffer = [];
706 writeOutputTemporaryLock(text);
707 if (outputOnScreen) trackPartialWidth(text);
708 } else {
709 trackPartialWidth(flushChunks(acquireTerminal()));
710 }
711 }
712 // with nothing to write, this still has to end what a draw lock left
713 // open when it cleared the last widgets
714 if (hasSyncStart) {
715 acquireTerminal().writeInteractive(ansi.syncEnd);
716 hasSyncStart = false;
717 }
718 closeTerminal();
719 return;
720 }
721
722 const term = acquireTerminal();
723 const { columns, rows } = UNWRAP(size);
724 let newWidgetLines: string[] = [];
725 let next = Infinity;
726 const focused = widgets.findLast((widget) => widget.input);
727 // the widget with the keyboard comes first: it gets the rows it needs
728 // before the others share what is left, and it stays where it is while
729 // they come and go under it
730 const order = widgets.filter((widget) => widget !== focused);
731 if (focused) order.unshift(focused);
732 let cursor: WidgetFrame["cursor"];
733 for (const widget of order) {
734 // a format callback may stop another widget, or its own
735 if (!widgets.includes(widget)) continue;
736 let out: string | WidgetFrame | null;
737 try {
738 out = widget.format({
739 now: lastFlush,
740 width: columns,
741 height: rows,
742 focused: widget === focused,
743 });
744 } catch (e) {
745 out = e instanceof Error ? stack.format(e, color) : exceptions.message(e);
746 }
747 const w = widgets.indexOf(widget);
748 if (w === -1) continue;
749 if (out == null) {
750 widgets.splice(w, 1);
751 internals.splice(w, 1);
752 continue;
753 }
754 next = Math.min(next, UNWRAP(internals[w]).frameTime);
755 const outText = typeof out === "string" ? out : out.text;
756 // an empty string is a live widget that currently displays nothing;
757 // only `null` detaches.
758 if (outText === "") continue;
759 const rowsLeft = Math.max(1, rows - newWidgetLines.length - 1);
760 if (rowsLeft === 1) continue;
761 const widgetLines = outText
762 .split("\n")
763 .slice(0, rowsLeft)
764 .map((line) => ansi.trimForTerminal(line, columns - 1));
765 newWidgetLines.push(...widgetLines);
766 const at = widget === focused && typeof out !== "string" ? out.cursor : undefined;
767 if (at && Number.isInteger(at.line) && at.line >= 0 && at.line < widgetLines.length) {
768 cursor = {
769 line: at.line,
770 column: Math.max(0, Math.min(Math.trunc(at.column), columns - 1)),
771 };
772 }
773 }
774 newWidgetLines = newWidgetLines.slice(0, rows - 1);
775 if (next < Infinity) redrawSoon(next);
776
777 if (observeInput && widgets.some((widget) => widget.input)) {
778 if (!unwatchInput) startInput(term);
779 } else {
780 endInput();
781 }
782
783 // a cursor on a line that did not fit on screen stays at rest
784 const park =
785 cursor && cursor.line < newWidgetLines.length ? newWidgetLines.length - cursor.line : 0;
786 const parkText = park ? ansi.cursorUp(park) + ansi.cursorRight(UNWRAP(cursor).column) : "";
787
788 const pCol = partialWidth % columns;
789
790 if (newWidgetLines.length === 0) {
791 if (lines.length > 0) {
792 term.writeInteractive(
793 (hasSyncStart ? "" : ansi.syncStart) +
794 unpark() +
795 // clear the widget space
796 (ansi.cursorUp(1) + ansi.clearFullLine).repeat(lines.length) +
797 (pCol ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") +
798 (needsToRestoreCursor ? ansi.cursorRestore : ""),
799 );
800 hasSyncStart = true;
801 needsToRestoreCursor = false;
802 lines = [];
803 }
804 if (buffer.length) {
805 trackPartialWidth(flushChunks(term));
806 }
807 if (hasSyncStart) {
808 term.writeInteractive(ansi.syncEnd);
809 hasSyncStart = false;
810 }
811 // the last widget may have detached this frame; release the terminal
812 // (and its patches) instead of holding the lock until the next flush
813 if (widgets.length === 0) closeTerminal();
814 return;
815 }
816
817 // concatenation of the buffered text that lands on the widget screen,
818 // which is what the cursor math must account for
819 const screenText = buffer
820 .filter((c) => c.err || outputOnScreen)
821 .map((c) => c.text)
822 .join("");
823 if (buffer.length && screenText === "") {
824 // off-screen output (e.g. stdout redirected to a file) does not
825 // interact with the widget block; flush it plainly and fall through
826 // to a pure widget redraw
827 flushChunks(term);
828 }
829
830 if (buffer.length) {
831 // when writing a buffer alongside widgets, the screen may look like this
832 // > [existing log]
833 // > [optional partial line]
834 // > [widget line 1]
835 // > [widget line 2]
836 // > [widget line 3]
837 // > [cursor is start of this line]
838 //
839 // first, clear out the space where new lines are going to intersect.
840 // `span.rows` measures the cursor descent in physical rows, so log
841 // lines wider than the terminal are accounted for correctly.
842 const span = measureTerminalSpan(screenText, pCol, columns);
843 // if more rows are buffered than there are widgets, only some are
844 // needed. when a partial line exists, the buffer starts on its row
845 // (one above the widget block), hence the -1.
846 const clearLinesTop = Math.min(
847 lines.length,
848 span.rows + (span.endWidth % columns > 0 ? 1 : 0) + (pCol ? -1 : 0),
849 );
850 const oldLines = lines.slice(clearLinesTop);
851 term.writeInteractive(
852 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
853 (hasSyncStart ? "" : ansi.syncStart) +
854 unpark() +
855 (clearLinesTop > 0 || (pCol && lines.length > 0)
856 ? // clear the lines for buffer
857 (clearLinesTop > 0
858 ? ansi.cursorUp(lines.length - clearLinesTop + 1) +
859 ansi.clearFullLine +
860 (ansi.cursorUp(1) + ansi.clearFullLine).repeat(clearLinesTop - 1)
861 : "") +
862 (pCol && lines.length > 0
863 ? ansi.cursorUp(clearLinesTop > 0 ? 1 : lines.length + 1) + ansi.cursorRight(pCol)
864 : "")
865 : ""),
866 );
867 // then write the buffered log content to its streams
868 flushChunks(term);
869 term.writeInteractive(
870 // if the buffer leaves a partial line, the widgets have to go on the
871 // next line. to avoid breaking stdout, the newline gets emitted on
872 // the interactive out. (a line ending exactly on the terminal edge
873 // leaves the cursor wrap-deferred; this newline lands on the next
874 // row, which is also where `span.rows` placed the continuation.)
875 (span.endWidth > 0 ? "\n" : "") +
876 widgetText(newWidgetLines, oldLines) +
877 // rows of a taller widget block that nothing was written over
878 (oldLines.length > newWidgetLines.length ? ansi.clearToEndOfScreen : "") +
879 parkText +
880 ansi.syncEnd,
881 );
882 partialWidth = span.endWidth;
883 } else {
884 const clearLinesBottom = Math.min(
885 lines.length,
886 Math.max(0, lines.length - newWidgetLines.length),
887 );
888 term.writeInteractive(
889 (needsToSaveCursor ? ansi.cursorSave + "\n" : "") +
890 (hasSyncStart ? "" : ansi.syncStart) +
891 unpark() +
892 // the first draw can land just after a partial log line; widgets
893 // must move below it. (`partialWidth` is zero whenever
894 // `needsToSaveCursor` is set, since releasing a draw lock resets
895 // it, so this never combines with the cursorSave newline.)
896 (lines.length === 0 && partialWidth > 0 ? "\n" : "") +
897 // clear the bottom lines
898 (clearLinesBottom
899 ? (ansi.cursorUp(1) + ansi.clearToEndOfLine).repeat(clearLinesBottom)
900 : "") +
901 // skip up the widget space
902 ansi.cursorUp(lines.length - clearLinesBottom) +
903 widgetText(newWidgetLines, lines) +
904 parkText +
905 ansi.syncEnd,
906 );
907 }
908 hasSyncStart = false;
909 needsToRestoreCursor ||= needsToSaveCursor;
910 needsToSaveCursor = false;
911 lines = newWidgetLines;
912 parked = park;
913 parkedColumn = cursor?.column ?? 0;
914 }
915
916 /** `next` drawn from the cursor down, over rows that hold `previous` */
917 function widgetText(next: string[], previous: string[]): string {
918 const rows = next
919 .map(
920 (line, i) =>
921 // a row that is only erased from where the shorter line ends is still
922 // as long as before to tmux, which wraps it on a narrower screen
923 (previous[i] && ansi.widthInTerminal(previous[i]) > ansi.widthInTerminal(line)
924 ? ansi.clearFullLine
925 : "") +
926 (line.includes("\x1b") && !line.endsWith(ansi.reset) ? line + ansi.reset : line) +
927 "\n",
928 )
929 .join("");
930 // a terminal can be narrower than it last said, as its pty is told of a
931 // resize after its screen is. a row that wrapped there would be two.
932 return unwatchInput ? wrapOff + rows + wrapOn : rows;
933 }
934
935 /**
936 * write every buffered chunk to its stream in order, returning the
937 * concatenation of the chunks that landed on the widget screen. error
938 * chunks flush through `writeInteractive` since the interactive stream is
939 * the error stream; plain output rows land on screen only when
940 * `outputSharesScreen`.
941 */
942 function flushChunks(term: TerminalLock): string {
943 let screen = "";
944 for (const chunk of buffer) {
945 if (chunk.err) {
946 term.writeInteractive(chunk.text);
947 screen += chunk.text;
948 } else {
949 term.writeOutput(chunk.text);
950 if (outputOnScreen) screen += chunk.text;
951 }
952 }
953 buffer = [];
954 return screen;
955 }
956
957 /**
958 * update `partialWidth` after `screenText` (already filtered to the chunks
959 * that landed on the widget screen) was written.
960 */
961 function trackPartialWidth(screenText: string) {
962 const i = screenText.lastIndexOf("\n");
963 partialWidth = ansi.widthInTerminal(screenText.slice(i + 1)) + (i === -1 ? partialWidth : 0);
964 }
965
966 function redrawSoon(ms: number) {
967 if (locks > 0 || (ms === 0 && rendering)) return;
968 const newRedrawTime = now() + ms;
969 if (timer) {
970 if (redrawTime < newRedrawTime) return;
971 timer.cancel(); // cancel previous
972 }
973 // 1ms wiggle room, generally runtimes have much larger variance on timers
974 redrawTime = newRedrawTime - 1;
975 timer = delay(ms);
976 timer.then(redrawCallback);
977 }
978
979 function requestRedraw() {
980 if (rendering) pendingRedraw = true;
981 else redrawSoon(0);
982 }
983
984 function flushAndClear(shortTermDrawLock: boolean) {
985 timer?.cancel();
986 timer = null;
987 if (lines.length > 0) {
988 const term = UNWRAP(terminal);
989 const pCol = partialWidth % UNWRAP(size).columns;
990 term.writeInteractive(
991 ansi.syncStart +
992 unpark() +
993 // clear the widget space
994 (ansi.cursorUp(1) + ansi.clearFullLine).repeat(lines.length) +
995 (pCol ? ansi.cursorUp(1) + ansi.cursorRight(pCol) : "") +
996 (shortTermDrawLock ? "" : ansi.syncEnd) +
997 (needsToRestoreCursor ? ansi.cursorRestore : ""),
998 );
999 needsToRestoreCursor = false;
1000 lines = [];
1001 hasSyncStart = shortTermDrawLock;
1002 }
1003 if (buffer.length > 0) {
1004 if (
1005 widgets.length === 0 &&
1006 !terminal &&
1007 writeOutputTemporaryLock &&
1008 !buffer.some((c) => c.err)
1009 ) {
1010 const text = buffer.map((c) => c.text).join("");
1011 buffer = [];
1012 writeOutputTemporaryLock(text);
1013 if (outputOnScreen) trackPartialWidth(text);
1014 } else {
1015 trackPartialWidth(flushChunks(acquireTerminal()));
1016 if (widgets.length === 0) closeTerminal();
1017 }
1018 }
1019 }
1020
1021 /** append a write to the flush buffer, scheduling the flush */
1022 function bufferChunk(chunk: string, err: boolean) {
1023 const last = buffer[buffer.length - 1];
1024 if (last && last.err === err) last.text += chunk;
1025 else buffer.push({ text: chunk, err });
1026 redrawSoon(0);
1027 if (locks > 0 || rendering) return;
1028 if (buffer.reduce((n, c) => n + c.text.length, 0) >= flushSyncBytes) {
1029 flushSync(true);
1030 } else if (timer && now() >= redrawTime + flushSyncMs) {
1031 // the scheduled flush is long overdue, so the event loop is blocked;
1032 // flushing inline keeps logs streaming through cpu-bound work
1033 flushSync(false);
1034 }
1035 }
1036
1037 /**
1038 * run the redraw flush immediately instead of waiting for the timer. with
1039 * `keepPartialLine`, text after the last buffered newline stays in the
1040 * buffer so a line under construction is not displayed mid-way; if the
1041 * buffer holds no newline at all, everything flushes to bound memory.
1042 */
1043 function flushSync(keepPartialLine: boolean) {
1044 let tail: typeof buffer = [];
1045 if (keepPartialLine) {
1046 let i = buffer.length - 1;
1047 while (i >= 0 && !UNWRAP(buffer[i]).text.includes("\n")) i -= 1;
1048 if (i >= 0) {
1049 const edge = UNWRAP(buffer[i]);
1050 const split = edge.text.lastIndexOf("\n") + 1;
1051 tail = buffer.splice(i + 1);
1052 if (split < edge.text.length) {
1053 tail.unshift({ text: edge.text.slice(split), err: edge.err });
1054 edge.text = edge.text.slice(0, split);
1055 }
1056 }
1057 }
1058 timer?.cancel();
1059 timer = null;
1060 redrawCallback();
1061 buffer = tail;
1062 if (tail.length) redrawSoon(0);
1063 }
1064
1065 return {
1066 writeOutput(chunk) {
1067 if (chunk) bufferChunk(chunk, false);
1068 },
1069 writeError(chunk) {
1070 if (chunk) bufferChunk(chunk, true);
1071 },
1072 getDrawLock(mode) {
1073 if (rendering) ASSERT(locks === 0);
1074 if (locks === 0) flushAndClear(mode === "short");
1075 // whoever holds a long lock may read the terminal themselves
1076 if (mode === "long") endInput();
1077 if (locks === 0 && widgets.length > 0 && terminal) {
1078 if (terminal.temporaryUnlock) {
1079 tempUnlock = terminal.temporaryUnlock();
1080 } else {
1081 closeTerminal();
1082 }
1083 }
1084 locks += 1;
1085 let disposed = false;
1086 return {
1087 release(mode) {
1088 if (disposed) return;
1089 disposed = true;
1090 locks -= 1;
1091 needsToSaveCursor ||= mode === "cursor-middle-of-line";
1092 // whatever the lock holder wrote has detached the cursor from any
1093 // previously tracked partial log line. "cursor-start-of-line"
1094 // states the cursor is on a fresh line; "cursor-middle-of-line"
1095 // engages the save/restore dance instead.
1096 partialWidth = 0;
1097 if (locks === 0) {
1098 tempUnlock?.();
1099 tempUnlock = null;
1100 // a redraw is also what lets go of a terminal that the lock
1101 // took from widgets which are gone by now
1102 if (buffer.length > 0 || widgets.length > 0 || terminal || hasSyncStart) {
1103 redrawSoon(0);
1104 }
1105 }
1106 },
1107 [Symbol.dispose]() {
1108 this.release("cursor-middle-of-line");
1109 },
1110 };
1111 },
1112 startWidget(options) {
1113 ASSERT(!widgets.includes(options), "Cannot start the same widget twice.");
1114 let fps = options.fps ?? null;
1115 const state: WidgetState = {
1116 frameTime: 1000 / (fps ?? 0),
1117 };
1118 widgets.push(options);
1119 internals.push(state);
1120 requestRedraw();
1121 return {
1122 options,
1123 get fps() {
1124 return fps;
1125 },
1126 set fps(value) {
1127 fps = value;
1128 state.frameTime = 1000 / (fps ?? 0);
1129 // a pending frame may sit beyond the new cadence; draw to reanchor
1130 requestRedraw();
1131 },
1132 redraw: requestRedraw,
1133 stop() {
1134 const i = widgets.indexOf(options);
1135 if (i === -1) return;
1136 widgets.splice(i, 1);
1137 UNWRAP(internals.splice(i, 1)[0]);
1138 requestRedraw();
1139 },
1140 [Symbol.dispose]() {
1141 this.stop();
1142 },
1143 };
1144 },
1145 cancel() {
1146 // cancel may be reached from exit handlers while a redraw is on the
1147 // stack (a crash inside a render callback unwinds through here); skip
1148 // the flush in that case and just tear down, so the original error
1149 // is the one that surfaces.
1150 if (!rendering) flushAndClear(false);
1151 widgets.splice(0, widgets.length);
1152 internals.splice(0, internals.length);
1153 timer?.cancel();
1154 timer = null;
1155 // the keyboard is given back even then, as this may be the exit
1156 endInput();
1157 if (!rendering) closeTerminal();
1158 },
1159 delay,
1160 now,
1161 capabilities: color ? ["widget", "color"] : ["widget"],
1162 };
1163}
1164
1165/**
1166 * compute the cursor descent (`rows`) and trailing line width (`endWidth`)
1167 * from writing `text` to a terminal `columns` wide, with the cursor starting
1168 * `startWidth` cells into a line. wrapping follows the DECAWM deferred-wrap
1169 * convention shared by modern terminals: a line of exactly `columns` cells
1170 * leaves the cursor pending on the same row, so a newline after it descends
1171 * only one row. a trailing line ending exactly on the boundary reports its
1172 * continuation point at column 0 of the next row (`endWidth % columns === 0`
1173 * with the descended row included in `rows`).
1174 */
1175function measureTerminalSpan(
1176 text: string,
1177 startWidth: number,
1178 columns: number,
1179): { rows: number; endWidth: number } {
1180 let rows = 0;
1181 let width = startWidth;
1182 const parts = text.split("\n");
1183 for (let i = 0; i < parts.length - 1; i += 1) {
1184 width += ansi.widthInTerminal(UNWRAP(parts[i]));
1185 rows += Math.max(1, Math.ceil(width / columns));
1186 width = 0;
1187 }
1188 width += ansi.widthInTerminal(UNWRAP(parts[parts.length - 1]));
1189 rows += Math.floor(width / columns);
1190 return { rows, endWidth: width };
1191}
1192
1193/** starts and stops bracketed paste, around reading the keyboard */
1194const pasteEnable = "\x1b[?2004h";
1195const pasteDisable = "\x1b[?2004l";
1196/**
1197 * asks if the terminal supports the kitty keyboard protocol. one that does
1198 * answers with a report of `"?"` parameters ending in `"u"`.
1199 */
1200const keyboardQuery = "\x1b[?u";
1201/**
1202 * written once the terminal answers, to have it encode keys unambiguously.
1203 * this is what tells `shift+enter` from `enter`.
1204 */
1205const keyboardEnhance = "\x1b[>1u";
1206const keyboardUnenhance = "\x1b[<u";
1207/**
1208 * asks which terminal this is, as xterm would tell its version. xterm.js
1209 * has a fixed answer, and no other way of telling that it is the one.
1210 */
1211const versionQuery = "\x1b[>c";
1212const xtermjsVersion = ">0;276;0";
1213/** stops and starts the terminal continuing on the next row at its last column */
1214const wrapOff = "\x1b[?7l";
1215const wrapOn = "\x1b[?7h";
1216const pasteEnd = "\x1b[201~";
1217
1218/**
1219 * turns what a terminal sends for keyboard input into {@linkcode WidgetInput}:
1220 * legacy xterm sequences, the [kitty keyboard protocol], `modifyOtherKeys`,
1221 * and bracketed paste. stateful, as any of those can be split across reads.
1222 *
1223 * [kitty keyboard protocol]: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
1224 */
1225class InputDecoder {
1226 #report: (params: string, final: string) => void;
1227 /** an escape sequence, or a paste terminator, that is cut off */
1228 #pending = "";
1229 #paste: string | null = null;
1230
1231 /**
1232 * `report` receives control sequences where the terminal describes itself
1233 * instead of a key, split into parameter bytes and the final byte.
1234 */
1235 constructor(report: (params: string, final: string) => void = () => {}) {
1236 this.#report = report;
1237 }
1238
1239 /**
1240 * true when input ended inside an escape sequence. a lone escape byte is
1241 * either the escape key or the start of a sequence, and only time can tell
1242 * them apart: if nothing else arrives shortly, call {@linkcode flush}.
1243 */
1244 get pending(): boolean {
1245 return this.#pending !== "" && this.#paste === null;
1246 }
1247
1248 /** decode the next chunk of terminal input */
1249 push(chunk: string): WidgetInput[] {
1250 const events: WidgetInput[] = [];
1251 const data = this.#pending + chunk;
1252 this.#pending = "";
1253
1254 let i = 0;
1255 while (i < data.length) {
1256 if (this.#paste !== null) {
1257 const end = data.indexOf(pasteEnd, i);
1258 if (end === -1) {
1259 const escape = data.lastIndexOf("\x1b");
1260 const cut = escape >= i && pasteEnd.startsWith(data.slice(escape)) ? escape : data.length;
1261 this.#paste += data.slice(i, cut);
1262 this.#pending = data.slice(cut);
1263 break;
1264 }
1265 const text = this.#paste + data.slice(i, end);
1266 events.push({ key: "paste", text: text.replace(/\r\n?/g, "\n") });
1267 this.#paste = null;
1268 i = end + pasteEnd.length;
1269 continue;
1270 }
1271
1272 const code = data.charCodeAt(i);
1273 if (code === 0x1b) {
1274 const length = this.#escape(data, i, events);
1275 if (length === 0) {
1276 this.#pending = data.slice(i);
1277 break;
1278 }
1279 i += length;
1280 } else if (isControlCode(code)) {
1281 const event = controlKey(code, 0);
1282 if (event) events.push(event);
1283 i += 1;
1284 } else {
1285 let end = i + 1;
1286 while (end < data.length && !isControlCode(data.charCodeAt(end))) end += 1;
1287 graphemes ??= new Intl.Segmenter();
1288 for (const { segment } of graphemes.segment(data.slice(i, end))) {
1289 events.push(printableKey(segment, 0));
1290 }
1291 i = end;
1292 }
1293 }
1294 return events;
1295 }
1296
1297 /** decode input that {@linkcode pending} was waiting on, as it stands */
1298 flush(): WidgetInput[] {
1299 if (!this.pending) return [];
1300 const data = this.#pending;
1301 this.#pending = "";
1302 // a held escape key, or one pressed twice
1303 if (/^\x1b+$/.test(data)) return Array.from(data, () => namedKey("escape", 0));
1304 // anything longer than an alt-prefixed key is a sequence that was cut
1305 // off, which is dropped instead of being keyText out as text
1306 if (data.length > 2) return [];
1307 const code = data.charCodeAt(1);
1308 const event = isControlCode(code) ? controlKey(code, modAlt) : printableKey(data[1]!, modAlt);
1309 return event ? [event] : [];
1310 }
1311
1312 /**
1313 * decode the escape sequence at `data[start]`, returning its length, or
1314 * `0` if it is cut off by the end of `data`.
1315 */
1316 #escape(data: string, start: number, events: WidgetInput[]): number {
1317 // an escape prefix is how legacy terminals encode alt
1318 let last = start;
1319 while (data[last + 1] === "\x1b") last += 1;
1320 const mods = last > start ? modAlt : 0;
1321 const introducer = data[last + 1];
1322 if (introducer === undefined) return 0;
1323
1324 if (introducer === "[") {
1325 let i = last + 2;
1326 // the linux console sends its first function keys as `CSI [ A`
1327 if (data[i] === "[") {
1328 const name = linuxKeys[data[i + 1] ?? ""];
1329 if (name) events.push(namedKey(name, mods));
1330 return i + 1 < data.length ? i + 2 - start : 0;
1331 }
1332 while (inRange(data.charCodeAt(i), 0x30, 0x3f)) i += 1;
1333 const params = data.slice(last + 2, i);
1334 while (inRange(data.charCodeAt(i), 0x20, 0x2f)) i += 1;
1335 if (i >= data.length) return 0;
1336 // a byte that cannot end the sequence aborts it, and is decoded next
1337 if (!inRange(data.charCodeAt(i), 0x40, 0x7e)) return i - start;
1338
1339 const final = data[i]!;
1340 if (params === "200" && final === "~") {
1341 this.#paste = "";
1342 } else if (/^[<=>?]/.test(params)) {
1343 this.#report(params, final);
1344 } else {
1345 const event = csiKey(params, final, mods);
1346 if (event) events.push(event);
1347 }
1348 return i + 1 - start;
1349 }
1350
1351 if (introducer === "O") {
1352 // some terminals put a modifier in front of the final byte
1353 let i = last + 2;
1354 while (inRange(data.charCodeAt(i), 0x30, 0x3b)) i += 1;
1355 if (i >= data.length) return 0;
1356 const name = ss3Keys[data[i]!];
1357 if (name) {
1358 const modifier =
1359 Number(
1360 data
1361 .slice(last + 2, i)
1362 .split(";")
1363 .at(-1),
1364 ) || 1;
1365 events.push(namedKey(name, mods | ((modifier - 1) & keyModifierMask)));
1366 return i + 1 - start;
1367 }
1368 } else if ("]P_^X".includes(introducer)) {
1369 // a string sequence is only recognized when whole, since waiting for
1370 // a terminator would swallow what is keyText after alt+]
1371 const bell = data.indexOf("\x07", last + 2);
1372 const terminator = data.indexOf("\x1b\\", last + 2);
1373 if (bell !== -1 && (terminator === -1 || bell < terminator)) {
1374 return bell + 1 - start;
1375 }
1376 if (terminator !== -1) return terminator + 2 - start;
1377 }
1378
1379 const code = data.codePointAt(last + 1)!;
1380 const event = isControlCode(code)
1381 ? controlKey(code, mods | modAlt)
1382 : printableKey(String.fromCodePoint(code), mods | modAlt);
1383 if (event) events.push(event);
1384 return last - start + (code > 0xffff ? 3 : 2);
1385 }
1386}
1387
1388// modifier bits, numbered as the kitty protocol encodes them
1389const modShift = 1;
1390const modAlt = 2;
1391const modCtrl = 4;
1392/** the lock keys are reported in higher bits, and are not keyModifiers */
1393const keyModifierMask = 0b111111;
1394const keyModifiers: [bit: number, prefix: string][] = [
1395 [modCtrl, "ctrl+"],
1396 [modAlt, "alt+"],
1397 [modShift, "shift+"],
1398 [8, "super+"],
1399 [16, "hyper+"],
1400 [32, "meta+"],
1401];
1402
1403const keyText: Partial<Record<string, string>> = {
1404 enter: "\n",
1405 tab: "\t",
1406 space: " ",
1407};
1408
1409const csiKeys: Partial<Record<string, string>> = {
1410 A: "up",
1411 B: "down",
1412 C: "right",
1413 D: "left",
1414 H: "home",
1415 F: "end",
1416 P: "f1",
1417 Q: "f2",
1418 S: "f4",
1419};
1420
1421const ss3Keys: Partial<Record<string, string>> = { ...csiKeys, R: "f3" };
1422
1423const linuxKeys: Partial<Record<string, string>> = {
1424 A: "f1",
1425 B: "f2",
1426 C: "f3",
1427 D: "f4",
1428 E: "f5",
1429};
1430
1431const tildeKeys: Partial<Record<number, string>> = {
1432 1: "home",
1433 2: "insert",
1434 3: "delete",
1435 4: "end",
1436 5: "pageup",
1437 6: "pagedown",
1438 7: "home",
1439 8: "end",
1440 11: "f1",
1441 12: "f2",
1442 13: "f3",
1443 14: "f4",
1444 15: "f5",
1445 17: "f6",
1446 18: "f7",
1447 19: "f8",
1448 20: "f9",
1449 21: "f10",
1450 23: "f11",
1451 24: "f12",
1452};
1453
1454/** code points that the kitty protocol assigns to keys without text */
1455const functionalKeys: Partial<Record<number, string>> = {
1456 9: "tab",
1457 13: "enter",
1458 27: "escape",
1459 32: "space",
1460 127: "backspace",
1461 57414: "enter",
1462 57417: "left",
1463 57418: "right",
1464 57419: "up",
1465 57420: "down",
1466 57421: "pageup",
1467 57422: "pagedown",
1468 57423: "home",
1469 57424: "end",
1470 57425: "insert",
1471 57426: "delete",
1472};
1473
1474let graphemes: Intl.Segmenter | null = null;
1475
1476function inRange(code: number, min: number, max: number) {
1477 return code >= min && code <= max;
1478}
1479
1480function isControlCode(code: number) {
1481 return code < 0x20 || inRange(code, 0x7f, 0x9f);
1482}
1483
1484function makeKey(base: string, mods: number, text: string): WidgetInput {
1485 const prefix = keyModifiers.map(([bit, name]) => (mods & bit ? name : "")).join("");
1486 return { key: prefix + base, text: mods & ~modShift ? "" : text };
1487}
1488
1489function namedKey(name: string, mods: number): WidgetInput {
1490 // shift+tab goes backwards, which is not a tab to type
1491 return makeKey(name, mods, name === "tab" && mods ? "" : (keyText[name] ?? ""));
1492}
1493
1494function printableKey(grapheme: string, mods: number): WidgetInput {
1495 if (grapheme === " ") return namedKey("space", mods);
1496 if (/^[a-zA-Z]$/.test(grapheme)) {
1497 if (mods & ~modShift) {
1498 if (grapheme !== grapheme.toLowerCase()) mods |= modShift;
1499 grapheme = grapheme.toLowerCase();
1500 } else if (mods) {
1501 mods = 0;
1502 grapheme = grapheme.toUpperCase();
1503 }
1504 }
1505 return makeKey(grapheme, mods, grapheme);
1506}
1507
1508/** a C0 control byte, which is how legacy terminals encode ctrl */
1509function controlKey(code: number, mods: number): WidgetInput | null {
1510 if (code === 0x0d) return namedKey("enter", mods);
1511 if (code === 0x09) return namedKey("tab", mods);
1512 if (code === 0x7f) return namedKey("backspace", mods);
1513 if (code === 0x1b) return namedKey("escape", mods);
1514 if (code === 0x00) return namedKey("space", mods | modCtrl);
1515 if (code > 0x7f) return null;
1516 const letter = String.fromCharCode(code + (code <= 0x1a ? 0x60 : 0x40));
1517 return printableKey(letter, mods | modCtrl);
1518}
1519
1520function csiKey(params: string, final: string, mods: number): WidgetInput | null {
1521 const fields = params.split(";").map((field) => field.split(":").map(Number));
1522 const first = fields[0]?.[0] || 0;
1523 mods |= Math.max(0, (fields[1]?.[0] || 1) - 1) & keyModifierMask;
1524 // key releases are only sent if asked for, but are cheap to rule out
1525 if (fields[1]?.[1] === 3) return null;
1526
1527 if (final === "u" || (final === "~" && first === 27)) {
1528 const [code = 0, shifted = 0] = final === "u" ? fields[0]! : (fields[2] ?? []);
1529 const name =
1530 functionalKeys[code] ?? (inRange(code, 57376, 57398) ? `f${code - 57376 + 13}` : null);
1531 if (name) return namedKey(name, mods);
1532 // the private use area holds keys that have no name here
1533 if (!isCodePoint(code) || isControlCode(code) || inRange(code, 0xe000, 0xf8ff)) return null;
1534 const event =
1535 isCodePoint(shifted) && shifted && mods === modShift
1536 ? printableKey(String.fromCodePoint(shifted), 0)
1537 : printableKey(String.fromCodePoint(code), mods);
1538 const text = final === "u" ? fields[2] : undefined;
1539 if (text?.[0] && text.every(isCodePoint)) event.text = String.fromCodePoint(...text);
1540 return event;
1541 }
1542
1543 if (final === "~") {
1544 const name = tildeKeys[first];
1545 return name ? namedKey(name, mods) : null;
1546 }
1547 if (final === "Z") return namedKey("tab", mods | modShift);
1548 // `CSI row ; column R` is a cursor position report
1549 if (final === "R") return first <= 1 ? namedKey("f3", mods) : null;
1550 const name = csiKeys[final];
1551 return name ? namedKey(name, mods) : null;
1552}
1553
1554/** parameters are whatever digits the terminal sent */
1555function isCodePoint(code: number) {
1556 return Number.isInteger(code) && code <= 0x10ffff;
1557}
1558
1559const logColors = node.process?.stdout.isTTY ?? false;
1560
1561let formatLine = /* @__PURE__ */ (() => {
1562 const fwo = node.builtin("util")?.formatWithOptions;
1563 if (fwo) {
1564 return (...args: unknown[]) => {
1565 if (args[0] instanceof Error) return stack.format(args[0], logColors);
1566 return fwo({ colors: logColors }, ...args);
1567 };
1568 }
1569 return (...args: unknown[]) =>
1570 args
1571 .map((arg) => {
1572 if (typeof arg === "string") return arg;
1573 try {
1574 return JSON.stringify(arg);
1575 } catch {
1576 return "[incompatible json " + typeof arg + "]";
1577 }
1578 })
1579 .join(" ");
1580})();
1581
1582let withinDispatch = false;
1583let withinStackCapture = false;
1584
1585/** this class is an implementation detail */
1586const ScopeImpl = class Scope implements RootScope {
1587 name: string | undefined;
1588 /** attached to every emitted message; see `Scope.scoped` */
1589 #custom: Message["custom"];
1590 // TODO: this abstraction implementation has low performance. making every
1591 // scope define it's own dispatch is needed to correctly implement `tee`.
1592 // since it is possible to implement this in simple and non-recursive way, i
1593 // feel okay adding the `tee` API.
1594 #dispatches: DispatchFunction[] = [];
1595 #dispatch: DispatchFunction = (m) => this.#dispatches.forEach((cb) => cb(m));
1596
1597 constructor(
1598 dispatch: (m: Message) => void,
1599 name: string | undefined = undefined,
1600 custom: Message["custom"] = undefined,
1601 ) {
1602 this.name = name;
1603 this.#custom = custom;
1604 this.#dispatches = [dispatch];
1605 }
1606
1607 #log(level: Message["level"], args: unknown[]) {
1608 let frames;
1609 if (!withinStackCapture) {
1610 withinStackCapture = true;
1611 frames = stack.capture().slice(2);
1612 withinStackCapture = false;
1613 }
1614 this.writeMessage({
1615 level,
1616 scope: this.name,
1617 custom: this.#custom,
1618 get text() {
1619 const value = formatLine(...args);
1620 Object.defineProperty(this, "text", { value });
1621 return value;
1622 },
1623 time: Date.now(),
1624 stack: frames,
1625 [originalLogArgs]: args,
1626 });
1627 }
1628
1629 info: (...args: unknown[]) => void = (...args: unknown[]) => {
1630 this.#log("info", args);
1631 };
1632 warn: (...args: unknown[]) => void = (...args: unknown[]) => {
1633 this.#log("warn", args);
1634 };
1635 error: (...args: unknown[]) => void = (...args: unknown[]) => {
1636 this.#log("error", args);
1637 };
1638 log: (...args: unknown[]) => void = (...args: unknown[]) => {
1639 this.#log("debug", args);
1640 };
1641 debug: (...args: unknown[]) => void = (...args: unknown[]) => {
1642 this.#log("debug", args);
1643 };
1644
1645 writeMessage: (m: Message) => void = (m) => {
1646 if (withinDispatch) return void globalLog.#dispatch(m);
1647 withinDispatch = true;
1648 this.#dispatch(m);
1649 withinDispatch = false;
1650 };
1651
1652 write: (text: string) => void = (text) => {
1653 let frames;
1654 if (!withinStackCapture) {
1655 withinStackCapture = true;
1656 frames = stack.capture().slice(2);
1657 withinStackCapture = false;
1658 }
1659 this.writeMessage({
1660 level: "info",
1661 scope: "",
1662 custom: this.#custom,
1663 newline: false,
1664 text,
1665 time: Date.now(),
1666 stack: frames,
1667 });
1668 };
1669
1670 scoped(name: string, custom?: Message["custom"]): Scope {
1671 const current = this.name;
1672 return new Scope(
1673 this.#dispatch,
1674 name ? (current ? `${current}/${name}` : name) : current,
1675 custom ? { ...this.#custom, ...custom } : this.#custom,
1676 );
1677 }
1678
1679 tee(destination: DispatchFunction) {
1680 this.#dispatches.push(destination);
1681 return ts.defer(() => this.#dispatches.splice(this.#dispatches.indexOf(destination), 1));
1682 }
1683};
1684
1685/**
1686 * global integration is done in a special manner with IIFE expressions to
1687 * ensure that the logic is removed when a bundler tree-shakes this file.
1688 * additionally, this ensures that replacing a global function properly affects
1689 * other installations. this is safe to do because these shared interfaces have
1690 * a commitment to never change in a breaking way.
1691 */
1692interface GlobalCommunication {
1693 readme: string;
1694 /** urls / file paths */
1695 instances: string[];
1696 widget?: {
1697 version: number;
1698 frozen: boolean;
1699 host: WidgetHost;
1700 /** url / file path / line number / identifying information */
1701 source: string;
1702 };
1703 /** overwrite the message writer */
1704 writeMessage?: DispatchFunction;
1705 /** overwrite the message formatter */
1706 formatAnsiMessage?: (m: Message, colors: boolean) => string;
1707 /** calls to the global `tee()` */
1708 tees: Set<DispatchFunction>;
1709}
1710
1711const globalSymbol = /* @__PURE__ */ Symbol.for("@clo/lib/log");
1712const version = 6;
1713let global: GlobalCommunication = /* @__PURE__ */ (() => {
1714 const global = ((globalThis as { [globalSymbol]?: GlobalCommunication })[globalSymbol] ??= {
1715 readme:
1716 'this global holds shared state for "@clo/lib/log", to allow different instances of itself to coordinate with each other',
1717 instances: [],
1718 tees: new Set(),
1719 });
1720 global.instances.push(import.meta.url);
1721 return global;
1722})();
1723
1724function defaultFormatAnsiMessage({ level, scope, text, newline }: Message, colors: boolean) {
1725 if (!text) return "";
1726 if (newline === false) return text;
1727 const prefix = colors
1728 ? // colorful
1729 `${levelToAnsi[level ?? "info"]}${scope ? `(${scope})` : ""}${ansi.fgReset}${ansi.dim}:${ansi.reset} `
1730 : // colorless
1731 scope
1732 ? `${level}(${scope}): `
1733 : `${level}: `;
1734 return prefix + text + "\n";
1735}
1736
1737let initWidgetHost = false;
1738function globalWidgetHost(): WidgetHost {
1739 if (
1740 global.widget &&
1741 (initWidgetHost ||
1742 global.widget.frozen ||
1743 // By default, pick the latest version of the widget host implementation.
1744 // This is most likely to resolve the most issues as possible. To opt out of
1745 // this, import the desired implementation and call its
1746 // `ensureGlobalsArePatched` or `replaceGlobalWidgetHost` method.
1747 global.widget.version >= version)
1748 ) {
1749 initWidgetHost = true;
1750 return global.widget.host;
1751 }
1752 initWidgetHost = true;
1753 global.widget = {
1754 frozen: false,
1755 host: node.process ? defaultNodeProcessWidgetHost(node.process) : defaultFallbackWidgetHost(),
1756 source: import.meta.url,
1757 version,
1758 };
1759 return global.widget.host;
1760}
1761
1762export function defaultNodeProcessWidgetHost(
1763 process: NonNullable<typeof node.process>,
1764 forceWidgetSupport = false,
1765): WidgetHost {
1766 // fallback
1767 if (!forceWidgetSupport && !process.stderr.isTTY) {
1768 return {
1769 writeOutput: (string) => process.stdout.write(string),
1770 writeError: (string) => process.stderr.write(string),
1771 getDrawLock: () => ({
1772 release() {},
1773 [Symbol.dispose]() {},
1774 }),
1775 startWidget: () => null,
1776 cancel: () => {},
1777 capabilities: [],
1778 };
1779 }
1780
1781 const host = createTerminalWidgetHost({
1782 lockTerminal() {
1783 const { stdout, stderr } = process;
1784 let disposed = false;
1785
1786 // patch calls to `process.std{out,err}`
1787 // note: `pipe` uses managed calls to `write`, so this is plenty
1788 const stdoutWrite = stdout.write;
1789 const stderrWrite = stderr.write;
1790 const stdoutEnd = stdout.end;
1791 const stderrEnd = stderr.end;
1792 function patchWriteMethod<T, R, A extends [string | Uint8Array]>(
1793 fn: (this: T, ...args: A) => R,
1794 touchesScreen: boolean,
1795 ) {
1796 return function (this: T, ...args: A) {
1797 using lock = disposed ? null : host.getDrawLock("short");
1798 const ret = fn.apply(this, args);
1799 if (lock) {
1800 // writes to a redirected stream never move the screen cursor
1801 lock.release(
1802 !touchesScreen ||
1803 (typeof args[0] === "string"
1804 ? args[0].endsWith("\n")
1805 : bufferEndsInNewline(args[0]))
1806 ? "cursor-start-of-line"
1807 : "cursor-middle-of-line",
1808 );
1809 }
1810 return ret;
1811 };
1812 }
1813 const newStdoutWrite = (stdout.write = patchWriteMethod(stdoutWrite, stdout.isTTY));
1814 const newStderrWrite = (stderr.write = patchWriteMethod(stderrWrite, true));
1815 function patchEndMethod<T, R, A extends unknown[]>(fn: (this: T, ...args: A) => R) {
1816 return function (this: T, ...args: A) {
1817 using lock = disposed ? null : host.getDrawLock("short");
1818 const ret = fn.apply(this, args);
1819 // TODO: this is not handled correctly, but people rarely close their
1820 // standard I/O! this likely should just disable the library if you
1821 // call end. i'm not particularly worried.
1822 if (lock) lock.release("cursor-start-of-line");
1823 return ret;
1824 };
1825 }
1826 const newStdoutEnd = (stdout.end = patchEndMethod(stdoutEnd));
1827 const newStderrEnd = (stderr.end = patchEndMethod(stderrEnd));
1828
1829 // non-node runtimes will typically implement console in a way that
1830 // doesn't use `node:process`, so it must also get patched. this is
1831 // okay because the lock is re-enterant.
1832 function patchSyncMethod<T, A extends unknown[]>(fn: (this: T, ...args: A) => void) {
1833 return function (this: T, ...args: A) {
1834 using lock = disposed ? null : host.getDrawLock("short");
1835 fn.apply(this, args);
1836 if (lock) lock.release("cursor-start-of-line");
1837 };
1838 }
1839 const console = globalThis.console as Console & Record<string, () => void>;
1840 const restoreConsole: [string, old: () => void, patch: () => void][] = [];
1841 for (const [key, old] of Object.entries(console)) {
1842 if (typeof old !== "function") continue;
1843 try {
1844 const patched = (console[key] = patchSyncMethod(old));
1845 restoreConsole.push([key, old, patched]);
1846 } catch {
1847 /* skip */
1848 }
1849 }
1850
1851 return {
1852 // log output goes to real stdout so it stays redirectable; the
1853 // `outputSharesScreen` option tells the host whether those rows
1854 // land on the widget screen and must be counted by the cursor math
1855 writeOutput: (string) => stdoutWrite.call(stdout, string),
1856 writeInteractive: (string) => stderrWrite.call(stderr, string),
1857 observeSize(callback) {
1858 const emit = () =>
1859 callback({
1860 columns: stderr.columns ?? 80,
1861 rows: stderr.rows ?? 24,
1862 });
1863 emit();
1864 stderr.addListener("resize", emit);
1865 return () => void stderr.removeListener("resize", emit);
1866 },
1867 temporaryUnlock() {
1868 // no action needed: the patched write methods re-enter the draw
1869 // lock, which is re-entrant, so external writes flow correctly
1870 // while the lock is held.
1871 return () => {};
1872 },
1873 close() {
1874 disposed = true;
1875 // leave patches in place if something else tampered with it.
1876 if (stdout.write === newStdoutWrite) stdout.write = stdoutWrite;
1877 if (stderr.write === newStderrWrite) stderr.write = stderrWrite;
1878 if (stdout.end === newStdoutEnd) stdout.end = stdoutEnd;
1879 if (stderr.end === newStderrEnd) stderr.end = stderrEnd;
1880 for (const [key, old, patched] of restoreConsole) {
1881 if (console[key] === patched) console[key] = old;
1882 }
1883 },
1884 };
1885 },
1886 now: () => performance.now(),
1887 delay: async.delay,
1888 color: process.stderr.isTTY,
1889 outputSharesScreen: process.stdout.isTTY,
1890 observeInput: nodeProcessInput(process, (mode) => host.getDrawLock(mode)),
1891 });
1892 process.addListener("beforeExit", () => host.cancel());
1893 process.addListener("exit", () => host.cancel());
1894 return host;
1895}
1896
1897/** reads `process.stdin` in raw mode, if it is a terminal */
1898function nodeProcessInput(
1899 process: NonNullable<typeof node.process>,
1900 getDrawLock: WidgetHost["getDrawLock"],
1901): TerminalWidgetHostOptions["observeInput"] {
1902 // asking `process.stdin` would open the stream for every program
1903 if (!node.builtin("tty")?.isatty(0)) return undefined;
1904
1905 return (callback) => {
1906 const { stdin } = process;
1907 const decoder = new TextDecoder();
1908 const onData = (chunk: Uint8Array | string) =>
1909 callback(typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true }));
1910
1911 // dying to a signal skips the exit handlers, which would leave the
1912 // terminal reading input. a long draw lock puts the terminal back, and
1913 // also removes these listeners, so that listeners running after them
1914 // (and the check below) see what the program itself installed.
1915 const signals = ["SIGINT", "SIGTERM"].map((signal) => ({
1916 signal,
1917 listener() {
1918 const lock = getDrawLock("long");
1919 if (process.listenerCount(signal) === 0) process.kill(process.pid, signal);
1920 async.delay(0).then(() => lock.release("cursor-start-of-line"));
1921 },
1922 }));
1923
1924 const wasRaw = stdin.isRaw;
1925 stdin.setRawMode(true);
1926 stdin.addListener("data", onData);
1927 // a listener alone does not restart a stream that was paused, which is
1928 // how the previous reader left it
1929 stdin.resume();
1930 for (const { signal, listener } of signals) process.prependListener(signal, listener);
1931
1932 return () => {
1933 for (const { signal, listener } of signals) process.removeListener(signal, listener);
1934 stdin.removeListener("data", onData);
1935 stdin.setRawMode(wasRaw);
1936 // a flowing stdin keeps the process alive
1937 if (stdin.listenerCount("data") === 0) stdin.pause();
1938 };
1939 };
1940}
1941
1942function defaultFallbackWidgetHost(): WidgetHost {
1943 let warned = false;
1944 return {
1945 writeOutput: (line: string) => console.log(ansi.strip(line)),
1946 writeError: (line: string) => console.error(ansi.strip(line)),
1947 getDrawLock: () => ({ [Symbol.dispose]() {}, release() {} }),
1948 startWidget: (options) => {
1949 if (!warned) {
1950 console.warn(
1951 '"@clo/lib/log"\'s startWidget was called in an environment ' +
1952 "that does not support the Node.js 'process' API. Widgets " +
1953 "will not be visible.",
1954 );
1955 warned = true;
1956 }
1957 return {
1958 options,
1959 fps: options.fps ?? null,
1960 redraw() {},
1961 stop() {},
1962 [Symbol.dispose]() {},
1963 };
1964 },
1965 cancel: () => {},
1966 capabilities: [],
1967 };
1968}
1969
1970/**
1971 * returns a WidgetHost from `node:process`, but without patching its methods.
1972 * as it leaves the process alone, it does not read the keyboard either.
1973 */
1974export function simpleNodeProcessWidgetHost(
1975 process: NonNullable<typeof node.process>,
1976 forceWidgetSupport = false,
1977): WidgetHost {
1978 return forceWidgetSupport || process.stderr.isTTY
1979 ? createTerminalWidgetHost({
1980 lockTerminal: () => ({
1981 writeOutput: (string) => process.stdout.write(string),
1982 writeInteractive: (string) => process.stderr.write(string),
1983 observeSize(callback) {
1984 const emit = () =>
1985 callback({
1986 columns: process.stderr.columns ?? 80,
1987 rows: process.stderr.rows ?? 24,
1988 });
1989 emit();
1990 process.stderr.addListener("resize", emit);
1991 return () => void process.stderr.removeListener("resize", emit);
1992 },
1993 close() {
1994 // no action needed
1995 },
1996 }),
1997 now: () => performance.now(),
1998 delay: async.delay,
1999 color: process.stderr.isTTY,
2000 outputSharesScreen: process.stdout.isTTY,
2001 })
2002 : {
2003 writeOutput: (string) => process.stdout.write(string),
2004 writeError: (string) => process.stderr.write(string),
2005 getDrawLock: () => ({
2006 release() {},
2007 [Symbol.dispose]() {},
2008 }),
2009 startWidget: () => null,
2010 cancel: () => {},
2011 capabilities: [],
2012 };
2013}
2014
2015function bufferEndsInNewline(buffer: ArrayBufferView | undefined) {
2016 return buffer
2017 ? buffer instanceof Uint8Array
2018 ? buffer.at(-1) === 0xa
2019 : new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength).at(-1) === 0xa
2020 : false;
2021}
2022
2023const levelToAnsi: Record<MessageLevel, string> = {
2024 info: `${ansi.fgBlue}info`,
2025 warn: `${ansi.fgYellow}warn`,
2026 error: `${ansi.fgRed}error`,
2027 debug: `${ansi.dim}dbg`,
2028};
2029
2030const globalLog = /* @__PURE__ */ (() =>
2031 new ScopeImpl((m) => {
2032 if (global.writeMessage) {
2033 global.writeMessage(m);
2034 } else if (node.process) {
2035 // info/debug levels land on stdout, warnings and errors on stderr;
2036 // colors are keyed off the destination stream
2037 const err = (m.level ?? "info") !== "info" && m.level !== "debug";
2038 globalWidgetHost()[err ? "writeError" : "writeOutput"](
2039 formatAnsiMessage(m, (err ? node.process.stderr : node.process.stdout).isTTY),
2040 );
2041 } else {
2042 let { level = "info", [originalLogArgs]: args = [m.text], scope } = m;
2043 if (scope) {
2044 const arg0 = args[0];
2045 const prefix = `[${scope}]`;
2046 if (typeof arg0 === "string") args[0] = `${prefix} ${arg0}`;
2047 else args.unshift(prefix);
2048 }
2049 console[level](...args);
2050 }
2051 global.tees.forEach((cb) => cb(m));
2052 }))();
2053
2054export type DispatchFunction = (message: Message) => void;
2055/**
2056 * agnostic to the backend. should be able to format `newline: false`
2057 * messages without a trailing newline, but not all backends may
2058 * support this.
2059 *
2060 * TODO: change this to a `write`+`flush` pattern?
2061 */
2062export type MessageFormatFunction = (message: Message, colors: boolean) => string;
2063
2064import { ASSERT, UNWRAP } from "./assert.ts";
2065import * as async from "./async.ts";
2066import * as exceptions from "./exception.ts";
2067import * as stack from "./log/stack.ts";
2068import * as node from "./node.ts";
2069import * as ansi from "./string/ansi.ts";
2070import * as ts from "./ts.ts";
packages/clo-lib/log/stack.ts created+432
...@@ -0,0 +1,432 @@
1/**
2 * the default stack trace formatters in node.js, deno, and bun are all
3 * extremely low quality. they don't highlight the most important information
4 * and spend too much space on showing source code. this module provides tools
5 * to {@linkcode parse} and {@linkcode format} errors. see {@linkcode format}'s
6 * comment for design decisions.
7 *
8 * to get a taste of the formatter:
9 * ```ts
10 * try {
11 * processFile(undefined);
12 * } catch (err) {
13 * console.error(stack.parse(err));
14 * console.error(stack.format(err, true));
15 * }
16 *
17 * function processFile(file) {
18 * return fs.createReadStream(file);
19 * }
20 *
21 * import * as fs from "node:fs";
22 * import * as stack from "@clo/lib/log/stack";
23 * ```
24 *
25 * @module
26 */
27
28/** retrieved from `parse` */
29export interface Frame {
30 /** `null` if top-level code or unnamed function. */
31 fn: string | null;
32 /** `null` if from a built in without a module name. */
33 file: string | null;
34 /** `null` if the source provider doesn't have a source location. */
35 line: number | null;
36 /** almost certainly defined if `line` is, but the runtime may omit it */
37 col: number | null;
38}
39
40/**
41 * parse a error stack trace from either v8 (node, deno, chrome),
42 * jsc (safari, bun), or spidermonkey (firefox).
43 *
44 * this function is derived from clover's work on bun's DevServer, which she
45 * derived from stacktracejs. this file is more refined than those two:
46 * https://github.com/oven-sh/bun/blob/b5f31a6ee2f52ea67eabeb61f6e6e71215d55b26/src/bake/client/stack-trace.ts
47 * https://github.com/stacktracejs/error-stack-parser/blob/9f33c224b5d7b607755eb277f9d51fcdb7287e24/error-stack-parser.js
48 *
49 * supports parsing v8, JavaScriptCore, SpiderMonkey, and IE error stack
50 * frames, effectively working in every JavaScript environment. leaves
51 * filesystem urls and paths intact (won't convert 'file:///' to or from a file
52 * path)
53 */
54export function parse(error: Error | string): Frame[] | null {
55 const stack = (error as Error)?.stack ?? error;
56 if (typeof stack !== "string") return null;
57 // a v8 trace begins with the message, which is all there is without frames
58 if (typeof error !== "string" && stack === String(error)) return [];
59 return stack.match(regexV8Stack) ? parseV8OrIe(stack) : parseJscOrSpidermonkey(stack);
60}
61
62/**
63 * capture and store for later, or use to trace the caller. with the
64 * default `trimStart` of `2`, the caller file is `capture()[0]?.file`.
65 */
66export function capture(trimStart = 2): Frame[] {
67 return parse(new Error())?.slice(trimStart) ?? [];
68}
69
70/** an error with a method by this key prints itself, given whether to use colors */
71export const formatSymbol: unique symbol = Symbol.for("@clo/lib/log/stack.format");
72
73/**
74 * convert an error into a human readable and debuggable string.
75 *
76 * this printer puts the error metadata at the top. each stack frame gets two
77 * lines: the absolute path or url, and then one line of source code. having
78 * five lines of context on just the top frame is useless, since five lines is
79 * often not enough context to understand the entire block of code. one line is
80 * all you need to remind yourself what the code on that line was doing. and if
81 * you can see every frame's source code, you can much more easily figure out
82 * what the bug is before opening the editor.
83 *
84 * to make understanding runtime code easier, `node:*` and `ext:deno` paths are
85 * resolved into GitHub source code URLs, and node builtins will also display
86 * their source code inline (via `node:fs` and `process.binding("natives")`).
87 *
88 * with zero external imports, it is safely included as a default error
89 * formatter. it is used not only in `lib/log.ts`, but also in the headless
90 * logger.
91 */
92export function format(error: Error, colors = false): string {
93 if (formatSymbol in error) {
94 return (error as Error & { [formatSymbol](colors: boolean): string })[formatSymbol](colors);
95 }
96 const { name, message, stack: _, ...payload } = error;
97
98 let out =
99 (colors ? ansi.fgRed : "") +
100 (name && name !== "Error" && name !== "TypeError" ? `[${name}] ` : "") +
101 message +
102 (colors ? ansi.fgReset : "") +
103 "\n";
104
105 out += Object.entries(payload)
106 .map(
107 ([k, v]) =>
108 " " + k + (colors ? ansi.style(ansi.dim, ":") : ":") + " " + JSON.stringify(v) + "\n",
109 )
110 .join("");
111
112 let frames = parse(error) ?? [];
113 if (node.isServer && "Bun" in globalThis) {
114 frames = frames.filter(
115 (frame) =>
116 frame.file !== "native" ||
117 !["moduleEvaluation", "loadAndEvaluateModule", "processTicksAndRejections"].includes(
118 frame.fn ?? "",
119 ),
120 );
121 }
122 if (error instanceof AggregateError) {
123 for (const err of error.errors) {
124 out += format(err, colors);
125 }
126 }
127 if (frames.length) {
128 out += frames.map((frame) => formatFrame(frame, colors)).join("\n") + "\n";
129 }
130
131 return out;
132}
133
134/** format a single stack trace frame. see `format` for more details. */
135export function formatFrame(frame: Frame, colors: boolean): string {
136 let { fn, file, line, col } = frame;
137 let out = "";
138 if (file === "native") file = null;
139 const internal =
140 !file ||
141 (node.isServer &&
142 (file.startsWith("node:") ||
143 file.startsWith("bun:") ||
144 file.startsWith("internal:") ||
145 file.startsWith("ext:")));
146
147 if (file) {
148 if (file.startsWith("file://")) file = new URL(file).pathname;
149 const root = node.isServer ? getPackageRoot(file) : null;
150
151 // filename
152 if (
153 node.isServer &&
154 internal &&
155 // all bun internals do not line map
156 ("Bun" in globalThis ||
157 // deno transpiled internals do not line map
158 ("Deno" in globalThis && root && root[2].endsWith(".ts")))
159 ) {
160 line = 0;
161 }
162
163 if (colors) {
164 if (root) {
165 const [pathTo, project] = root;
166 out +=
167 ansi.dim +
168 pathTo +
169 ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) +
170 project +
171 ansi.fgReset;
172 } else if (internal) {
173 out += ansi.dim;
174 }
175 out += root?.[2] ?? file;
176 } else out += root ? root.join("") : file;
177
178 // location
179 if (line) {
180 if (colors) out += ansi.mergeStyles(ansi.dim + ansi.fgYellow);
181 if (root?.[0]?.match(/^https:\/\/github.com/)) {
182 out += "#L" + line;
183 } else {
184 out += ":";
185 out += line;
186 if (line) {
187 out += ":";
188 out += col;
189 }
190 }
191 if (colors) {
192 out += fn ? ansi.fgReset : ansi.mergeStyles(ansi.fgReset + ansi.resetWeight);
193 }
194 } else if (fn) {
195 out += ansi.dim;
196 }
197 } else {
198 if (colors) out += ansi.dim;
199 out += "[unknown source]";
200 if (colors && !fn) out += ansi.resetWeight;
201 }
202
203 if (fn) {
204 if (!colors) {
205 out += " at " + fn;
206 } else if (internal) {
207 out += " at " + fn + ansi.resetWeight;
208 } else {
209 out += " at " + ansi.mergeStyles(ansi.resetWeight + ansi.fgCyan) + fn + ansi.fgReset;
210 }
211 }
212
213 preview: if (node.isServer) {
214 if (!file || !line) break preview;
215 const code = node.isServer ? getSourceCode(file) : undefined;
216 if (!code) break preview;
217 const text = code[line - 1];
218 if (!text) break preview;
219 out += "\n";
220 if (colors) {
221 if (col) {
222 // get a selection where the error happened
223 let l = col - 1;
224 let r = col;
225 while (l > 0 && !/[a-zA-Z0-9_$\s]/.test(text[l]!)) l -= 1;
226 while (l > 1 && /[a-zA-Z0-9_$]/.test(text[l - 1]!)) l -= 1;
227 while (r < text.length && /[a-zA-Z0-9_$]/.test(text[r]!)) r += 1;
228 // expand to special cases
229 let center = text.slice(l, r);
230 if (center === "new") {
231 r += text.slice(r).match(/\s*[a-zA-Z0-9_$]+/)?.[0]?.length ?? 0;
232 }
233 // print it all red, but use dim and bold to highlight the problem
234 const left = text.slice(0, l);
235 center = text.slice(l, r);
236 const right = text.slice(r);
237 const irrelevant = internal || file?.includes("node_modules");
238 const fg = !irrelevant ? ansi.fgRed : "";
239 const bold = !irrelevant ? ansi.bold : ansi.dim + ansi.bold;
240 out +=
241 (left
242 ? ansi.mergeStyles(fg + ansi.dim) + left + ansi.mergeStyles(ansi.resetWeight + bold)
243 : ansi.mergeStyles(fg + bold)) +
244 center +
245 (right ? ansi.mergeStyles(ansi.resetWeight + ansi.dim) + right : "") +
246 ansi.mergeStyles(ansi.resetWeight + ansi.fgReset);
247 } else {
248 out += text;
249 }
250 } else {
251 out += text;
252 }
253 }
254
255 return out;
256}
257
258const existCache = new Map<string, boolean>();
259function getPackageRoot(absPath: string) {
260 const fs = node.builtin("fs");
261 const path = node.builtin("path");
262 const process = node.process;
263 if (!fs || !path || !process) return null;
264
265 const isNodeBuiltin = absPath.startsWith("node:");
266 if ("Bun" in globalThis && (isNodeBuiltin || absPath.startsWith("internal:"))) {
267 return [
268 `https://github.com/oven-sh/bun/blob/bun-v${process.versions.bun}/src/js/`,
269 "",
270 absPath.replace(":", "/") + ".ts",
271 ];
272 }
273 if (isNodeBuiltin) {
274 if ("Deno" in globalThis) {
275 return [
276 `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/node/polyfills/`,
277 "",
278 absPath.slice(5) + ".js",
279 ];
280 }
281 return [
282 `https://github.com/nodejs/node/blob/${process.versions.node}/lib/`,
283 "",
284 absPath.slice(5) + ".js",
285 ];
286 }
287 if (
288 absPath.startsWith("ext:deno_") &&
289 process.versions.deno &&
290 // these files are transpiled without source maps.
291 !absPath.endsWith(".ts.js")
292 ) {
293 const node = absPath.startsWith("ext:deno_node");
294 const filename = node
295 ? absPath.slice("ext:deno_node/".length)
296 : absPath.slice("ext:deno_".length);
297 return [
298 `https://github.com/denoland/deno/blob/v${process.versions.deno}/ext/` +
299 (node ? "node/polyfills/" : ""),
300 "",
301 node && filename.endsWith(".ts.js") ? filename.slice(0, -3) : filename,
302 ];
303 }
304
305 if (!path.isAbsolute(absPath)) return null;
306 let dir = path.dirname(absPath);
307 do {
308 let exists = existCache.get(dir);
309 if (exists == null) {
310 existCache.set(
311 dir,
312 (exists =
313 fs.existsSync(path.join(dir, "package.json")) ||
314 fs.existsSync(path.join(dir, "deno.json"))),
315 );
316 }
317 if (exists) {
318 return [path.dirname(dir) + path.sep, path.basename(dir), absPath.slice(dir.length)] as const;
319 }
320 } while (dir !== (dir = path.dirname(dir)));
321 return "";
322}
323
324const codeCache = new Map<string, string[]>();
325function getSourceCode(file: string) {
326 const fs = node.builtin("fs");
327 const path = node.builtin("path");
328 if (!fs || !path) return null;
329 if (file.startsWith("node:")) {
330 // node.js has a secret binding for all the internal source codes
331 return node.binding("natives")?.[file.slice(5)]?.split("\n") ?? null;
332 } else {
333 // bun and deno do not offer a way to get source code of internal modules,
334 // probably for good reason.
335 //
336 // i originally had an idea to download and transpile the version to
337 // provide a reference and source code, but it's undocumented how deno
338 // compiles their code and i don't think bun's line numbers actually remap
339 // correctly.
340 }
341 if (!path.isAbsolute(file)) return null;
342 let cache = codeCache.get(file);
343 if (cache == null) {
344 try {
345 codeCache.set(file, (cache = fs.readFileSync(file, "utf-8").split("\n")));
346 } catch {}
347 }
348 return cache;
349}
350
351function parseV8OrIe(stack: string): Frame[] {
352 return stack
353 .split("\n")
354 .filter((line) => !!line.match(regexV8Stack))
355 .map(function (line) {
356 let sanitizedLine = line
357 .replace(/^\s+/, "")
358 .replace(/\(eval code/g, "(")
359 .replace(/^.*?\s+/, "");
360
361 // capture and preserve the parenthesized location "(/foo/my bar.js:12:87)" in
362 // case it has spaces in it, as the string is split on \s+ later on
363 const loc = sanitizedLine.match(/ (\(.+\)$)/);
364
365 // remove the parenthesized location from the line, if it was matched
366 sanitizedLine = loc ? sanitizedLine.replace(loc[0], "") : sanitizedLine;
367
368 // if a location was matched, pass it to extractLocation() otherwise pass all sanitizedLine
369 // because this line doesn't have function name
370 // an anonymous async frame is "async <location>"
371 const locationParts = extractLocation(loc?.[1] ?? sanitizedLine.replace(/^async /, ""));
372 const functionName = (loc && sanitizedLine) || undefined;
373 const fileName =
374 ["eval", "<anonymous>"].indexOf(locationParts[0]) > -1 ? undefined : locationParts[0];
375
376 return {
377 fn: functionName ?? null,
378 file: fileName,
379 line: 0 | locationParts[1],
380 col: 0 | locationParts[2],
381 } satisfies Frame;
382 });
383}
384
385function parseJscOrSpidermonkey(stack: string): Frame[] {
386 // Using string literal "\n" does not work in Safari.
387 return stack.split(/\n/g).map((source) => {
388 let fn: string | null = "";
389 let file: string | null = null;
390 let line: number | null = null;
391 let col: number | null = null;
392 if (source.endsWith("@")) {
393 // Safari eval frames only have function names and nothing else
394 fn = source.slice(0, -1);
395 } else if (source.indexOf("@") === -1 && source.indexOf(":") === -1) {
396 // Safari eval frames only have function names and nothing else
397 fn = source.endsWith("@") ? source.slice(0, -1) : source;
398 } else {
399 const matches = source.match(regexFunctionName);
400 const functionName = matches && matches[1] ? matches[1] : undefined;
401 const locationParts = extractLocation(source.replace(regexFunctionName, ""));
402 fn = functionName!;
403 file = locationParts[0];
404 line = 0 | locationParts[1];
405 col = 0 | locationParts[2];
406 }
407 if (fn === "module code") fn = null;
408 return {
409 fn,
410 file,
411 line,
412 col,
413 };
414 });
415}
416
417function extractLocation(urlLike: string) {
418 // Fail-fast but return locations like "(native)"
419 if (urlLike.indexOf(":") === -1) {
420 return [urlLike];
421 }
422
423 const parts: any = regexLocation.exec(urlLike.replace(/[()]/g, ""));
424 return [parts[1], parts[2] || undefined, parts[3] || undefined];
425}
426
427const regexFunctionName = /((.*".+"[^@]*)?[^@]*)(?:@)/;
428const regexV8Stack = /^\s*at .*(\S+:\d+|\(native\))/m;
429const regexLocation = /(.+?)(?::(\d+))?(?::(\d+))?$/;
430
431import * as node from "../node.ts";
432import * as ansi from "../string/ansi.ts";
packages/clo-lib/math.ts created+8
...@@ -0,0 +1,8 @@
1export function gcd(a: number, b: number): number {
2 while (b !== 0) [a, b] = [b, a % b];
3 return a;
4}
5
6export function clamp(value: number, min: number, max: number): number {
7 return Math.min(max, Math.max(min, value));
8}
packages/clo-lib/node.ts created+289
...@@ -0,0 +1,289 @@
1/**
2 * functions to load Node.js apis via `globalThis.process`. trivially bundlable
3 * for the browser. does not depend on `@types/node` and does not intend to
4 * define types for the entire api. instead, this file's types are used for
5 * other library modules like `lib/log.ts` to bind to the system.
6 *
7 * if you are using a competent bundler, you can define `globalThis.process` as
8 * a bundling constant (esbuild: `--define`) to enable tree shaking across the
9 * library to only include browser code paths.
10 *
11 * @module
12 */
13
14export const process: Process | undefined =
15 (globalThis as { process?: Process }).process &&
16 (globalThis as { process?: Process }).process?.versions?.node
17 ? (globalThis as { process?: Process }).process
18 : undefined;
19
20export const isServer: boolean = !!process;
21
22/** partial types for Node.js `globalThis.process` */
23interface Process {
24 getBuiltinModule<K extends keyof Builtins>(name: K): Builtins[K] | null;
25 binding?<K extends keyof Bindings>(name: K): Bindings[K] | null;
26 addListener(event: string, callback: () => void): this;
27 prependListener(event: string, callback: () => void): this;
28 removeListener(event: string, callback: () => void): this;
29 listenerCount(event: string): number;
30 kill(pid: number, signal: string): boolean;
31 env: Partial<Record<string, string>>;
32 pid: number;
33 ppid: number;
34 platform: string;
35 versions: { node?: string; bun?: string; deno?: string };
36 stdin: TtyInput;
37 stdout: Tty;
38 stderr: Tty;
39}
40
41/** the raw mode members are only present when the stream is a tty */
42interface TtyInput {
43 isRaw: boolean;
44 setRawMode(mode: boolean): this;
45 addListener(event: "data", callback: (chunk: Uint8Array | string) => void): this;
46 removeListener(event: "data", callback: (chunk: Uint8Array | string) => void): this;
47 listenerCount(event: string): number;
48 pause(): this;
49 resume(): this;
50}
51
52interface Tty {
53 isTTY: boolean;
54 /** only present when the stream is a tty */
55 columns: number | undefined;
56 /** only present when the stream is a tty */
57 rows: number | undefined;
58 addListener(event: string, callback: () => void): this;
59 removeListener(event: string, callback: () => void): this;
60 end(text?: string | Uint8Array): this;
61 write(text: string | Uint8Array): boolean;
62}
63
64/**
65 * Subset of Node.js builtin types needed to not depend on the problematic
66 * "@types/node" package.
67 */
68interface Builtins {
69 child_process: {
70 spawnSync(command: string, args: string[], options: { stdio: "ignore" }): unknown;
71 };
72 fs: {
73 existsSync(file: string): boolean;
74 readFileSync(file: string, encoding: "utf-8"): string;
75 readFileSync(file: string): Uint8Array;
76 writeFileSync(file: string, data: string, options?: { mode?: number }): void;
77 appendFileSync(file: string, data: string, options?: { mode?: number }): void;
78 writeSync(fd: number, data: string): number;
79 mkdirSync(dir: string, options: { recursive: true }): void;
80 };
81 inspector: {
82 Session: new () => {
83 connect(): void;
84 post(
85 method: string,
86 params: object,
87 // the result is whatever the Chrome DevTools Protocol method returns
88 callback: (error: Error | null, result: any) => void,
89 ): void;
90 };
91 };
92 path: {
93 isAbsolute(file: string): boolean;
94 dirname(file: string): string;
95 basename(file: string): string;
96 join(...parts: string[]): string;
97 resolve(...parts: string[]): string;
98 sep: string;
99 };
100 tty: {
101 isatty(fd: number): boolean;
102 };
103 url: {
104 pathToFileURL(file: string): URL;
105 };
106 util: {
107 inspect(object: unknown, options?: { colors?: boolean }): string;
108 formatWithOptions(options: { colors?: boolean }, ...args: unknown[]): string;
109 };
110 stream: {
111 Readable: {
112 toWeb(nodeStream: unknown): ReadableStream;
113 fromWeb(webStream: ReadableStream): { pipe: (other: unknown) => void };
114 };
115 Writable: {
116 prototype: {
117 write: (value: string) => boolean;
118 };
119 };
120 };
121}
122
123/** subset of Node.js binding types */
124interface Bindings {
125 /**
126 * key value mapping of internal module id to its source code.
127 * ```
128 * { "fs": "// Copyright Joyent, Inc. and other Node contributors.\n...
129 * ```
130 */
131 natives: Partial<Record<string, string>>;
132}
133
134/** return a built-in module */
135export function builtin<K extends keyof Builtins>(name: K): Builtins[K] | undefined {
136 return process?.getBuiltinModule(name) ?? undefined;
137}
138
139/** return a built-in semi-private binding */
140export function binding<K extends keyof Bindings>(name: K): Bindings[K] | undefined {
141 if (!process) return undefined;
142 try {
143 return process.binding?.(name) ?? undefined;
144 } catch {
145 return undefined;
146 }
147}
148
149/** copy of `keyof typeof import('node:os').constants.errno` */
150// deno-fmt-ignore-next
151export type ErrorCode =
152 | (string & {})
153 | "E2BIG"
154 | "EACCES"
155 | "EADDRINUSE"
156 | "EADDRNOTAVAIL"
157 | "EAFNOSUPPORT"
158 | "EAGAIN"
159 | "EALREADY"
160 | "EBADF"
161 | "EBADMSG"
162 | "EBUSY"
163 | "ECANCELED"
164 | "ECHILD"
165 | "ECONNABORTED"
166 | "ECONNREFUSED"
167 | "ECONNRESET"
168 | "EDEADLK"
169 | "EDESTADDRREQ"
170 | "EDOM"
171 | "EDQUOT"
172 | "EEXIST"
173 | "EFAULT"
174 | "EFBIG"
175 | "EHOSTUNREACH"
176 | "EIDRM"
177 | "EILSEQ"
178 | "EINPROGRESS"
179 | "EINTR"
180 | "EINVAL"
181 | "EIO"
182 | "EISCONN"
183 | "EISDIR"
184 | "ELOOP"
185 | "EMFILE"
186 | "EMLINK"
187 | "EMSGSIZE"
188 | "EMULTIHOP"
189 | "ENAMETOOLONG"
190 | "ENETDOWN"
191 | "ENETRESET"
192 | "ENETUNREACH"
193 | "ENFILE"
194 | "ENOBUFS"
195 | "ENODATA"
196 | "ENODEV"
197 | "ENOENT"
198 | "ENOEXEC"
199 | "ENOLCK"
200 | "ENOLINK"
201 | "ENOMEM"
202 | "ENOMSG"
203 | "ENOPROTOOPT"
204 | "ENOSPC"
205 | "ENOSR"
206 | "ENOSTR"
207 | "ENOSYS"
208 | "ENOTCONN"
209 | "ENOTDIR"
210 | "ENOTEMPTY"
211 | "ENOTSOCK"
212 | "ENOTSUP"
213 | "ENOTTY"
214 | "ENXIO"
215 | "EOPNOTSUPP"
216 | "EOVERFLOW"
217 | "EPERM"
218 | "EPIPE"
219 | "EPROTO"
220 | "EPROTONOSUPPORT"
221 | "EPROTOTYPE"
222 | "ERANGE"
223 | "EROFS"
224 | "ESPIPE"
225 | "ESRCH"
226 | "ESTALE"
227 | "ETIME"
228 | "ETIMEDOUT"
229 | "ETXTBSY"
230 | "EWOULDBLOCK"
231 | "EXDEV"
232 | "WSAEINTR"
233 | "WSAEBADF"
234 | "WSAEACCES"
235 | "WSAEFAULT"
236 | "WSAEINVAL"
237 | "WSAEMFILE"
238 | "WSAEWOULDBLOCK"
239 | "WSAEINPROGRESS"
240 | "WSAEALREADY"
241 | "WSAENOTSOCK"
242 | "WSAEDESTADDRREQ"
243 | "WSAEMSGSIZE"
244 | "WSAEPROTOTYPE"
245 | "WSAENOPROTOOPT"
246 | "WSAEPROTONOSUPPORT"
247 | "WSAESOCKTNOSUPPORT"
248 | "WSAEOPNOTSUPP"
249 | "WSAEPFNOSUPPORT"
250 | "WSAEAFNOSUPPORT"
251 | "WSAEADDRINUSE"
252 | "WSAEADDRNOTAVAIL"
253 | "WSAENETDOWN"
254 | "WSAENETUNREACH"
255 | "WSAENETRESET"
256 | "WSAECONNABORTED"
257 | "WSAECONNRESET"
258 | "WSAENOBUFS"
259 | "WSAEISCONN"
260 | "WSAENOTCONN"
261 | "WSAESHUTDOWN"
262 | "WSAETOOMANYREFS"
263 | "WSAETIMEDOUT"
264 | "WSAECONNREFUSED"
265 | "WSAELOOP"
266 | "WSAENAMETOOLONG"
267 | "WSAEHOSTDOWN"
268 | "WSAEHOSTUNREACH"
269 | "WSAENOTEMPTY"
270 | "WSAEPROCLIM"
271 | "WSAEUSERS"
272 | "WSAEDQUOT"
273 | "WSAESTALE"
274 | "WSAEREMOTE"
275 | "WSASYSNOTREADY"
276 | "WSAVERNOTSUPPORTED"
277 | "WSANOTINITIALISED"
278 | "WSAEDISCON"
279 | "WSAENOMORE"
280 | "WSAECANCELLED"
281 | "WSAEINVALIDPROCTABLE"
282 | "WSAEINVALIDPROVIDER"
283 | "WSAEPROVIDERFAILEDINIT"
284 | "WSASYSCALLFAILURE"
285 | "WSASERVICE_NOT_FOUND"
286 | "WSATYPE_NOT_FOUND"
287 | "WSA_E_NO_MORE"
288 | "WSA_E_CANCELLED"
289 | "WSAEREFUSED";
packages/clo-lib/package.json created+18
...@@ -0,0 +1,18 @@
1{
2 "name": "@clo/lib",
3 "private": true,
4 "license": "ISC",
5 "type": "module",
6 "exports": {
7 "./progress": "./progress.ts",
8 "./progress/browser": "./progress-browser.ts"
9 },
10 "scripts": {
11 "typecheck": "tsc-rs --noEmit",
12 "test": "vp test run --config ../../vite.config.ts --dir ."
13 },
14 "devDependencies": {
15 "@types/node": "catalog:",
16 "vite-plus": "catalog:"
17 }
18}
packages/clo-lib/progress-browser.ts created+373
...@@ -0,0 +1,373 @@
1// @effect-diagnostics globalDate:off globalTimers:off - Vendored @clo/lib has no Effect dependency.
2import { UNWRAP } from "./assert.ts";
3import type { Events } from "./Events.ts";
4import type * as log from "./log.ts";
5import * as math from "./math.ts";
6import type { ReadOnlyNode, Root } from "./progress.ts";
7import { isVisible, maxLogLines, remainingSeconds } from "./progress-format.ts";
8import * as string from "./string.ts";
9import * as ansi from "./string/ansi.ts";
10import * as ts from "./ts.ts";
11
12/**
13 * draw `root` into the shadow root of `host`, after the Windows 7 progress bar
14 * and busy cursor, on the animation frame after each change, until disposed.
15 * the shadow root keeps the page and the tree from styling each other, and the
16 * tree follows the page's `color-scheme`. `host` must accept a shadow root,
17 * such as a `div`; one rendered by {@linkcode formatHtml} is taken over.
18 * ```ts
19 * const root = new progress.Root();
20 * const detach = progress.attachToDocument(root, document.getElementById("progress")!);
21 * await build(root);
22 * detach();
23 * ```
24 */
25export function attachToDocument<R, M extends Events.Map>(
26 root: Root<R, M>,
27 host: Element,
28): ts.Dispose {
29 const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
30 shadow.innerHTML = formatShadow("");
31 const tree = UNWRAP(shadow.lastElementChild);
32 const template = document.createElement("template");
33 const elements = new WeakMap<ReadOnlyNode, Element>();
34 const changedAt = new WeakMap<ReadOnlyNode, number>();
35 const staleLogs = new Set<ReadOnlyNode>();
36 let frame: number | null = null;
37 let tick: ts.Timer | undefined;
38 let tickIn = Infinity;
39
40 const redraw = () => void (frame ??= requestAnimationFrame(draw));
41 function draw() {
42 frame = null;
43 for (const node of staleLogs) {
44 const logs = elements.get(node)?.children[2];
45 if (logs) logs.innerHTML = formatLogs(node.logs);
46 }
47 staleLogs.clear();
48 clearTimeout(tick);
49 tickIn = Infinity;
50 sync(tree, root.active);
51 if (tickIn !== Infinity) tick = setTimeout(redraw, tickIn);
52 }
53 function sync(list: Element, nodes: readonly ReadOnlyNode[]) {
54 let next = list.firstElementChild;
55 for (const node of nodes) {
56 if (!isVisible(node)) continue;
57 // estimates show whole seconds, rounded, so each changes on a half second
58 const ms = (node.estimatedTime ?? 0) - Date.now();
59 if (ms > 0) tickIn = Math.min(tickIn, ((ms + 500) % 1000) + 5);
60 let li = elements.get(node);
61 if (li) {
62 update(li, node, changedAt);
63 } else {
64 template.innerHTML = formatNode(node, "");
65 elements.set(node, (li = UNWRAP(template.content.firstElementChild)));
66 changedAt.set(node, performance.now());
67 }
68 if (li === next) next = li.nextElementSibling;
69 else list.insertBefore(li, next);
70 sync(UNWRAP(li.children[1]), node.children);
71 }
72 while (next) {
73 const stale = next;
74 next = next.nextElementSibling;
75 stale.remove();
76 }
77 }
78
79 const listeners = [
80 root.on("node-start", redraw),
81 root.on("node-change", (node, key) => {
82 if (key === "logs") staleLogs.add(node);
83 redraw();
84 }),
85 root.on("node-end", redraw),
86 ];
87 draw();
88 return ts.defer(() => {
89 for (const off of listeners) off();
90 if (frame !== null) cancelAnimationFrame(frame);
91 clearTimeout(tick);
92 shadow.replaceChildren();
93 });
94}
95
96/**
97 * the markup {@linkcode attachToDocument} draws for `list`, as a declarative
98 * shadow root to place inside the element it later takes over. browsers attach
99 * it only while parsing a document, such as a server rendered page.
100 */
101export function formatHtml(list: readonly ReadOnlyNode[]): string {
102 const format = (nodes: readonly ReadOnlyNode[]): string =>
103 nodes
104 .filter(isVisible)
105 .map((node) => formatNode(node, format(node.children)))
106 .join("");
107 return `<template shadowrootmode="open">${formatShadow(format(list))}</template>`;
108}
109
110const formatShadow = (items: string) =>
111 `<style>${css}</style><ol class=tree role=list>${items}</ol>`;
112
113/** the parts of a node's markup that change after it is drawn */
114function fields(node: ReadOnlyNode) {
115 const fraction =
116 node.showTotal && node.total > 0 ? math.clamp(node.value / node.total, 0, 1) : null;
117 const remaining = remainingSeconds(node);
118 return {
119 className: fraction === null ? "spin" : fraction === 1 ? "bar full" : "bar",
120 fraction,
121 count: formatCount(node),
122 eta: remaining
123 ? string.formatDurationLetters(remaining).replace(/(?<=\D)(?=\d)/g, " ") + " left"
124 : "",
125 };
126}
127
128/** `attachToDocument` relies on this layout: row, children, logs */
129function formatNode(node: ReadOnlyNode, children: string): string {
130 const { className, fraction, count, eta } = fields(node);
131 const id = `n${node.key}`;
132 const value =
133 fraction === null
134 ? ""
135 : ` aria-valuenow="${node.value}" aria-valuemax="${node.total}" style="--p:${fraction}"`;
136 return (
137 `<li class="${className}"><div class=row>` +
138 `<div class=indicator role=progressbar aria-labelledby=${id}${value}><i></i></div>` +
139 `<span class=count><b>${esc(count[0])}</b><span>${esc(count[1])}</span></span>` +
140 `<span class=eta>${eta}</span>` +
141 `<span class=label id=${id}>${esc(node.text)}</span>` +
142 `</div><ol role=list>${children}</ol><div class=logs>${formatLogs(node.logs)}</div></li>`
143 );
144}
145
146/**
147 * bring `li` from {@linkcode formatNode} up to date, except its children and
148 * logs. `changedAt` holds when each bar last moved.
149 */
150function update(li: Element, node: ReadOnlyNode, changedAt: WeakMap<ReadOnlyNode, number>) {
151 const { className, fraction, count, eta } = fields(node);
152 const [indicator, counter, estimate, label] = UNWRAP(li.firstElementChild).children;
153 const [value, total] = UNWRAP(counter).children;
154 const progressbar = UNWRAP(indicator);
155 setAttribute(li, "class", className);
156 setText(UNWRAP(value), count[0]);
157 setText(UNWRAP(total), count[1]);
158 setText(UNWRAP(estimate), eta);
159 setText(UNWRAP(label), node.text);
160 if (fraction === null) {
161 for (const name of ["aria-valuenow", "aria-valuemax", "style"])
162 setAttribute(progressbar, name, null);
163 return;
164 }
165 const shown = math.clamp(
166 Number(progressbar.getAttribute("aria-valuenow")) /
167 Number(progressbar.getAttribute("aria-valuemax")),
168 0,
169 1,
170 );
171 setAttribute(progressbar, "aria-valuenow", String(node.value));
172 setAttribute(progressbar, "aria-valuemax", String(node.total));
173 if (fraction === shown) return;
174 // a forward glide lasts as long as the last update took to arrive, so steady
175 // progress moves steadily; going back snaps, as the original does
176 const now = performance.now();
177 const glide = fraction > shown ? Math.min(now - (changedAt.get(node) ?? now), 1000) : 0;
178 changedAt.set(node, now);
179 setAttribute(progressbar, "style", `--p:${fraction};--glide:${Math.round(glide)}ms`);
180}
181
182function setAttribute(element: Element, name: string, value: string | null) {
183 if (element.getAttribute(name) === value) return;
184 if (value === null) element.removeAttribute(name);
185 else element.setAttribute(name, value);
186}
187
188function setText(element: Element, text: string) {
189 if (element.textContent !== text) element.textContent = text;
190}
191
192/** `[value, total]`, split at the formatter's slash so the total can be styled apart */
193function formatCount({ value, total, showTotal, valueFormatter }: ReadOnlyNode): [string, string] {
194 const text = valueFormatter(value, total);
195 const slash = text.lastIndexOf("/");
196 if (slash === -1) return [text, ""];
197 return [text.slice(0, slash), showTotal ? text.slice(slash + 1) : ""];
198}
199
200/** the last lines of `logs`. partial writes join into lines of their own. */
201function formatLogs(logs: readonly log.Message[]): string {
202 let start = logs.length;
203 for (let breaks = 0; start > 0 && breaks <= maxLogLines;) {
204 const { text, newline } = UNWRAP(logs[--start]);
205 breaks += string.countNewlines(text) + (newline === false ? 0 : 1);
206 }
207 const lines: { kind: string; html: string }[] = [];
208 let partial: { kind: string; html: string } | null = null;
209 for (const { text, newline, level = "info", scope } of logs.slice(start)) {
210 ansi
211 .toHtml(text)
212 .split("\n")
213 .forEach((html, i) => {
214 if (newline !== false) {
215 // a message starts its own line, so a write's trailing newline leaves no blank one
216 if (i === 0 && partial?.html === "") lines.pop();
217 const prefix = i === 0 && scope ? `<span class=scope>${esc(scope)}</span> ` : "";
218 lines.push({ kind: i === 0 ? level : "more", html: prefix + html });
219 } else if (i === 0 && partial) {
220 partial.html += html;
221 } else {
222 lines.push((partial = { kind: "raw", html }));
223 }
224 });
225 if (newline !== false) partial = null;
226 }
227 while (lines.at(-1)?.html === "") lines.pop();
228 return lines
229 .slice(-maxLogLines)
230 .map(({ kind, html }) => `<div class=${kind}>${html}</div>`)
231 .join("");
232}
233
234const esc = string.escapeHtmlContent;
235
236/** inline svg for a background image */
237const svg = (body: string) =>
238 `url("data:image/svg+xml,${encodeURIComponent(
239 `<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'>${body}</svg>`,
240 )}")`;
241
242// light-dark() resolves against `color-scheme`, which is inherited from the
243// host; `all: initial` keeps every other inherited property out.
244const css = /* @__PURE__ */ (() => `
245:host{display:block}
246.tree{all:initial;display:block;color-scheme:inherit;container-type:inline-size;
247 font:12px/23px "Segoe UI",system-ui,sans-serif;color:var(--progress-foreground,light-dark(#1e1e1e,#e3e5e8));
248 --line:var(--progress-border,light-dark(#a9b3bd,#5a626c));--muted:var(--progress-muted,light-dark(#56616c,#a4aeb9));${ansi.htmlColors}}
249ol{margin:0;padding:0}
250li{display:flow-root;position:relative}
251.tree>li+li{margin-top:4px}
252.row{display:flex;flex-wrap:wrap;align-items:center;column-gap:6px;min-height:23px;white-space:nowrap}
253.label{overflow:hidden;text-overflow:ellipsis;min-width:0}
254.tree>li>.row>.label{font-size:13px;color:var(--progress-accent,light-dark(#039,#8fb4ff))}
255.count,.eta{flex:none;margin:4px 0;padding:0 6px;border-radius:8px;font-size:11px;line-height:15px;
256 font-variant-numeric:tabular-nums;color:var(--muted);
257 background:linear-gradient(light-dark(#fdfeff,#363b43),light-dark(#edf1f5,#2b3037));
258 box-shadow:inset 0 0 0 1px light-dark(#d5dce4,#40474f)}
259.count:has(>b:empty),.eta:empty{display:none}
260.count>b{font-weight:600;color:var(--progress-foreground,light-dark(#2c3640,#d5dbe1))}
261.count>span:not(:empty)::before{content:"/";margin:0 .2em;opacity:.55}
262
263/* bars lead the row, wider at the top level, as in the terminal. the track is
264 a 1px frame, 6px of gloss, a stretched body, and 5px of bottom light */
265.tree>li{--bar:176}
266li li{--bar:96}
267@container (width < 400px){.tree>li{--bar:112}li li{--bar:64}}
268.bar>.row>.indicator{position:relative;flex:none;box-sizing:border-box;width:calc(var(--bar) * 1px);
269 height:15px;margin:4px 0;border:1px solid transparent;border-radius:2px;
270 background:
271 linear-gradient(light-dark(#f3f3f3,#34383e),light-dark(#fcfcfc,#3b3f45) 1.5px,
272 light-dark(#dbdbdb,#2f3237) 6px,light-dark(#cacaca,#26292d) 6px,
273 light-dark(#cacaca,#26292d) calc(100% - 5px),light-dark(#d5d5d5,#2b2e33) calc(100% - 1px),
274 light-dark(#eaeaea,#33373c)) padding-box,
275 linear-gradient(light-dark(#b2b2b2,#4b5058) 0 1px,light-dark(#bdbdbd,#464b53) 1px,
276 light-dark(#919191,#393d44) calc(100% - 1px),light-dark(#8c8c8c,#393d44) calc(100% - 1px))
277 border-box}
278.indicator>i{position:absolute;top:0;bottom:0;left:0;width:calc(var(--p) * 100%);overflow-x:clip;
279 --gloss:#d2f1fc 1.5px,#addcf3 3.5px,#9ad3ef;--body:var(--progress-accent,#0794d1);--rest:var(--progress-accent,#1ea0d8);--edge:var(--progress-accent,#035f8f);
280 --glow:160 225 255;--shade:color-mix(in srgb,var(--edge) 40%,#0000);
281 background:
282 linear-gradient(90deg,#0000 1px,var(--shade) 1px,var(--shade) 5px,#0000 27px),
283 linear-gradient(270deg,#0000 1px,var(--shade) 1px,#0000 14px),
284 linear-gradient(var(--gloss) 6px,var(--body) 6px,var(--body) calc(100% - 5px),var(--rest));
285 transition:width var(--glide,0s) linear}
286/* a glow sweeps the fill at one speed for every bar, over the frame too, while
287 the bottom light swells under it */
288.indicator>i::before{content:"";position:absolute;left:0;right:0;bottom:0;height:5px;
289 background:linear-gradient(#0000,rgb(var(--glow)/.6));animation:swell 2.4s ease-in-out infinite}
290.indicator>i::after{content:"";position:absolute;top:-1px;bottom:-1px;left:0;width:64px;
291 background:linear-gradient(90deg,#0000,rgb(var(--glow)/.03) 8.3%,rgb(var(--glow)/.08) 16.7%,
292 rgb(var(--glow)/.2) 25%,rgb(var(--glow)/.36) 33.3%,rgb(var(--glow)/.53) 41.7%,
293 rgb(var(--glow)/.6),rgb(var(--glow)/.53) 58.3%,rgb(var(--glow)/.36) 66.7%,
294 rgb(var(--glow)/.2) 75%,rgb(var(--glow)/.08) 83.3%,rgb(var(--glow)/.03) 91.7%,#0000);
295 animation:glow 2.4s linear infinite}
296@keyframes glow{from{translate:-100%}50%,to{translate:176px}}
297@keyframes swell{from{opacity:.4}25%{opacity:1}65%,88%{opacity:0}to{opacity:.4}}
298:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::before,
299:is(.full,li:has(>.logs>:is(.warn,.error)))>.row>.indicator>i::after{content:none}
300li:has(>.logs>.warn)>.row>.indicator>i{
301 --gloss:#fffdcd 1.5px,#fffba1 3.5px,#fffa8b;--body:#d2ca00;--rest:#fef400;--edge:#8f8600}
302li:has(>.logs>.error)>.row>.indicator>i{
303 --gloss:#ffcdcd 1.5px,#ffa1a1 3.5px,#ff8b8b;--body:#d20000;--rest:#fe0000;--edge:#8f0000}
304
305/* the busy ring leads a top-level row, lit from above, with a highlight
306 running clockwise; below the top level the terminal shows no spinner */
307.spin>.row>.indicator{position:relative;flex:none;width:15px;height:15px;margin:4px 0;border-radius:50%;
308 background:conic-gradient(#9bc6da,#8dc2d0 30deg,#6daebd 60deg,#2b8982 90deg,#319f9d 120deg,
309 #4dc7bb 150deg,#5ecfc5 180deg,#4fc6bb 210deg,#2da09e 240deg,#238382 270deg,#6db2bb 300deg,
310 #91c5d1 330deg,#9bc6da);
311 mask:radial-gradient(closest-side,#0000 61%,#000 67%)}
312li li.spin>.row>.indicator{display:none}
313.spin>.row>.indicator::before,.spin>.row>.indicator::after{content:"";position:absolute;inset:0;border-radius:50%}
314.spin>.row>.indicator::before{animation:spin .9s linear infinite;
315 background:conic-gradient(from -150deg,#0000,#2fd4dc99 40deg,#4fe6eae6 90deg,#8ff3f4 125deg,
316 #e8fcfd 150deg,#b4eef0b3 162deg,#0000 180deg)}
317.spin>.row>.indicator::after{background:radial-gradient(closest-side,#00323c73 64%,#00323c00 76%,
318 #fff3 86%,#00323c00 92%,#00323c80)}
319.spin>.row>.indicator>i{display:none}
320@keyframes spin{to{rotate:1turn}}
321
322/* tree lines: a node's children hang from the start of its row, and each child
323 is drawn with the terminal's ├─, │ and └─ */
324li li{padding-left:22px}
325li>ol{position:relative}
326li>ol:not(:empty)::before{content:"";position:absolute;left:7px;top:-4px;height:5px;
327 border-left:1px solid var(--line)}
328li li::before,li li::after{content:"";position:absolute;left:7px;border:0 solid var(--line)}
329li li::before{top:0;bottom:-1px;border-left-width:1px}
330li li::after{top:11px;width:11px;border-top-width:1px}
331li li:last-child::before{bottom:auto;height:12px}
332
333.logs{margin:1px 0 4px;padding:3px 8px;border-radius:4px;
334 background:var(--progress-background,light-dark(#f3f5f8,#24282e));box-shadow:inset 0 0 0 1px light-dark(#e1e6ec,#31363d);
335 font:11px/16px ui-monospace,"Cascadia Mono",Consolas,"SF Mono",Menlo,monospace;color:var(--muted)}
336.logs:empty{display:none}
337.logs>div{overflow:hidden;text-overflow:ellipsis;white-space:pre}
338.logs>.more{padding-left:16px}
339.logs>.debug{opacity:.7}
340.logs>:is(.info,.warn,.error)::before{content:"";display:inline-block;width:11px;height:11px;
341 margin-right:5px;vertical-align:-2px;background:center/contain no-repeat}
342.logs>.info::before{background-image:${svg(
343 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#7cbaf6'/><stop offset='1' stop-color='#1557b8'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#0c3f8c' stroke-width='.6'/><rect x='5.25' y='5' width='1.5' height='4.2' rx='.5' fill='#fff'/><circle cx='6' cy='3.3' r='.9' fill='#fff'/>",
344)}}
345.logs>.warn::before{background-image:${svg(
346 "<defs><linearGradient id='g' x2='0' y2='1'><stop stop-color='#ffe680'/><stop offset='1' stop-color='#f0b400'/></linearGradient></defs><path d='M6 .8l5.4 10H.6z' stroke-linejoin='round' fill='url(#g)' stroke='#b58300' stroke-width='.7'/><rect x='5.3' y='4' width='1.4' height='3.8' rx='.5' fill='#333'/><circle cx='6' cy='9.2' r='.8' fill='#333'/>",
347)}}
348.logs>.error::before{background-image:${svg(
349 "<defs><radialGradient id='g' cx='.35' cy='.3' r='.8'><stop stop-color='#ff8a80'/><stop offset='1' stop-color='#c41a1a'/></radialGradient></defs><circle cx='6' cy='6' r='5.5' fill='url(#g)' stroke='#8a0f0f' stroke-width='.6'/><path d='M4 4l4 4m0-4l-4 4' stroke='#fff' stroke-width='1.5' stroke-linecap='round'/>",
350)}}
351.scope{color:var(--progress-secondary,light-dark(#7a6a9e,#b5a6d8))}
352
353
354@media (prefers-reduced-motion:reduce){
355 .indicator>i{transition:none}
356 .indicator>i::before,.indicator>i::after{content:none}
357 .spin>.row>.indicator::before{animation:pulse 1.8s ease-in-out infinite alternate}
358}
359@keyframes pulse{from{opacity:.25}}
360@media (forced-colors:active){
361 .indicator{forced-color-adjust:none}
362 .bar>.row>.indicator{background:Canvas;border-color:CanvasText}
363 .indicator>i{background:Highlight}
364 .indicator>i::before,.indicator>i::after{content:none}
365 .spin>.row>.indicator{background:Highlight}
366}
367:host([data-progress-motion="static"]) .indicator,
368:host([data-progress-motion="static"]) .indicator>i,
369:host([data-progress-motion="static"]) .indicator::before,
370:host([data-progress-motion="static"]) .indicator::after,
371:host([data-progress-motion="static"]) .indicator>i::before,
372:host([data-progress-motion="static"]) .indicator>i::after{animation:none;transition:none}
373`)();
packages/clo-lib/progress-format.ts created+23
...@@ -0,0 +1,23 @@
1// @effect-diagnostics globalDate:off - Vendored @clo/lib has no Effect dependency.
2import type { ReadOnlyNode } from "./progress.ts";
3
4export function isVisible(node: ReadOnlyNode): boolean {
5 return !node.hidden && (!node.passive || node.children.some(isVisible));
6}
7
8export function remainingSeconds(node: ReadOnlyNode): number | null {
9 const { estimatedTime } = node;
10 if (!estimatedTime) return null;
11 const ms = estimatedTime - Date.now();
12 return ms > 1000 && !findLongerEstimate(node, estimatedTime) ? Math.round(ms / 1000) : null;
13}
14
15function findLongerEstimate(state: ReadOnlyNode, estimatedTime: number): boolean {
16 return state.children.some(
17 (child) =>
18 isVisible(child) &&
19 ((child.estimatedTime ?? 0) > estimatedTime || findLongerEstimate(child, estimatedTime)),
20 );
21}
22
23export const maxLogLines = 3;
packages/clo-lib/progress.test.ts created+85
...@@ -0,0 +1,85 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import {
4 decodeEventStream,
5 encodeEventStream,
6 Root,
7 type StreamNode,
8 type StreamEvent,
9} from "./progress.ts";
10
11describe("progress transport", () => {
12 it("reconstructs a late snapshot with nested nodes and empty labels", async () => {
13 const source = new Root();
14 const parent = source.start("parent", { total: 8, value: 3 });
15 parent.start("", { total: 4, value: 2 });
16 const target = new Root();
17 const input = encodeEventStream(source, { throttleMs: 0 });
18 const decoding = decodeEventStream(input, target);
19 await new Promise<void>((resolve) =>
20 target.on("node-start", () => {
21 if (target.active[0]?.children.length) resolve();
22 }),
23 );
24 expect(target.active[0]?.text).toBe("parent");
25 expect(target.active[0]?.value).toBe(3);
26 expect(target.active[0]?.children[0]?.text).toBe("");
27 expect(target.active[0]?.children[0]?.value).toBe(2);
28 decoding.cancel();
29 expect(target.active).toHaveLength(0);
30 });
31
32 it("applies zero values, cleared text, and a cleared estimate", async () => {
33 const target = new Root();
34 let controller: ReadableStreamDefaultController<StreamEvent>;
35 const input = new ReadableStream<StreamEvent>({
36 start(value) {
37 controller = value;
38 },
39 });
40 const decoding = decodeEventStream<void, { checkpoint: [] }>(input, target);
41 const checkpoint = () => new Promise<void>((resolve) => decoding.on("checkpoint", resolve));
42 let ready = checkpoint();
43 controller!.enqueue([
44 -1,
45 { k: 1 as StreamNode["k"], t: "task", v: 5, e: 10, E: 2000 },
46 [1],
47 ["checkpoint"],
48 ]);
49 await ready;
50 ready = checkpoint();
51 controller!.enqueue([
52 0,
53 { k: 1 as StreamNode["k"], t: "", v: 0, e: 0, E: null },
54 ["checkpoint"],
55 ]);
56 await ready;
57 expect(target.active[0]).toMatchObject({ text: "", value: 0, total: 0, estimatedTime: null });
58 controller!.enqueue([0, ["end", null]]);
59 controller!.close();
60 await decoding;
61 expect(target.active).toHaveLength(0);
62 });
63
64 it("cancels a pending read and removes managed nodes without ending the transport", async () => {
65 const target = new Root();
66 const cancelled = Promise.withResolvers<void>();
67 let controller: ReadableStreamDefaultController<StreamEvent>;
68 const input = new ReadableStream<StreamEvent>({
69 start(value) {
70 controller = value;
71 },
72 cancel() {
73 cancelled.resolve();
74 },
75 });
76 const decoding = decodeEventStream<void, { checkpoint: [] }>(input, target);
77 const ready = new Promise<void>((resolve) => decoding.on("checkpoint", resolve));
78 controller!.enqueue([-1, { k: 1 as StreamNode["k"], t: "interrupted" }, [1], ["checkpoint"]]);
79 await ready;
80 decoding.cancel();
81 await cancelled.promise;
82 expect(target.active).toHaveLength(0);
83 expect(input.locked).toBe(false);
84 });
85});
packages/clo-lib/progress.ts created+1917
...@@ -0,0 +1,1917 @@
1// @effect-diagnostics globalDate:off globalTimers:off - Vendored @clo/lib has no Effect dependency.
2/**
3 * a progress reporting API that allows creating CLI spinners and progress bars
4 * that can be easily nested to visualize complex, parallel work. in addition
5 * to providing rich feedback in the terminal, a headless {@linkcode Root} node
6 * can be constructed, which can be bound to the DOM through `progress/browser`
7 * or transmit progress information
8 * {@link encodeEventStream|across a network}.
9 *
10 * the progress API takes advantage of the `using` keyword to make nodes
11 * scoped. the convention is to make functions take a {@linkcode Ref|progress.Ref},
12 * to act as the destination for the sub-node.
13 *
14 * ```ts
15 * async function doSomething(p: progress.Ref) {
16 * using node = p.start("my task");
17 * // ...
18 * await doSomethingElse(node); // node satisfies progress.Ref
19 * // ...
20 * }
21 * async function doSomethingElse(p: progress.Ref) {
22 * using node = p.start("my sub task");
23 * // ...
24 * }
25 *
26 * doSomething(progress); // the progress module satisfies progress.Ref
27 *
28 * import * as progress from "@clo/lib/progress";
29 * ```
30 *
31 * in some cases, such as in `subprocess/ffmpeg.ts`, it may make more sense
32 * to pass in a pre-started node. the `ffmpeg` module, for example, uses the
33 * starting text as a base title and decorates it with encoding stats.
34 *
35 * when using the top-level {@linkcode start|progress.start}, proper
36 * integration is made with `lib/log.ts` so that the progress tree does not
37 * intersect with regular log messages, as well as forwarding each
38 * {@linkcode Node} custom logging scope to the global log, including any
39 * custom redirections.
40 *
41 * this module is under construction. while i am happy with the overall API, it
42 * needs more work and feature development. the API of `Node` is stable, though.
43 *
44 * inspired by the [Zig Progress API](https://andrewkelley.me/post/zig-new-cli-progress-bar-explained.html).
45 *
46 * @module
47 */
48
49/**
50 * creates a new trackable unit of work, visible in the terminal as a spinner
51 * that rests at the end of the log. if `estimate` is given, a progress bar is
52 * shown.
53 */
54/* node:coverage ignore next 3 */
55export function start(text: string, opts?: StartOptions): Node {
56 return global.start(text, opts);
57}
58
59/**
60 * a reference point to create sub-items. this interface is satisfied by
61 * - the `progress` module itself
62 * - a node recieved by `p.start("label")`
63 * - a headless progress runner from `progress.headless()`
64 *
65 * prefer taking `Ref` as a function parameter, so that the function can be
66 * started at the top level, or as a sub-task of an existing node.
67 *
68 * ```ts
69 * function build(p: progress.Ref = progress) {
70 * using _ = p.start("build process");
71 * // ...
72 * }
73 * ```
74 *
75 * sometimes it makes sense to have an API recieve `Node` instead of `Ref`,
76 * specifically if the caller should have some control over the node name.
77 *
78 * ```ts
79 * await ffmpeg.spawn({
80 * cmd: ["-i", "hello.mov", "-c:v", "svtav1", "hello.mp4"],
81 * progress: progress.start("encode hello.mov"),
82 * });
83 * ```
84 */
85export interface Ref {
86 /**
87 * creates a new trackable unit of work as a child of this one.
88 * when given an estimate, a progress bar is rendered.
89 */
90 start(text: string, opts?: StartOptions): Node;
91}
92
93/**
94 * control for a progress nooe. all fields are primed with setters to trigger
95 * ui dispatch automatically. to make tools that use `progress.ts` more
96 * modular to a custom progress implementation, many fields are optional.
97 */
98export interface Node extends Ref, Disposable {
99 /** end this Node or RootNode. ending collapses children. */
100 end(this: Node): void;
101 /** one line of text display. */
102 text: string;
103 /**
104 * number of items completed. by default this is a count of items, but the
105 * `units` property can be passed to `start` to change how the `value` is
106 * interpretted when displaying this progress node.
107 */
108 value: number;
109 /**
110 * an estimate of how many items this task contains.
111 * when set above `0`, it is shown with a progress bar.
112 */
113 total: number;
114 /**
115 * defaults to increasing by 1. if incrementing causes `value >= estimate`,
116 * it also calls `end`. use `node.value += 1` if that behavior is not wanted.
117 */
118 inc(this: Node, value?: number): void;
119 /** a scoped logger set to output inline on this item. */
120 readonly log: log.RootScope;
121
122 /**
123 * a time for when this node is estimated to be completed, in milliseconds
124 * relative to the unix epoch. by default, this is automatically managed and
125 * made read-only. if `StartOptions` specifies `estimateCompletions: false`,
126 * this field may be mutated.
127 */
128 estimatedTime?: number | null | undefined;
129 /**
130 * default `true`. `false` will hides a bar + printing total. this is useful
131 * for preseving auto-end behavior of `inc` without showing the total.
132 */
133 showTotal?: boolean;
134 /**
135 * default `false`. when true, this progress item is hidden if
136 * there are no children.
137 */
138 passive?: boolean;
139 /** default `false`. when true, this progress item is hidden */
140 hidden?: boolean;
141 /** override sorting for children */
142 sortChildren?: ((a: ReadOnlyNode, b: ReadOnlyNode) => number) | null;
143 /**
144 * specify how `value` and `total` is formed into text.
145 * this has some discoverable presets on `StartOptions.units`.
146 */
147 valueFormatter?: (value: number, total: number | null) => string;
148}
149
150/** override the default field values of {@linkcode Node} on creation. */
151export interface StartOptions {
152 /** number of items already completed. */
153 value?: number | undefined | null;
154 /** when set above `0`, it is shown with a progress bar. */
155 total?: number | undefined | null;
156 /**
157 * presets for common formattings of `value` and `total`.
158 * @default "count"
159 */
160 units?: "count" | "bytes" | "percent";
161 /**
162 * customize how an estimate is given for progress bars. to make
163 * `Node.estimatedTime` mutable, pass `false` here.
164 * @default `null`, which is equivilent to `new progress.Ema()`
165 */
166 estimateCompletion?: EstimationAlgorithm | boolean | null;
167 /**
168 * when true, this progress item is hidden if there are no children.
169 * this makes sense when scheduling items with a priority queue, where a
170 * passive node is only used for grouping, but children are used for
171 * indicating status.
172 * @default false
173 */
174 passive?: boolean | undefined | null;
175 /** default `false`. when true, this progress item is hidden */
176 hidden?: boolean | undefined | null;
177 /**
178 * default `true`. `false` will hides a bar + printing estimate,
179 * but preseving auto-end behavior of `inc`
180 */
181 showTotal?: boolean | undefined | null;
182 /** specify sorting for children */
183 sortChildren?: Node["sortChildren"] | undefined | null;
184 /**
185 * specify how `value` and `total` is formed into text.
186 * this has some discoverable presets on `StartOptions.units`.
187 */
188 valueFormatter?: ValueFormatter;
189 /** pre-fill the list of log messages */
190 messages?: log.Message[] | undefined;
191}
192
193/**
194 * value` will be at least zero, total will be `null` or greater than zero. a
195 * custom value formatter can be passed to {@linkcode start} or mutated on an
196 * existing node.
197 */
198export type ValueFormatter = (value: number, total: number | null) => string;
199
200/**
201 * creates a function to use as a `Node.valueFormatter` by using a single
202 * number formatter, and then joining the node's value and total with a "/",
203 * but leaving it out if there is no total.
204 */
205export function slashValueFormatter(fmtNumber: (value: number) => string): ValueFormatter {
206 return (value: number, total: number | null) =>
207 value > 0 ? fmtNumber(value) + (total != null && total > 0 ? "/" + fmtNumber(total) : "") : "";
208}
209
210/** the formatter used when passing `units: "count"` to {@linkcode start} */
211export const defaultValueFormatter: ValueFormatter = /* @__PURE__ */ slashValueFormatter(String);
212
213/** the formatter used when passing `units: "bytes"` to {@linkcode start} */
214export const bytesValueFormatter: ValueFormatter =
215 /* @__PURE__ */
216 slashValueFormatter(string.formatByteSize);
217
218/** the formatter used when passing `units: "percent"` to {@linkcode start} */
219export function percentValueFormatter(value: number, total: number | null): string {
220 if (total != null && total > 0) {
221 value /= total;
222 }
223 return `${Math.round(value * 1000) / 10}%`;
224}
225
226/**
227 * {@linkcode Root} allows overriding it's timing functions, which are used for
228 * mostly for rendering and streaming adapters.
229 */
230export interface RootOptions {
231 delay?: typeof async.delay;
232 /** used for debouncing and UI. time estimation does NOT use this method */
233 now?: typeof performance.now;
234}
235
236/**
237 * a headless progress node. when the state of the tree changes, the `change`
238 * event is emitted (batched with many changes). the primary use case of using
239 * a custom `Root`s is to stream a task's progress over a network or process
240 * IPC using {@linkcode encodeByteStream}/{@linkcode encodeEventStream}.
241 * another use case is to render a progress task differently.
242 *
243 * custom events may be dispatched on the `Root`, but this is only useful when
244 * streaming, since the streams will carry any custom events.
245 */
246export class Root<Result = void, Map extends Events.Map = ts.EmptyObject> extends Events<
247 MergeRootEvents<Result, Map>
248> {
249 delay: typeof async.delay;
250 now: typeof performance.now;
251 #status: "active" | "end" | "error" = "active";
252 #active: Internal[] = [];
253 #debounce: async.Cancelable<void> | null = null;
254
255 constructor({ delay, now }: RootOptions = {}) {
256 super();
257 this.delay = delay ?? async.delay;
258 this.now = now ?? performance.now.bind(performance);
259 this.on("node-end", (state) => {
260 if (state.parent) return;
261 const i = this.#active.indexOf(state as Internal);
262 ASSERT(i !== -1);
263 this.#active.splice(i, 1);
264 });
265 }
266
267 /** the caller may not mutate `Root`'s state */
268 get active(): readonly ReadOnlyNode[] {
269 return this.#active;
270 }
271
272 /**
273 * creates a new trackable unit of work at the top level of this root.
274 * when given an estimate, a progress bar is rendered.
275 */
276 start(text: string, opts?: StartOptions): Node {
277 ASSERT(this.#status === "active");
278 const [state, node] = newNode(this, text, opts);
279 this.#active.push(state);
280 this.emit("node-start", state, null);
281 return node;
282 }
283
284 /**
285 * end this root, as well as all children nodes. this emits the `end` event
286 * with the provided value, JSON-serializing it if connected via
287 * {@linkcode encodeEventStream} or {@linkcode encodeByteStream}.
288 *
289 * this function is pre-bound so that it can be passed to `then`:
290 * ```ts
291 * const root = new Root();
292 * void asyncFunction().then(root.end, root.error);
293 * ```
294 */
295 end = (result: Result): void => {
296 this.emit("end", result);
297 };
298
299 /**
300 * end this root, as well as all children nodes. this emits the `error` event.
301 */
302 error = (error: unknown): void => {
303 this.emit("error", error);
304 };
305
306 /** convert this root into a promise. */
307 asPromise(): Promise<Result> {
308 return this.once("end").then((x) => x[0]);
309 }
310
311 /**
312 * emit an event on the specified channel. behavior is not defined when manually
313 * emitting one of the built in events.
314 */
315 override emit<C extends keyof MergeRootEvents<Result, Map>>(
316 channel: C,
317 ...args: MergeRootEvents<Result, Map>[C]
318 ): void {
319 ASSERT(this.#status === "active");
320 if (channel !== "change" && rootEvents.has(channel)) this.#emitChangeSoon();
321 if (channel === "error" || channel === "end") {
322 this.#end();
323 this.#status = channel;
324 }
325 super.emit(channel, ...args);
326 }
327
328 #end() {
329 const hasActive = this.#active.length > 0 || !this.#debounce;
330 for (const active of this.#active) endNode(this, active);
331 this.#active.splice(0, this.#active.length);
332 this.#debounce?.cancel();
333 if (hasActive) this.emit("change", this.#active);
334 }
335
336 #emitChangeSoon = () => {
337 if (this.#debounce) return;
338 (this.#debounce = this.delay(1)).then(() => {
339 this.#debounce = null;
340 this.emit("change", this.#active);
341 });
342 };
343}
344
345/** the built in events provided by {@linkcode Root} */
346export type RootEventMap<Result = void> = {
347 // this event is the debounced "ready to re-render" event. do not emit.
348 change: [rootNodes: readonly ReadOnlyNode[]];
349 // these events are emitted without a debounce, and are used for general
350 // communication within `Root`'s implementation. do not emit.
351 "node-start": [newNode: ReadOnlyNode, parentNode: ReadOnlyNode | null];
352 "node-change": [node: ReadOnlyNode, key: keyof ReadOnlyNode];
353 "node-end": [node: ReadOnlyNode];
354 "node-detached-log": [msg: log.Message];
355 // these events are used for result communication. they can be sent via
356 // `emit` or the shorthand methods `end` and `error`. sending either will put
357 // the `Root` into a "done" state.
358 end: [result: Result];
359 error: [error: unknown];
360};
361
362const rootEvents: ReadonlySet<unknown> = new Set([
363 "change",
364 "node-start",
365 "node-change",
366 "node-end",
367 "node-detached-log",
368 "end",
369 "error",
370]);
371
372type MergeRootEvents<Result, Map extends Events.Map> = {
373 [K in keyof Map | keyof RootEventMap]: K extends keyof RootEventMap
374 ? RootEventMap<Result>[K]
375 : Map[K];
376};
377
378/** a read-only version of {@linkcode Node}, provided to renderers. */
379export type ReadOnlyNode = Readonly<
380 Pick<
381 Required<Node>,
382 | "text"
383 | "value"
384 | "total"
385 | "estimatedTime"
386 | "passive"
387 | "hidden"
388 | "showTotal"
389 | "sortChildren"
390 | "valueFormatter"
391 > & {
392 /** unique identifier. once {@linkcode Node} is disposed, the key is reused. */
393 key: number;
394 logs: readonly log.Message[];
395 /** traverse down the tree. */
396 children: readonly ReadOnlyNode[];
397 /** traverse up the tree. */
398 parent: ReadOnlyNode | null;
399 }
400>;
401
402/** internal state is read-write. */
403interface Internal extends ts.Writeable<ReadOnlyNode> {
404 logs: log.Message[];
405 children: Internal[];
406 parent: Internal | null;
407 detached: boolean;
408}
409
410// TypeScript Test
411void function (unknown: unknown) {
412 unknown as Internal satisfies ReadOnlyNode;
413};
414
415/**
416 * constructs a linked `Node` and `Internal` that dispatches events to `owner`.
417 * the `Internal` is the non-reactive source of truth that can be blindly cast
418 * to `ReadOnlyNode`, and `Node` is the Node.
419 */
420function newNode<Result, EventMap extends Events.Map>(
421 owner: Root<Result, EventMap>,
422 text: string,
423 opts: StartOptions = {},
424): [Internal, Node] {
425 let estimator: EstimationAlgorithm | null = null;
426
427 const state: Internal = {
428 key: globalKeyPool.get(),
429 text,
430 value: opts.value ?? 0,
431 total: opts.total ?? 0,
432 estimatedTime: null,
433 showTotal: opts.showTotal ?? true,
434 passive: opts.passive ?? false,
435 hidden: opts.hidden ?? false,
436 logs: [...(opts.messages ?? [])],
437 children: [],
438 parent: null,
439 sortChildren: opts.sortChildren ?? null,
440 valueFormatter:
441 opts.valueFormatter ??
442 {
443 count: defaultValueFormatter,
444 bytes: bytesValueFormatter,
445 percent: percentValueFormatter,
446 }[opts.units ?? "count"],
447 detached: false,
448 };
449
450 if (opts.estimateCompletion !== false) {
451 const { estimateCompletion } = opts;
452 if (estimateCompletion && typeof estimateCompletion === "object") {
453 estimator = estimateCompletion;
454 } else estimator = new Ema();
455 }
456 estimator?.sample(Date.now(), 0, 0);
457
458 function mutate(key: keyof ReadOnlyNode) {
459 if (state.detached) return;
460 if (
461 key === "value" &&
462 estimator &&
463 state.total > 0 &&
464 state.value > 0 &&
465 state.value < state.total
466 ) {
467 // originally, auto-estimation would use the Root's timing primitives,
468 // but this makes it so that `state.estimatedTime` is in terms of the
469 // Root, which is unintuitive for everyone but the renderer.
470 const time = estimator.sample(Date.now(), state.value, state.total);
471 if (time && (!state.estimatedTime || state.estimatedTime !== time)) {
472 state.estimatedTime = time;
473 owner.emit("node-change", state, "estimatedTime");
474 }
475 }
476 const { parent } = state;
477 parent?.sortChildren && parent.children.sort(parent.sortChildren);
478 owner.emit("node-change", state, key);
479 }
480
481 const scope = log.headlessScope((m) => {
482 if (state.detached) return owner.emit("node-detached-log", m);
483 state.logs.push(m);
484 owner.emit("node-change", state, "logs");
485 });
486
487 const binding: Node = {
488 start(text, opts) {
489 if (state.detached) return nullNode;
490 const [child, node] = newNode(owner, text, opts);
491 state.children.push(child);
492 child.parent = state;
493 state.sortChildren && state.children.sort(state.sortChildren);
494 owner.emit("node-start", child, state);
495 owner.emit("node-change", state, "children");
496 return node;
497 },
498 get text() {
499 return state.text;
500 },
501 set text(value) {
502 state.text = value;
503 mutate("text");
504 },
505 get value() {
506 return state.value;
507 },
508 set value(value) {
509 state.value = value;
510 mutate("value");
511 },
512 get total() {
513 return state.total;
514 },
515 set total(value) {
516 state.total = value;
517 mutate("total");
518 },
519 get showTotal() {
520 return state.showTotal;
521 },
522 set showTotal(value) {
523 state.showTotal = value;
524 mutate("showTotal");
525 },
526 get passive() {
527 return state.passive;
528 },
529 set passive(value) {
530 state.passive = value;
531 mutate("passive");
532 },
533 get hidden() {
534 return state.hidden;
535 },
536 set hidden(value) {
537 state.hidden = value;
538 mutate("hidden");
539 },
540 get sortChildren() {
541 return state.sortChildren;
542 },
543 set sortChildren(value) {
544 state.sortChildren = value;
545 mutate("sortChildren");
546 },
547 get estimatedTime() {
548 return state.estimatedTime;
549 },
550 set estimatedTime(date) {
551 if (!estimator) {
552 state.estimatedTime = date;
553 mutate("estimatedTime");
554 }
555 },
556 get valueFormatter() {
557 return state.valueFormatter;
558 },
559 set valueFormatter(value) {
560 state.valueFormatter = value;
561 mutate("valueFormatter");
562 },
563 inc(delta = 1) {
564 state.value += delta;
565 if (state.total > 0 && state.value >= state.total) {
566 endNode(owner, state);
567 } else {
568 mutate("value");
569 }
570 },
571 log: scope,
572 end: () => void endNode(owner, state),
573 [Symbol.dispose]: () => void endNode(owner, state),
574 };
575 return [state, binding];
576}
577
578const noop = () => {};
579noop[Symbol.dispose] = noop;
580
581/** minimal implementation of {@linkcode Node} that has no I/O */
582export const nullNode: Node = {
583 start: () => nullNode,
584 get text() {
585 return "[detached]";
586 },
587 set text(_) {},
588 get value() {
589 return 0;
590 },
591 set value(_) {},
592 get total() {
593 return 0;
594 },
595 set total(_) {},
596 inc: noop,
597 log: {
598 info: noop,
599 warn: noop,
600 error: noop,
601 log: noop,
602 debug: noop,
603 writeMessage: noop,
604 write: noop,
605 scoped() {
606 return this;
607 },
608 tee: () => noop,
609 },
610 end: () => {},
611 [Symbol.dispose]: () => {},
612};
613
614function endNode<R, M extends Events.Map>(owner: Root<R, M>, state: Internal) {
615 if (state.detached) return;
616 state.children.forEach((child) => endNode(owner, child));
617 state.detached = true;
618 globalKeyPool.recycle(state.key);
619 if (state.parent) {
620 const i = state.parent.children.indexOf(state);
621 ASSERT(i !== -1);
622 state.parent.children.splice(i, 1);
623 }
624 owner.emit("node-end", state);
625 state.logs = [];
626 state.parent = null;
627}
628
629/** Convert the top level {@linkcode ReadOnlyNode|ReadOnlyNode[]} into ANSI text. */
630export function formatAnsi(now: number, list: readonly ReadOnlyNode[]): string {
631 let out = "";
632 for (const top of list) {
633 if (!isVisible(top)) continue;
634 out += renderAnsiMainLine(top, now, 0) + "\n";
635 out += renderChildren(top, now, []);
636 }
637 return out.trimEnd();
638}
639
640const spinnerFps = 12.5;
641const box = { tee: "├─ ", line: "│ ", langle: "└─ " };
642const barChars = [" ", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
643const fullBar = "█";
644const spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"].map((frame) =>
645 ansi.style(ansi.fgBlue, frame),
646);
647
648function renderAnsiMainLine(state: ReadOnlyNode, now: number, depth: number) {
649 const { text, total, showTotal, value } = state;
650 const remaining = remainingSeconds(state);
651 const estimate = remaining ? ", " + string.formatDurationLetters(remaining) : "";
652 const valueFormatted = state.valueFormatter(value, total);
653 if (total && showTotal) {
654 return (
655 ansi.style(
656 ansi.bgBrightBlack + ansi.fgBlue,
657 formatUnicodeBar(value / total, Math.max(4, depth > 0 ? 12 : 25)),
658 ) +
659 (valueFormatted || estimate ? ` [${valueFormatted}${estimate}] ` : " ") +
660 text
661 );
662 }
663 const frame = Math.floor(now / (1000 / spinnerFps)) % spinner.length;
664 return (
665 (depth === 0 ? spinner[frame] + " " : "") +
666 (valueFormatted ? `[${valueFormatted}] ` : "") +
667 text
668 );
669}
670
671function renderChildren(state: ReadOnlyNode, now: number, depth: boolean[]) {
672 let maxHeight = 50; // TODO: flexible layout
673 let truncated = 0;
674 let out = "";
675 const children = state.children.filter(isVisible);
676 if (children.length === 0) return "";
677 for (let i = 0, { length } = children; i < length; i += 1) {
678 const child = UNWRAP(children[i]);
679 let item = "";
680 const left = depth.map((x) => (x ? box.line : " ")).join("");
681 item += left + (i === length - 1 && !truncated ? box.langle : box.tee);
682 item += renderAnsiMainLine(child, now, 1 + depth.length) + "\n";
683 item += renderChildren(child, now, depth.concat(i < length - 1));
684 const h = string.countNewlines(item);
685 if (h > maxHeight) {
686 truncated += 1;
687 continue;
688 }
689 const logLines = child.logs
690 .map((msg) => log.formatAnsiMessage(msg, true))
691 .join("")
692 .trim();
693 if (logLines) {
694 for (const line of logLines.split("\n").slice(-Math.min(maxLogLines, maxHeight))) {
695 item +=
696 left +
697 (i === length - 1 && !truncated ? " " : box.line) +
698 " " +
699 ansi.style(ansi.fgBrightBlack, ">") +
700 " " +
701 line +
702 "\n";
703 }
704 }
705 maxHeight -= h + string.countNewlines(logLines);
706 out += item;
707 }
708 if (truncated) {
709 out += depth.map((x) => (x ? box.line : " ")).join("") + box.langle;
710 out += ansi.style(ansi.fgBrightBlack, `[${truncated} more]`) + "\n";
711 }
712 return out;
713}
714
715/**
716 * This function is derived from an old program I wrote back in 2020 called `f`
717 * which did ffmpeg handling. It is probably one of the coolest progress bars
718 * ever imagined.
719 */
720export function formatUnicodeBar(progress: number, width: number): string {
721 if (progress >= 1) return fullBar.repeat(width);
722 if (progress <= 0 || Number.isNaN(progress)) return " ".repeat(width);
723
724 const wholeWidth = Math.floor(progress * width);
725 const remainderWidth = (progress * width) % 1;
726 const partWidth = Math.floor(remainderWidth * 8);
727 let partChar = barChars[partWidth];
728 if (width - wholeWidth - 1 < 0) partChar = "";
729
730 const fill = fullBar.repeat(wholeWidth);
731 const empty = " ".repeat(width - wholeWidth - 1);
732
733 return fill + partChar + empty;
734}
735
736/**
737 * configure a {@linkcode Root} to display its contents to a
738 * {@linkcode log.HeadlessWidgetHost}. this is used by the global progress
739 * instance to output to the terminal.
740 * ```ts
741 * const globalProgress = new progress.Root();
742 * progress.attachToScreen(log);
743 * ```
744 * unit tests use a mock screen instead of the terminal.
745 * ```ts
746 * const screen = new TestWidgetHost();
747 * const root = new progress.Root(screen.delay);
748 * root.attachToScreen(screen);
749 * // can use the progress API
750 * root.start("hello");
751 * // and then examine the contents
752 * screen.expectFrame(...);
753 * ```
754 */
755export function attachToScreen(
756 root: Root,
757 { writeOutput, startWidget }: Pick<log.WidgetHost, "writeOutput" | "startWidget">,
758): ts.Dispose {
759 const stack = new DisposableStack();
760 let widget: log.WidgetInstance | null = null;
761
762 stack.use(
763 root.on("change", (items) => {
764 if (items.length > 0) {
765 widget ??=
766 startWidget({
767 format: ({ now }) => formatAnsi(now, root.active),
768 }) ?? null;
769 if (!widget) return;
770 // scan for spinners and visible estimations
771 widget.fps = items.some((x) => !x.hidden && !(x.showTotal !== false && x.total > 0))
772 ? spinnerFps
773 : hasEstimate(items)
774 ? 1
775 : null;
776 widget.redraw();
777 } else {
778 widget?.stop();
779 widget = null;
780 }
781 }),
782 );
783 stack.use(
784 root.on("node-detached-log", (msg) => {
785 writeOutput(log.formatAnsiMessage(msg, true));
786 }),
787 );
788 stack.use(
789 root.on("node-end", (node) => {
790 let title = node.text;
791 let p: ReadOnlyNode | null = node;
792 while ((p = p.parent)) title = p.text + " / " + title;
793 const { logs } = node;
794 if (logs.length > 0) {
795 const header = `[logs from ${title}]`;
796 writeOutput(ansi.style(ansi.fgBrightBlack, header) + "\n");
797 writeOutput(logs.map((msg) => log.formatAnsiMessage(msg, true)).join(""));
798 }
799 }),
800 );
801
802 stack.defer(() => {
803 widget?.stop();
804 widget = null;
805 });
806
807 return ts.defer(() => stack.dispose());
808}
809
810function hasEstimate(nodes: readonly ReadOnlyNode[]): boolean {
811 return nodes.some((n) => n.estimatedTime != null || hasEstimate(n.children));
812}
813
814const header = /* @__PURE__ */ string.encodeUtf8("clover's progress <3\n");
815
816/** the 'Content-Type' of {@linkcode encodeByteStream}'s return value */
817export const contentType = "application/x-clover-progress";
818
819/**
820 * opaque but JSON-compatible serialized state, either containing the entire
821 * progress tree or just a delta update. this data structure always be future
822 * and backwards compatbile with changes to this library. if a breaking change
823 * is made, the APIs in this file will be renamed.
824 *
825 * for details on the format, read the source of {@linkcode encodeEventStream} or
826 * {@linkcode encodeByteStream}
827 */
828export type StreamEvent = Array<number | StreamNode | StreamCustomEvent | StreamRootChildren>;
829const kNode = Symbol("underlyingNode");
830/** @internal */
831export interface StreamNode {
832 k: EncodedKey;
833 /** text, overwrite */
834 t?: string | undefined;
835 /** value, overwrite */
836 v?: number | undefined;
837 /** total, overwrite */
838 e?: number | undefined;
839 /** showTotal, overwrite */
840 s?: boolean | undefined;
841 /** passive, overwrite */
842 p?: boolean | undefined;
843 /** logs, append */
844 l?: log.Message[] | undefined;
845 /** hidden, overwrite */
846 h?: boolean | undefined;
847 /** children, overwrite */
848 c?: EncodedKey[] | undefined;
849 /** estimated time, in unix milliseconds, overwrite */
850 E?: number | null | undefined;
851 /** formatted value if not defaultValueFormatter, overwrite */
852 V?: string | undefined;
853
854 /** access to the underlying node is used by {@linkcode encodeByteStream} */
855 [kNode]?: ReadOnlyNode;
856}
857type StreamCustomEvent = [
858 /** custom event name or "end" */
859 e: "end" | string,
860 /** custom event payload */
861 ...args: ts.Json[],
862];
863type StreamRootChildren = number[];
864
865/** options for both {@linkcode encodeEventStream} and {@linkcode encodeByteStream} */
866export interface EncodeStreamOptions {
867 /**
868 * set the minimum time between packets.
869 * @default 1000 / 30 (30fps)
870 */
871 throttleMs?: number;
872 /**
873 * `log.ts` is capable of capturing stack traces from all log calls. by
874 * seeing this to true, those traces will be serialized. disabled by default
875 * for privacy reasons.
876 */
877 serializeStackTraces?: boolean;
878}
879
880/**
881 * converts a {@linkcode Root|progress.Root} into an object stream
882 * for communicating progress over the process or network boundary.
883 *
884 * if the given root adds custom event handlers, they must all have
885 * json-serializable payloads.
886 */
887export function encodeEventStream<Result extends ts.Json, Map extends { [key: string]: ts.Json[] }>(
888 // fun fact, this type is impossible to write without generics
889 root: Root<Result, Map>,
890 { throttleMs = 1000 / 30 /* 30fps */, serializeStackTraces = false }: EncodeStreamOptions = {},
891): ReadableStream<StreamEvent> {
892 const { delay, now } = root;
893 const stack = new DisposableStack();
894 const s = new Encoder(root, serializeStackTraces);
895 const ready = new async.Watch(false);
896
897 let lastEvent = now();
898 let timer: async.Cancelable<void> | null = null;
899 function emitSoon() {
900 if (timer || ready.value) return;
901 const remaining = lastEvent + throttleMs - now();
902 if (remaining > 0) {
903 (timer = delay(remaining)).then(() => {
904 timer = null;
905 ready.value = true;
906 lastEvent = now();
907 });
908 } else {
909 ready.value = true;
910 lastEvent = now();
911 }
912 }
913 /** terminal events skip the throttle so the stream ends promptly */
914 function emitNow() {
915 timer?.cancel();
916 timer = null;
917 ready.value = true;
918 lastEvent = now();
919 }
920 stack.defer(() => {
921 if (timer) timer.cancel();
922 s.changed.clear();
923 s.deleted.clear();
924 });
925
926 let isFirst = true;
927 return new ReadableStream(
928 {
929 start(controller) {
930 if (root.active.length > 0) {
931 controller.enqueue(s.getDelta(isFirst));
932 isFirst = false;
933 }
934
935 stack.use(s.observe(emitSoon, emitNow));
936 },
937 async pull(controller) {
938 await ready.until((x) => x === true);
939 ready.value = false;
940 if (timer) {
941 timer.cancel();
942 timer = null;
943 }
944 controller.enqueue(s.getDelta(isFirst));
945 isFirst = false;
946 if (s.ended) {
947 controller.close();
948 stack.dispose();
949 }
950 },
951 cancel() {
952 stack.dispose();
953 },
954 },
955 { highWaterMark: 0 },
956 );
957}
958
959/**
960 * decodes a stream created by {@linkcode encodeEventStream} into managed calls
961 * to `target.start`. when cancelling, all the managed nodes are destroyed.
962 */
963export function decodeEventStream<Result = void, EventMap extends Events.Map = ts.EmptyObject>(
964 encoded: ReadableStream<StreamEvent>,
965 target: Ref,
966): async.Cancelable<Result> & Events<EventMap> {
967 const { resolve, reject, promise } = Promise.withResolvers<Result>();
968 const events = new Events<EventMap>();
969 const ctrl = new AbortController();
970 const decoder = new Decoder(target, events, resolve);
971 const { signal } = ctrl;
972 (async () => {
973 if (signal.aborted) return;
974 const reader = encoded.getReader();
975 const cancel = () => void reader.cancel(signal.reason).catch(() => {});
976 signal.addEventListener("abort", cancel, { once: true });
977 try {
978 let hasEmittedEnd = false;
979 while (true) {
980 signal.throwIfAborted();
981 const { value, done } = await reader.read();
982 signal.throwIfAborted();
983 if (done) break;
984 if (decoder.processEvent(value)) hasEmittedEnd = true;
985 }
986 ASSERT(hasEmittedEnd, "Stream terminated early.");
987 } catch (err) {
988 reader.cancel(err);
989 if (!signal.aborted) reject(err);
990 } finally {
991 signal.removeEventListener("abort", cancel);
992 decoder.reset();
993 reader.releaseLock();
994 }
995 })();
996 return Object.assign(ts.mixin(promise, events), {
997 cancel: (e?: unknown) => {
998 ctrl.abort(e);
999 decoder.reset();
1000 },
1001 [Symbol.dispose]: () => {
1002 ctrl.abort();
1003 decoder.reset();
1004 },
1005 });
1006}
1007
1008/**
1009 * by listening to the event interface in {@linkcode Root}, compute delta
1010 * events for a progress stream. the caller is expected to fill `changed` and `deleted`
1011 */
1012class Encoder<Result extends ts.Json, EventMap extends { [key: string]: ts.Json[] }> {
1013 root: Root<Result, EventMap>;
1014 pool: KeyPool<EncodedKey> = new KeyPool();
1015 keyMap: Map<ReadOnlyNode, EncodedKey> = new Map();
1016 logsLength: WeakMap<ReadOnlyNode, number> = new WeakMap();
1017 serializeStackTraces: boolean;
1018
1019 rootChildrenUpdated = true;
1020 changed = new Map<ReadOnlyNode, Set<keyof ReadOnlyNode>>();
1021 deleted = new Set<ReadOnlyNode>();
1022 /** custom and terminal ("end"/"error") events awaiting the next delta */
1023 pendingEvents: StreamCustomEvent[] = [];
1024 /** set when "end" or "error" was recorded; the stream closes after flushing */
1025 ended = false;
1026
1027 constructor(root: Root<Result, EventMap>, serializeStackTraces: boolean = false) {
1028 this.root = root;
1029 this.serializeStackTraces = serializeStackTraces;
1030 for (const node of root.active) {
1031 this.changed.set(node, new Set());
1032 }
1033 }
1034
1035 /**
1036 * fill `changed`, `deleted`, and `pendingEvents` from the events of the
1037 * root. `soon` is called when there is a delta worth sending, and `now`
1038 * when it is the last one.
1039 */
1040 observe(soon: () => void, now: () => void): DisposableStack {
1041 const { root } = this;
1042 const stack = new DisposableStack();
1043 // TODO: why does ts require two args
1044 stack.use(
1045 root.on("node-start", (newNode, _) => {
1046 if (!newNode.parent) this.rootChildrenUpdated = true;
1047 this.changed.set(newNode, new Set());
1048 soon();
1049 }),
1050 );
1051 stack.use(
1052 root.on("node-change", (node, key) => {
1053 let set = this.changed.get(node);
1054 if (!set) this.changed.set(node, (set = new Set()));
1055 set.add(key);
1056 soon();
1057 }),
1058 );
1059 stack.use(
1060 root.on("node-end", (node) => {
1061 if (!node.parent) this.rootChildrenUpdated = true;
1062 this.changed.delete(node);
1063 this.deleted.add(node); // handle add and remove in same frame
1064 soon();
1065 }),
1066 );
1067 // custom events pass through the stream verbatim
1068 stack.use(
1069 root.onAny((channel, args) => {
1070 if (rootEvents.has(channel)) return;
1071 this.pendingEvents.push([channel as string, ...(args as ts.Json[])]);
1072 soon();
1073 }),
1074 );
1075 stack.use(
1076 root.on("end", (result) => {
1077 this.pendingEvents.push(["end", result]);
1078 this.ended = true;
1079 now();
1080 }),
1081 );
1082 stack.use(
1083 root.on("error", (error) => {
1084 this.pendingEvents.push(["error", exceptions.message(error)]);
1085 this.ended = true;
1086 now();
1087 }),
1088 );
1089 return stack;
1090 }
1091
1092 getDelta(isFirst = false) {
1093 const deleted = Array.from(this.deleted, (node) => [node, this.keyMap.get(node)] as const)
1094 .filter(([node, dst]) => {
1095 if (dst == null) return false; // started and stopped in same tick
1096 this.pool.recycle(dst);
1097 this.keyMap.delete(node);
1098 return true;
1099 })
1100 .map((x) => UNWRAP(x[1]));
1101 ASSERT(!isFirst || deleted.length === 0);
1102
1103 const { updated = [], started = [] } = Object.groupBy(this.changed, ([node]) =>
1104 this.keyMap.has(node) ? "updated" : "started",
1105 );
1106
1107 const payload: StreamEvent = [isFirst ? -1 : deleted.length, ...deleted];
1108
1109 if (started.length > 0) {
1110 this.beginNodesDepthFirst(
1111 started.reverse().map(([node]) => node),
1112 payload,
1113 );
1114 }
1115
1116 for (const [node, props] of updated) {
1117 let logs = undefined;
1118 if (props.has("logs") && node.logs.length > (this.logsLength.get(node) ?? 0)) {
1119 logs = this.redactLogsIfNeeded(node.logs.slice(this.logsLength.get(node) ?? 0));
1120 this.logsLength.set(node, node.logs.length);
1121 }
1122 payload.push({
1123 k: UNWRAP(this.keyMap.get(node)),
1124 t: props.has("text") ? node.text : undefined,
1125 v: props.has("value") ? node.value : undefined,
1126 e: props.has("total") ? node.total : undefined,
1127 s: props.has("showTotal") ? node.showTotal : undefined,
1128 p: props.has("passive") ? node.passive : undefined,
1129 l: logs,
1130 h: props.has("hidden") ? node.hidden : undefined,
1131 c: props.has("children")
1132 ? node.children.map((node) => UNWRAP(this.keyMap.get(node)))
1133 : undefined,
1134 E: props.has("estimatedTime") ? node.estimatedTime : undefined,
1135 V:
1136 node.valueFormatter !== defaultValueFormatter &&
1137 (props.has("value") || props.has("total") || props.has("valueFormatter"))
1138 ? node.valueFormatter(node.value, node.total)
1139 : undefined,
1140 [kNode]: node,
1141 });
1142 }
1143
1144 if (this.rootChildrenUpdated) {
1145 payload.push(this.root.active.map((node) => UNWRAP(this.keyMap.get(node))));
1146 }
1147
1148 payload.push(...this.pendingEvents);
1149
1150 this.deleted.clear();
1151 this.changed.clear();
1152 this.pendingEvents = [];
1153 this.rootChildrenUpdated = false;
1154
1155 return payload;
1156 }
1157
1158 beginNodesDepthFirst(nodes: ReadOnlyNode[], out: StreamEvent) {
1159 for (let i = 0; i < nodes.length; i += 1) {
1160 const node = UNWRAP(nodes[i]);
1161 nodes.push(...node.children);
1162 }
1163 let next;
1164 while ((next = nodes.pop())) {
1165 if (!this.keyMap.has(next)) {
1166 out.push(this.beginNode(next));
1167 }
1168 }
1169 }
1170
1171 beginNode(node: ReadOnlyNode): StreamNode {
1172 const k = this.pool.get();
1173 this.keyMap.set(node, k);
1174
1175 return {
1176 k,
1177 v: node.value !== 0 ? node.value : undefined,
1178 e: node.total !== 0 ? node.total : undefined,
1179 t: node.text,
1180 l:
1181 node.logs.length > 0
1182 ? (this.logsLength.set(node, node.logs.length),
1183 this.redactLogsIfNeeded(node.logs.slice()))
1184 : undefined,
1185 c:
1186 node.children.length > 0
1187 ? node.children.map((node) =>
1188 UNWRAP(this.keyMap.get(node), () => [node.children, this.keyMap]),
1189 )
1190 : undefined,
1191 p: node.passive === true ? true : undefined,
1192 s: node.showTotal === false ? false : undefined,
1193 h: node.hidden === true ? true : undefined,
1194 E: node.estimatedTime != null ? node.estimatedTime : undefined,
1195 V:
1196 node.valueFormatter !== defaultValueFormatter
1197 ? node.valueFormatter(node.value, node.total)
1198 : undefined,
1199 [kNode]: node,
1200 };
1201 }
1202
1203 redactLogsIfNeeded(messages: log.Message[]) {
1204 if (this.serializeStackTraces) return messages;
1205 return messages.map((x) => ({ ...x, stack: undefined }));
1206 }
1207}
1208
1209class Decoder<Result = void, EventMap extends Events.Map = ts.EmptyObject> {
1210 target: Ref;
1211 events: Events<EventMap>;
1212 resolve: (result: Result) => void;
1213 active = new Map<EncodedKey, Node>();
1214 pendingStart = new Map<EncodedKey, PendingStart>();
1215 pendingParents = new Map<EncodedKey, EncodedKey>();
1216
1217 constructor(target: Ref, events: Events<EventMap>, resolve: (result: Result) => void) {
1218 this.target = target;
1219 this.events = events;
1220 this.resolve = resolve;
1221 }
1222
1223 reset() {
1224 for (const v of this.active.values()) v.end();
1225 this.active.clear();
1226 }
1227
1228 /** returns whether the stream signalled "end"; throws on "error" */
1229 processEvent(event: StreamEvent): boolean {
1230 event = event.slice();
1231
1232 // delete unreferenced nodes first, since they may get re-allocated
1233 // in the next phase.
1234 const deletedCount = event.shift();
1235 ASSERT(typeof deletedCount === "number");
1236 if (deletedCount === -1) {
1237 // connection reset, delete all nodes
1238 this.reset();
1239 } else {
1240 for (const id of event.splice(0, deletedCount)) {
1241 ASSERT(typeof id === "number");
1242 UNWRAP(
1243 this.active.get(id as EncodedKey),
1244 () => `dstkey ${id} not found, ${[...this.active.keys()]}`,
1245 ).end();
1246 ASSERT(this.active.delete(id as EncodedKey));
1247 }
1248 }
1249
1250 // parse all of the events, updating all nodes and extracting creations
1251 let resolving = false;
1252 let resolvingValue: unknown = null;
1253 let updateChildren: Array<{
1254 key: EncodedKey;
1255 children: EncodedKey[];
1256 }> = [];
1257 for (const chunk of event) {
1258 ASSERT(typeof chunk === "object");
1259 if (Array.isArray(chunk)) {
1260 if (typeof chunk[0] === "string") {
1261 if (chunk[0] === "end") {
1262 resolving = true;
1263 resolvingValue = chunk[1];
1264 } else if (chunk[0] === "error") {
1265 throw new Error(`Progress stream error: ${chunk[1]}`);
1266 } else {
1267 // custom event
1268 // @ts-expect-error TODO: typescript soundness
1269 this.events.emit(...chunk);
1270 }
1271 } else {
1272 const ids = chunk as EncodedKey[];
1273 for (const id of ids) this.pendingParents.set(id, 0 as EncodedKey);
1274 }
1275 } else {
1276 // node update or creation
1277 const {
1278 k: key,
1279 t: text,
1280 v: value,
1281 e: total,
1282 s: showTotal,
1283 p: passive,
1284 h: hidden,
1285 l: messages,
1286 c: children,
1287 E: estimatedTime,
1288 V: formattedValue,
1289 } = chunk;
1290 const node = this.active.get(key);
1291 if (children) {
1292 for (const id of children) this.pendingParents.set(id, key);
1293 updateChildren.push({ key, children });
1294 }
1295 if (node) {
1296 if (text !== undefined) node.text = text;
1297 if (value !== undefined) node.value = value;
1298 if (total !== undefined) node.total = total;
1299 if (showTotal != null) node.showTotal = showTotal;
1300 if (passive != null) node.passive = passive;
1301 if (hidden != null) node.hidden = hidden;
1302 for (const message of messages ?? []) node.log.writeMessage(message);
1303 if (estimatedTime !== undefined) node.estimatedTime = estimatedTime;
1304 if (formattedValue !== undefined) node.valueFormatter = () => formattedValue;
1305 for (const id of children ?? []) this.pendingParents.set(id, key);
1306 } else {
1307 ASSERT(text != null, `DstKey ${key} not active but not created`);
1308 this.pendingStart.set(key, {
1309 text,
1310 value,
1311 total,
1312 showTotal,
1313 passive,
1314 hidden,
1315 messages,
1316 estimatedTime,
1317 formattedValue,
1318 });
1319 }
1320 }
1321 }
1322
1323 let entry;
1324 while ((entry = this.pendingStart.entries().next().value)) {
1325 const [key, value] = entry;
1326 this.startRecursive(key, value);
1327 }
1328 this.pendingParents.clear();
1329
1330 // validate that every referenced child became active; a failure here
1331 // means the stream is desynced
1332 for (const { key, children } of updateChildren) {
1333 UNWRAP(this.active.get(key), () => `parent ${key} not active`);
1334 for (const id of children) {
1335 UNWRAP(this.active.get(id), () => `child ${id} not active`);
1336 }
1337 }
1338
1339 if (resolving) {
1340 this.reset();
1341 this.resolve(resolvingValue as Result);
1342 return true;
1343 }
1344 return false;
1345 }
1346
1347 startRecursive(key: EncodedKey, opts: PendingStart): Ref {
1348 ASSERT(this.pendingStart.delete(key));
1349 // a node whose parent linkage is missing (malformed stream) attaches at
1350 // the root rather than killing the whole decoder
1351 const parentId = this.pendingParents.get(key) ?? (0 as EncodedKey);
1352 let ref: Ref | null = parentId === 0 ? this.target : (this.active.get(parentId) ?? null);
1353 if (!ref) {
1354 ref = this.startRecursive(parentId, UNWRAP(this.pendingStart.get(parentId)));
1355 }
1356 const { text, estimatedTime, formattedValue, ...options } = opts;
1357 const node = ref.start(text, {
1358 ...options,
1359 estimateCompletion: false,
1360 });
1361 this.active.set(key, node);
1362 node.estimatedTime = estimatedTime;
1363 if (formattedValue !== undefined) node.valueFormatter = () => formattedValue;
1364 this.pendingParents.delete(key);
1365 return node;
1366 }
1367}
1368
1369interface PendingStart {
1370 text: string;
1371 value?: number | undefined;
1372 total?: number | undefined;
1373 showTotal?: boolean | undefined;
1374 passive?: boolean | undefined;
1375 hidden?: boolean | undefined;
1376 messages?: log.Message[] | undefined;
1377 estimatedTime?: number | null | undefined;
1378 formattedValue?: string | undefined;
1379}
1380
1381/** this interface must not have more than 16 properties */
1382interface DeltaFlags {
1383 // the new status is always present since it's one bit
1384 hidden: boolean;
1385 passive: boolean;
1386 showTotal: boolean;
1387
1388 // for each packet that changed it stores out of line
1389 changedValue: boolean;
1390 changedTotal: boolean;
1391 changedLogs: boolean;
1392 changedText: boolean;
1393 changedChildren: boolean;
1394 changedEstimatedTime: boolean;
1395 changedFormattedValue: boolean;
1396
1397 estimatedTimeNonNull: boolean;
1398
1399 futureProofingPacket: boolean;
1400 deleted: boolean;
1401}
1402
1403/**
1404 * NOTE: this function contains bugs and its format is not yet stabilized.
1405 *
1406 * converts a {@linkcode Root|progress.Root} into an byte stream for
1407 * communicating progress over the process or network boundary. the output is a
1408 * raw binary payload that uses an extremely compact representation for the
1409 * nodes. if trying to transmit UTF-8, consider the JSON-encoding
1410 * {@linkcode encodeEventStream}
1411 *
1412 * if the given root adds custom event handlers, they must all have
1413 * json-serializable payloads.
1414 */
1415export function encodeByteStream<
1416 Result extends ts.Json,
1417 EventMap extends { [key: string]: ts.Json[] },
1418>(root: Root<Result, EventMap>, options?: EncodeStreamOptions): ReadableStream<Uint8Array> {
1419 const events = encodeEventStream(root, options);
1420 const transform = new TransformStream({ transform: writeStreamEvent });
1421 return events.pipeThrough(transform);
1422}
1423
1424/** convert {@linkcode StreamEvent} into a byte payload. Mutates the input event. */
1425function writeStreamEvent(event: StreamEvent, writer: stream.WriteTarget) {
1426 using w = new stream.BufferedWriter(writer);
1427
1428 let deleteCount = event.shift();
1429 ASSERT(typeof deleteCount === "number");
1430
1431 if (deleteCount === -1) {
1432 deleteCount = 0;
1433 w.write(header);
1434 } else {
1435 w.u8("\n".charCodeAt(0));
1436
1437 const deletedNodes = event.splice(0, deleteCount);
1438 w.varUint(deletedNodes.length);
1439 for (const dstKey of deletedNodes) {
1440 ASSERT(typeof dstKey === "number");
1441 w.varUint(dstKey);
1442 }
1443 }
1444
1445 const rootChildren = event.find(
1446 (x): x is StreamRootChildren => Array.isArray(x) && typeof x[0] === "number",
1447 );
1448 const changedNodes = event.filter(
1449 (x): x is StreamNode => typeof x === "object" && !Array.isArray(x),
1450 );
1451 w.varUint(changedNodes.length + +!!rootChildren);
1452 for (const obj of changedNodes) {
1453 ASSERT(typeof obj === "object" && "k" in obj);
1454 const {
1455 k: key,
1456 t: text,
1457 v: value,
1458 e: total,
1459 l: messages,
1460 c: childrenSet,
1461 E: estimatedTime,
1462 V: formattedValue,
1463 } = obj;
1464 const { hidden, showTotal, passive } = UNWRAP(obj[kNode]);
1465 ASSERT(key > 0);
1466 ASSERT(messages == null || messages.length > 0);
1467
1468 w.varUint(key);
1469 w.u16(
1470 encodeDeltaFlags({
1471 hidden,
1472 passive,
1473 showTotal,
1474 changedValue: value !== undefined,
1475 changedTotal: total !== undefined,
1476 changedLogs: messages !== undefined,
1477 changedText: text !== undefined,
1478 changedChildren: childrenSet !== undefined,
1479 changedEstimatedTime: estimatedTime !== undefined,
1480 changedFormattedValue: formattedValue !== undefined,
1481 estimatedTimeNonNull: estimatedTime !== null,
1482 }),
1483 );
1484 if (value !== undefined) w.f64(value);
1485 if (total !== undefined) w.f64(total);
1486 if (text !== undefined) w.stringWithLength(text);
1487 if (messages !== undefined) {
1488 w.varUint(messages.length);
1489 for (const msg of messages) {
1490 let level = logLevelSerialize.indexOf(msg.level ?? "info");
1491 if (level === -1) level = 0;
1492 w.u8(
1493 level +
1494 (msg.newline === false ? 1 << 4 : 0) +
1495 (msg.scope ? 1 << 5 : 0) +
1496 (msg.stack && msg.stack.length > 0 ? 1 << 6 : 0) +
1497 (msg.custom !== undefined ? 1 << 7 : 0),
1498 );
1499 w.stringWithLength(msg.text);
1500 w.varUint(Math.floor(msg.time));
1501 if (msg.scope) w.stringWithLength(msg.scope);
1502 if (msg.stack && msg.stack.length > 0) {
1503 w.varUint(msg.stack.length);
1504 for (const { fn, file, line, col } of msg.stack) {
1505 w.stringWithLength(fn ?? "");
1506 w.stringWithLength(file ?? "");
1507 w.varUint(line ?? 0);
1508 w.varUint(col ?? 0);
1509 }
1510 }
1511 if (msg.custom !== undefined) {
1512 w.stringWithLength(JSON.stringify(msg.custom));
1513 }
1514 }
1515 }
1516 if (childrenSet !== undefined) {
1517 w.varUint(childrenSet.length);
1518 for (const key of childrenSet) w.varUint(key);
1519 }
1520 if (estimatedTime != null) w.varUint(Math.floor(estimatedTime));
1521 if (formattedValue !== undefined) {
1522 w.stringWithLength(formattedValue);
1523 }
1524 }
1525 if (rootChildren) {
1526 w.varUint(0);
1527 w.varUint(rootChildren.length);
1528 for (const key of rootChildren) {
1529 w.varUint(key);
1530 }
1531 }
1532
1533 const customEvents = event.filter(
1534 (x): x is StreamCustomEvent => Array.isArray(x) && typeof x[0] === "string",
1535 );
1536 w.varUint(customEvents.length);
1537 for (const [event, ...args] of customEvents) {
1538 w.stringWithLength(event);
1539 w.stringWithLength(JSON.stringify(args));
1540 }
1541}
1542
1543/**
1544 * decodes a stream created by {@linkcode encodeByteStream} into managed calls to
1545 * `target.start`. when cancelling, all the managed nodes are destroyed.
1546 */
1547export function decodeByteStream<Result = void, EventMap extends Events.Map = ts.EmptyObject>(
1548 encoded: ReadableStream<Uint8Array>,
1549 target: Ref,
1550): async.Cancelable<Result> & Events<EventMap> {
1551 let cancelled = false;
1552 let reader: stream.BufferedReader | null = null;
1553 return decodeEventStream<Result, EventMap>(
1554 new ReadableStream<StreamEvent>({
1555 async start(controller) {
1556 reader = new stream.BufferedReader(encoded.getReader());
1557 try {
1558 // an event boundary with no further bytes is a clean end of stream
1559 while (reader && (await reader.ensureAvailableOrFalse(1))) {
1560 controller.enqueue(await readStreamEvent(reader));
1561 }
1562 if (!cancelled) controller.close();
1563 } catch (e) {
1564 if (!cancelled) {
1565 reader?.cancel(e);
1566 throw e;
1567 }
1568 } finally {
1569 reader?.releaseLock();
1570 reader = null;
1571 }
1572 },
1573 cancel(reason) {
1574 cancelled = true;
1575 reader?.releaseLock();
1576 reader = null;
1577 encoded.cancel(reason);
1578 },
1579 }),
1580 target,
1581 );
1582}
1583
1584async function readStreamEvent(r: stream.BufferedReader): Promise<StreamEvent> {
1585 const event: StreamEvent = [];
1586 const start = await r.peekU8();
1587 if (start === "\n".charCodeAt(0)) {
1588 await r.u8();
1589 let deletedCount = await r.varUint();
1590 event.push(deletedCount);
1591 while (deletedCount-- > 0) event.push(await r.varUint());
1592 } else {
1593 const readHeader = await r.readExactly(header.byteLength);
1594 if (!bytes.eql(readHeader, header)) {
1595 const m = JSON.stringify(string.decodeUtf8(readHeader));
1596 throw new Error(`Header mismatch, got ${m}`);
1597 }
1598 event.push(-1);
1599 }
1600 const changedNodes = await r.varUint();
1601 for (let i = 0; i < changedNodes; i += 1) {
1602 const key = await r.varUint();
1603 if (key === 0) {
1604 let count = await r.varUint();
1605 const array: StreamRootChildren = [];
1606 while (count-- > 0) array.push(await r.varUint());
1607 event.push(array);
1608 continue;
1609 }
1610 const flags = decodeDeltaFlags(await r.u16());
1611 const v = flags.changedValue ? await r.f64() : undefined;
1612 const e = flags.changedTotal ? await r.f64() : undefined;
1613 const t = flags.changedText ? await r.stringWithLength() : undefined;
1614 let l: undefined | log.Message[];
1615 if (flags.changedLogs) {
1616 l = [];
1617 let len = await r.varUint();
1618 while (len-- > 0) {
1619 const msgFlags = await r.u8();
1620 const level = logLevelSerialize[msgFlags & 0b1111] ?? "info";
1621 // bitwise tests: `msgFlags && (1 << 5) > 0` parsed as
1622 // `msgFlags && true`, which fabricated scope/stack/custom reads for
1623 // any message with a non-zero level and desynced the entire stream
1624 const partial = (msgFlags & (1 << 4)) !== 0;
1625 const hasScope = (msgFlags & (1 << 5)) !== 0;
1626 const hasStack = (msgFlags & (1 << 6)) !== 0;
1627 const hasCustom = (msgFlags & (1 << 7)) !== 0;
1628 const text = await r.stringWithLength();
1629 const time = await r.varUint();
1630 const scope = hasScope ? await r.stringWithLength() : undefined;
1631 let stack: stack.Frame[] | undefined = undefined;
1632 if (hasStack) {
1633 stack = [];
1634 let frames = await r.varUint();
1635 while (frames-- > 0) {
1636 const fn = await r.stringWithLength();
1637 const file = await r.stringWithLength();
1638 const line = await r.varUint();
1639 const col = await r.varUint();
1640 stack.push({
1641 fn: fn || null,
1642 file: file || null,
1643 line: line || null,
1644 col: col || null,
1645 });
1646 }
1647 }
1648 const custom = hasCustom ? JSON.parse(await r.stringWithLength()) : undefined;
1649 l.push({ level, text, time, scope, stack, custom, newline: partial ? false : undefined });
1650 }
1651 }
1652 let c: undefined | EncodedKey[] = undefined;
1653 if (flags.changedChildren) {
1654 c = [];
1655 let len = await r.varUint();
1656 while (len-- > 0) c.push((await r.varUint()) as EncodedKey);
1657 }
1658 const E = flags.changedEstimatedTime
1659 ? flags.estimatedTimeNonNull
1660 ? await r.varUint()
1661 : null
1662 : undefined;
1663 const V = flags.changedFormattedValue ? await r.stringWithLength() : undefined;
1664
1665 // a "v2"'s packet will go here, contents ignored by this version.
1666 if (flags.futureProofingPacket) {
1667 const len = await r.varUint();
1668 void (await r.readExactly(len));
1669 }
1670
1671 event.push({
1672 k: key as EncodedKey,
1673 t,
1674 v,
1675 e,
1676 s: flags.showTotal,
1677 p: flags.passive,
1678 l,
1679 h: flags.hidden,
1680 c,
1681 E,
1682 V,
1683 });
1684 }
1685
1686 // events
1687 let eventCount = await r.varUint();
1688 while (eventCount-- > 0) {
1689 const name = await r.stringWithLength();
1690 const args = JSON.parse(await r.stringWithLength());
1691 event.push([name, ...args]);
1692 }
1693
1694 return event;
1695}
1696
1697const logLevelSerialize = ["info", "error", "warn", "debug"] as const;
1698
1699function encodeDeltaFlags(node: Partial<DeltaFlags>) {
1700 return (
1701 (node.hidden ? 1 << 0 : 0) +
1702 (node.passive ? 1 << 1 : 0) +
1703 (node.showTotal ? 1 << 2 : 0) +
1704 (node.changedValue ? 1 << 3 : 0) +
1705 (node.changedTotal ? 1 << 4 : 0) +
1706 (node.changedLogs ? 1 << 6 : 0) +
1707 (node.changedText ? 1 << 7 : 0) +
1708 (node.changedChildren ? 1 << 8 : 0) +
1709 (node.changedEstimatedTime ? 1 << 9 : 0) +
1710 (node.changedFormattedValue ? 1 << 10 : 0) +
1711 (node.estimatedTimeNonNull ? 1 << 11 : 0) +
1712 (node.futureProofingPacket ? 1 << 14 : 0) +
1713 (node.deleted ? 1 << 15 : 0)
1714 );
1715}
1716
1717function decodeDeltaFlags(flags: number): DeltaFlags {
1718 return {
1719 hidden: (flags & (1 << 0)) !== 0,
1720 passive: (flags & (1 << 1)) !== 0,
1721 showTotal: (flags & (1 << 2)) !== 0,
1722 changedValue: (flags & (1 << 3)) !== 0,
1723 changedTotal: (flags & (1 << 4)) !== 0,
1724 changedLogs: (flags & (1 << 6)) !== 0,
1725 changedText: (flags & (1 << 7)) !== 0,
1726 changedChildren: (flags & (1 << 8)) !== 0,
1727 changedEstimatedTime: (flags & (1 << 9)) !== 0,
1728 changedFormattedValue: (flags & (1 << 10)) !== 0,
1729 estimatedTimeNonNull: (flags & (1 << 11)) !== 0,
1730 futureProofingPacket: (flags & (1 << 14)) !== 0,
1731 deleted: (flags & (1 << 15)) !== 0,
1732 };
1733}
1734
1735class KeyPool<T extends number> {
1736 next = 0;
1737 old: T[] = [];
1738
1739 get() {
1740 const existing = this.old.shift();
1741 if (existing) return existing as T;
1742 return (this.next += 1) as T;
1743 }
1744
1745 recycle(k: T) {
1746 this.old.push(k);
1747 }
1748}
1749
1750/** stateful estimation by providing samples */
1751export interface EstimationAlgorithm {
1752 /**
1753 * given a timestamp `time` and a progress value `progress`, return the
1754 * timestamp that the action will be finished on. called with (t, 0, 0)
1755 * to initialize the algorithm.
1756 */
1757 sample(time: number, current: number, total: number): number | null;
1758}
1759
1760/**
1761 * Exponential Moving Average.
1762 * this is the algorithm used for the default progress estimator.
1763 * https://en.wikipedia.org/wiki/Exponential_smoothing
1764 */
1765export class Ema implements EstimationAlgorithm {
1766 /** milliseconds */
1767 start: number | null = null;
1768 estimate: number | null = null;
1769 /** smoothing factor (0-1) */
1770 alpha: number;
1771 samples = 0;
1772
1773 constructor(alpha: number = 0.25) {
1774 this.alpha = alpha;
1775 }
1776
1777 sample(time: number, current: number, total: number): number | null {
1778 this.samples += 1;
1779
1780 ASSERT(current >= 0 && current <= total);
1781 if (this.start == null) {
1782 this.start = time;
1783 this.estimate = null;
1784 return null;
1785 }
1786
1787 const elapsed = time - this.start;
1788 if (elapsed === 0) return null; // too quick
1789
1790 const rate = current / elapsed;
1791 this.estimate =
1792 this.estimate != null ? (1 - this.alpha) * this.estimate + this.alpha * rate : rate;
1793 if (elapsed < 10_000 || this.estimate <= 0 || this.samples < 3) return null;
1794 const remaining = total - current;
1795 return time + remaining / this.estimate;
1796 }
1797
1798 reset(): void {
1799 this.start = null;
1800 this.estimate = null;
1801 }
1802}
1803
1804/** according to a stream */
1805type EncodedKey = number & { brand: typeof kNode };
1806
1807const globalKeyPool = new KeyPool<number>();
1808
1809/** names the pipe to a parent process that handed this one a place in its progress */
1810const ipcEnvVar = "CLOVER_PROGRESS";
1811
1812/**
1813 * the environment for a child process whose global root and log are to be
1814 * read by {@linkcode decodeAttachToRef}. `fd` is where the child has the pipe.
1815 */
1816export function ipcChildEnvironment(fd: number): Record<string, string> {
1817 return { [ipcEnvVar]: `${fd}:${UNWRAP(node.process).pid}` };
1818}
1819
1820export async function decodeAttachToRef(
1821 readable: ReadableStream<Uint8Array>,
1822 target: Ref,
1823): Promise<void> {
1824 const decoder = new Decoder(target, new Events(), noop);
1825 try {
1826 for await (const line of stream.lines(readable)) {
1827 const record: StreamEvent | log.Message = JSON.parse(line);
1828 if (Array.isArray(record)) decoder.processEvent(record);
1829 else log.writeMessage(record);
1830 }
1831 } finally {
1832 decoder.reset();
1833 }
1834}
1835
1836/**
1837 * the other end of {@linkcode decodeAttachToRef}. writes are synchronous, so that
1838 * the last words of a process are not lost when it exits.
1839 */
1840function tryAttachToParent(root: Root): boolean {
1841 const { process } = node;
1842 const fs = node.builtin("fs");
1843 const [fd, parent] = (process?.env[ipcEnvVar] ?? "").split(":").map(Number);
1844 // every descendant inherits the variable, but only the child has the pipe
1845 if (!process || !fs || !fd || process.ppid !== parent) return false;
1846
1847 const write = (record: StreamEvent | log.Message) => {
1848 try {
1849 fs.writeSync(fd, JSON.stringify(record) + "\n");
1850 } catch {
1851 // the parent is gone, and so is anyone to tell
1852 }
1853 };
1854
1855 const encoder = new Encoder(root);
1856 let timer: async.Cancelable<void> | null = null;
1857 const flush = () => {
1858 timer?.cancel();
1859 timer = null;
1860 write(encoder.getDelta());
1861 };
1862 const flushSoon = () => {
1863 if (timer) return;
1864 (timer = root.delay(1000 / 30)).then(flush);
1865 };
1866
1867 encoder.observe(flushSoon, flush);
1868 process.addListener("exit", () => timer && flush());
1869 // what changed on a node before a message is said stays before it
1870 log.replaceGlobalMessageDestination((m) => (timer && flush(), write({ ...m, stack: undefined })));
1871 return true;
1872}
1873
1874const global: Ref = /** @__PURE__ */ ((root = new Root()) => (
1875 tryAttachToParent(root) || attachToScreen(root, log),
1876 root
1877))();
1878
1879/**
1880 * a {@linkcode Ref} to the global progress root. unlike referencing the
1881 * namespace import, this value is tree-shakable.
1882 */
1883export const globalRoot: Ref = { start: (text, opts) => global.start(text, opts) };
1884
1885/**
1886 * for testing. not covered by semver
1887 * @internal
1888 */
1889export const internals: {
1890 readStreamEvent: typeof readStreamEvent;
1891 writeStreamEvent: typeof writeStreamEvent;
1892 kNode: symbol;
1893 EncodedKey: EncodedKey;
1894} = /** @__PURE__ */ (() => ({
1895 readStreamEvent,
1896 writeStreamEvent,
1897 kNode: kNode as symbol,
1898 EncodedKey: 0 as EncodedKey,
1899}))();
1900
1901import { ASSERT, UNWRAP } from "./assert.ts";
1902import * as async from "./async.ts";
1903import * as bytes from "./bytes.ts";
1904import { Events } from "./Events.ts";
1905import * as exceptions from "./exception.ts";
1906import * as log from "./log.ts";
1907import * as stack from "./log/stack.ts";
1908import * as math from "./math.ts";
1909import * as node from "./node.ts";
1910import * as stream from "./stream.ts";
1911import * as string from "./string.ts";
1912import * as ansi from "./string/ansi.ts";
1913import * as ts from "./ts.ts";
1914
1915import { isVisible, maxLogLines, remainingSeconds } from "./progress-format.ts";
1916
1917export { isVisible };
packages/clo-lib/stream.ts created+484
...@@ -0,0 +1,484 @@
1/**
2 * this module contains helpers for creating and consuming `ReadableStream`,
3 * particularly with binary payloads.
4 *
5 * @module
6 */
7
8const shared = new Uint8Array(8);
9const sharedView = new DataView(shared.buffer);
10
11/** write bytes `data` to a destination */
12export type WriteBytesFn = (data: Uint8Array) => void;
13
14/** something that can be written to */
15export type WriteTarget = WriteBytesFn | { enqueue(chunk: Uint8Array): void; close?(): void };
16
17/**
18 * `ReadableStreamDefaultController.enqueue` does not buffer data in any way.
19 * for use cases such as a streaming write of many small binary chunks, such as
20 * differently sized integers in the case of `progress.encodeByteStream`, this
21 * batches those many small writes into their own `ArrayBuffer` objects.
22 *
23 * when passing a `ReadableStreamDefaultController`, the `BufferedWriter`
24 * "owns" it, and will close the stream when disposed. to prevent that, pass
25 * a `WriteBytesFn` instead.
26 *
27 * ```ts
28 * const w = new BufferedWriter(unbufferedWrite);
29 * w.write(string.encodeUtf8("a null-terminated string"));
30 * w.u8(0);
31 * w.write(string.encodeUtf8("a second null-terminated string"));
32 * w.u8(0);
33 *
34 * w.flush(); // don't forget to flush!
35 * // `unbufferedWrite` will be called once with 57 bytes
36 * ```
37 */
38export class BufferedWriter {
39 #view: DataView<ArrayBuffer>;
40 #written: number = 0;
41 #flush: WriteBytesFn;
42 #size: number;
43 #onClose?: (() => void) | null | undefined;
44
45 constructor(flush: WriteTarget, size: number = 8192) {
46 this.#flush = typeof flush === "function" ? flush : flush.enqueue.bind(flush);
47 this.#view = new DataView(new ArrayBuffer(size));
48 this.#size = size;
49 this.#onClose = typeof flush === "function" ? null : flush.close?.bind(flush);
50 }
51
52 flush() {
53 const written = this.#written;
54 if (written === 0) return;
55 const size = this.#size;
56 if (written === size) {
57 // transfer
58 this.#flush(new Uint8Array(this.#view.buffer));
59 } else {
60 // copy
61 this.#flush(new Uint8Array(this.#view.buffer.slice(0, written)));
62 }
63 this.#view = new DataView(new ArrayBuffer(size));
64 this.#written = 0;
65 }
66
67 close() {
68 const written = this.#written;
69 if (written === 0) return;
70 const size = this.#size;
71 if (written === size) {
72 // transfer
73 this.#flush(new Uint8Array(this.#view.buffer));
74 } else {
75 // copy
76 this.#flush(new Uint8Array(this.#view.buffer.slice(0, written)));
77 }
78 this.#written = 0;
79 this.#size = 0;
80 this.#view = new DataView(new ArrayBuffer(0));
81 this.#onClose?.();
82 }
83
84 [Symbol.dispose]() {
85 this.close();
86 }
87
88 write(bytes: Uint8Array<ArrayBuffer> | ArrayBuffer) {
89 const len = bytes.byteLength;
90 if (len >= this.size) {
91 this.flush();
92 this.#flush("buffer" in bytes ? bytes : new Uint8Array(bytes));
93 return;
94 }
95 this.ensureUnusedCapacity(len);
96 new Uint8Array(this.#view.buffer, this.#written).set(
97 bytes instanceof ArrayBuffer ? new Uint8Array(bytes) : bytes,
98 );
99 this.#written += bytes.byteLength;
100 }
101
102 varUint(int: number) {
103 ASSERT(int >= 0);
104 do {
105 const data = int % (1 << 7);
106 int = Math.floor(int / (1 << 7));
107 const continuation = int > 0 ? 1 << 7 : 0;
108 this.u8(data + continuation);
109 } while (int > 0);
110 }
111
112 u8(int: number) {
113 const size = 1;
114 this.ensureUnusedCapacity(size);
115 this.#view.setUint8(this.#written, int);
116 this.#written += size;
117 }
118
119 u16(int: number) {
120 const size = 2;
121 this.ensureUnusedCapacity(size);
122 this.#view.setUint16(this.#written, int, true);
123 this.#written += size;
124 }
125
126 u32(int: number) {
127 const size = 4;
128 this.ensureUnusedCapacity(size);
129 this.#view.setUint32(this.#written, int, true);
130 this.#written += size;
131 }
132
133 u64(int: bigint) {
134 const size = 8;
135 this.ensureUnusedCapacity(size);
136 this.#view.setBigUint64(this.#written, int, true);
137 this.#written += size;
138 }
139
140 i8(int: number) {
141 const size = 1;
142 this.ensureUnusedCapacity(size);
143 this.#view.setUint8(this.#written, int);
144 this.#written += size;
145 }
146
147 i16(int: number) {
148 const size = 2;
149 this.ensureUnusedCapacity(size);
150 this.#view.setInt16(this.#written, int, true);
151 this.#written += size;
152 }
153
154 i32(int: number) {
155 const size = 4;
156 this.ensureUnusedCapacity(size);
157 this.#view.setInt32(this.#written, int, true);
158 this.#written += size;
159 }
160
161 i64(int: bigint) {
162 const size = 8;
163 this.ensureUnusedCapacity(size);
164 this.#view.setBigInt64(this.#written, int, true);
165 this.#written += size;
166 }
167
168 f16(float: number) {
169 const size = 2;
170 this.ensureUnusedCapacity(size);
171 this.#view.setFloat16(this.#written, float, true);
172 this.#written += size;
173 }
174
175 f32(float: number) {
176 const size = 4;
177 this.ensureUnusedCapacity(size);
178 this.#view.setFloat32(this.#written, float, true);
179 this.#written += size;
180 }
181
182 f64(float: number) {
183 const size = 8;
184 this.ensureUnusedCapacity(size);
185 this.#view.setFloat64(this.#written, float, true);
186 this.#written += size;
187 }
188
189 stringWithLength(text: string) {
190 const buf = string.encodeUtf8(text);
191 this.varUint(buf.byteLength);
192 this.write(buf);
193 }
194
195 get written(): number {
196 return this.#written;
197 }
198 get size(): number {
199 return this.#size;
200 }
201
202 ensureUnusedCapacity(extra: number) {
203 const size = this.size;
204 const written = this.#written;
205 if (written + extra > size) {
206 if (extra > size) {
207 this.resize(extra);
208 } else {
209 this.flush();
210 }
211 }
212 }
213
214 resize(newCapacity: number) {
215 newCapacity = Math.round(newCapacity);
216 const written = this.#written;
217 if (written > 0) {
218 // transfer
219 this.#flush(new Uint8Array(this.#view.buffer, 0, written));
220 this.#written = 0;
221 }
222 this.#size = newCapacity;
223 this.#view = new DataView(new ArrayBuffer(newCapacity));
224 }
225}
226
227/**
228 * a reader for `ReadableStream` which allows the caller to specify the
229 * chunking size and read numbers at a time.
230 */
231export class BufferedReader implements Disposable {
232 #reader: ReadableStreamDefaultReader<Uint8Array> | null;
233 #offset = 0;
234 #buffer: Uint8Array[] = [];
235 #available = 0;
236
237 constructor(reader: ReadableStreamDefaultReader<Uint8Array>) {
238 this.#reader = reader;
239 }
240
241 get available(): number {
242 return this.#available;
243 }
244
245 /** Releasing will discard the bytes remaining in the buffer. */
246 releaseLock() {
247 UNWRAP(this.#reader, "reader disposed").releaseLock();
248 this.#reader = null;
249 }
250
251 cancel(error: unknown) {
252 UNWRAP(this.#reader, "reader disposed").cancel(error);
253 }
254
255 async ensureAvailableOrFalse(size: number): Promise<boolean> {
256 if (size <= 0 || this.#available >= size) return true;
257 const reader = UNWRAP(this.#reader, "reader disposed");
258 const buffer = this.#buffer;
259 while (this.#available < size) {
260 const { value, done } = await reader.read();
261 if (done) break;
262 buffer.push(value);
263 this.#available += value.byteLength;
264 }
265 return this.#available >= size;
266 }
267 async ensureAvailable(size: number): Promise<void> {
268 ASSERT(await this.ensureAvailableOrFalse(size), "End of stream");
269 }
270
271 #collectLimitedView(length: number): DataView {
272 ASSERT(this.available >= length);
273 const buffer = this.#buffer;
274 let chunk = UNWRAP(buffer[0]);
275 let offset = this.#offset;
276 if (chunk.byteLength - offset >= length) {
277 this.#shiftOffset(length);
278 return new DataView(chunk.buffer, chunk.byteOffset + offset, length);
279 }
280 let x = 0;
281 while (true) {
282 for (; offset < chunk.byteLength && x < length; offset += 1, x += 1)
283 shared[x] = chunk[offset]!;
284 if (x === length) {
285 if (offset === chunk.byteLength) {
286 ASSERT(buffer.shift());
287 this.#offset = 0;
288 } else this.#offset = offset;
289 this.#available -= length;
290 return sharedView;
291 }
292 ASSERT(buffer.shift());
293 chunk = UNWRAP(buffer[0]);
294 offset = 0;
295 }
296 }
297 #collectBuffer(length: number) {
298 ASSERT(this.available >= length);
299 this.#available -= length;
300 const buffer = this.#buffer;
301 let offset = this.#offset;
302
303 // slice an existing data view
304 if (buffer[0] && buffer[0].byteLength - offset >= length) {
305 const first = buffer[0];
306 if (first.byteLength - offset === length) {
307 this.#offset = 0;
308 ASSERT(buffer.shift());
309 return offset ? first.subarray(offset) : first;
310 }
311 this.#offset += length;
312 return first.subarray(offset, offset + length);
313 }
314
315 // join by allocating a new buffer
316 const joined = new Uint8Array(length);
317 let i = 0;
318 do {
319 const chunk = UNWRAP(buffer[0]);
320 const { byteLength } = chunk;
321 const copyLen = Math.min(byteLength, length);
322 joined.set(copyLen === byteLength ? chunk : chunk.subarray(offset, offset + copyLen), i);
323 i += copyLen;
324 length -= copyLen;
325 if (copyLen === byteLength) ASSERT(buffer.shift());
326 if (length === 0) this.#offset = copyLen;
327 } while (length > 0);
328 return joined;
329 }
330 #shiftOffset(offset: number) {
331 const newOffset = (this.#offset += offset);
332 this.#available -= offset;
333 if (UNWRAP(this.#buffer[0]).byteLength === newOffset) {
334 this.#buffer.shift();
335 this.#offset = 0;
336 } else {
337 this.#offset = newOffset;
338 }
339 }
340
341 async u8(): Promise<number> {
342 const int = await this.peekU8();
343 this.#shiftOffset(1);
344 return int;
345 }
346
347 async peekU8(): Promise<number> {
348 await this.ensureAvailable(1);
349 return UNWRAP(UNWRAP(this.#buffer[0])[this.#offset]);
350 }
351
352 async i8(): Promise<number> {
353 await this.ensureAvailable(2);
354 return this.#collectLimitedView(1).getInt8(0);
355 }
356
357 async u16(): Promise<number> {
358 await this.ensureAvailable(2);
359 return this.#collectLimitedView(2).getUint16(0, true);
360 }
361
362 async i16(): Promise<number> {
363 await this.ensureAvailable(2);
364 return this.#collectLimitedView(2).getInt16(0, true);
365 }
366
367 async u32(): Promise<number> {
368 await this.ensureAvailable(4);
369 return this.#collectLimitedView(4).getUint32(0, true);
370 }
371
372 async i32(): Promise<number> {
373 await this.ensureAvailable(4);
374 return this.#collectLimitedView(4).getInt32(0, true);
375 }
376
377 async u64(): Promise<bigint> {
378 await this.ensureAvailable(8);
379 return this.#collectLimitedView(8).getBigUint64(0, true);
380 }
381
382 async i64(): Promise<bigint> {
383 await this.ensureAvailable(8);
384 return this.#collectLimitedView(8).getBigInt64(0, true);
385 }
386
387 async f16(): Promise<number> {
388 await this.ensureAvailable(2);
389 return this.#collectLimitedView(2).getFloat16(0, true);
390 }
391
392 async f32(): Promise<number> {
393 await this.ensureAvailable(4);
394 return this.#collectLimitedView(4).getFloat32(0, true);
395 }
396
397 async f64(): Promise<number> {
398 await this.ensureAvailable(8);
399 return this.#collectLimitedView(8).getFloat64(0, true);
400 }
401
402 async varUint(): Promise<number> {
403 let result = 0;
404 let shift = 1;
405 let byte;
406 do {
407 byte = await this.u8();
408 result += (byte & 0x7f) * shift;
409 shift *= 128;
410 } while (byte & 0x80);
411 return result;
412 }
413
414 async stringWithLength(): Promise<string> {
415 const len = await this.varUint();
416 return string.decodeUtf8(await this.readExactly(len));
417 }
418
419 /** Read up to a maximum number of bytes */
420 async readUpTo(size: number): Promise<Uint8Array> {
421 await this.ensureAvailableOrFalse(size);
422 return this.#collectBuffer(Math.min(size, this.#available));
423 }
424 async readExactly(size: number): Promise<Uint8Array> {
425 await this.ensureAvailable(size);
426 return this.#collectBuffer(size);
427 }
428
429 [Symbol.dispose]() {
430 this.releaseLock();
431 }
432}
433
434/** easily create a readable stream with a writer */
435export function bufferedReadableStream(): [
436 ReadableStream,
437 BufferedWriter,
438 ReadableStreamDefaultController<Uint8Array>,
439] {
440 let unbuffered: ReadableStreamDefaultController<Uint8Array> | null = null;
441 const reader = new ReadableStream<Uint8Array>({
442 start: (controller) => (unbuffered = controller),
443 });
444 ASSERT(unbuffered);
445 return [reader, new BufferedWriter(unbuffered), unbuffered];
446}
447
448/** easily create a readable stream with a writer */
449export function readWritePair(): [ReadableStream, ReadableStreamDefaultController] {
450 let unbuffered: ReadableStreamDefaultController<Uint8Array> | null = null;
451 const reader = new ReadableStream<Uint8Array>({
452 start: (controller) => (unbuffered = controller),
453 });
454 ASSERT(unbuffered);
455 return [reader, unbuffered];
456}
457
458/**
459 * decode utf-8 and yield each line without its ending, which is any of `\n`,
460 * `\r\n`, or the lone `\r` that command line programs redraw a status with.
461 */
462export async function* lines(readable: ReadableStream<Uint8Array>): AsyncGenerator<string> {
463 const decoder = new TextDecoder();
464 let rest = "";
465 let endsInCarriageReturn = false;
466
467 for await (const bytes of readable) {
468 let chunk = decoder.decode(bytes, { stream: true });
469 if (chunk === "") continue;
470 // the `\n` of a `\r\n` that arrived in two chunks
471 if (endsInCarriageReturn && chunk[0] === "\n") chunk = chunk.slice(1);
472 endsInCarriageReturn = chunk.endsWith("\r");
473
474 const parts = (rest + chunk).split(/\r\n|\r|\n/);
475 rest = UNWRAP(parts.pop());
476 yield* parts;
477 }
478
479 rest += decoder.decode();
480 if (rest) yield rest;
481}
482
483import { ASSERT, UNWRAP } from "./assert.ts";
484import * as string from "./string.ts";
packages/clo-lib/string.ts created+121
...@@ -0,0 +1,121 @@
1/**
2 * helpers for strings, escaping, and encoding.
3 *
4 * @module
5 */
6
7/** count the number of `\n` characters are in the string */
8export function countNewlines(str: string): number {
9 let count = 0;
10 for (let i = 0, { length } = str; i < length; i += 1) {
11 if (str[i] === "\n") count += 1;
12 }
13 return count;
14}
15
16/**
17 * escape a string for use in a regular expression.
18 * @deprecated use `RegExp.escape` for browsers past May 2025
19 */
20export const escapeRegExp: (unsafeText: string) => string =
21 RegExp.escape ?? ((unsafeText: string) => unsafeText.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
22
23/** escape a string for use in a regular expression. */
24export function escapeRegExpReplacement(unsafeText: string): string {
25 return unsafeText.replace(/\$/g, "$$$$");
26}
27
28export function escapeHtmlContent(unsafeText: string): string {
29 return unsafeText.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
30}
31
32export function escapeHtmlAttr(unsafeText: string): string {
33 return escapeHtmlContent(unsafeText).replace(/'/g, "&#x27;").replace(/"/g, "&quot;");
34}
35
36/**
37 * Escape a string for use in a POSIX shell. Prefers easier to read output vs
38 * simpler implementation.
39 */
40export function escapeShellArgument(s: string): string {
41 if (s.length === 0) return "''";
42 if (/^[a-zA-Z0-9_\-./@,+=%:]+$/.test(s) && s[0] !== ":") return s;
43 if (!s.includes("'")) return `'${s}'`;
44 if (!/["$`\\]/.test(s)) return `"${s}"`;
45 let complex = `'${s.replace(/'/g, "'\\''")}'`;
46 if (s.endsWith("'")) complex = complex.slice(0, -2);
47 if (s.startsWith("'")) complex = complex.slice(2);
48 return complex;
49}
50
51const te = new TextEncoder();
52export function encodeUtf8(input: string): Uint8Array<ArrayBuffer> {
53 return te.encode(input) as Uint8Array<ArrayBuffer>;
54}
55
56const td = new TextDecoder();
57export function decodeUtf8(input: Uint8Array): string {
58 return td.decode(input);
59}
60
61const byteUnits = "kMGTPEZYRQ"; // kilo, mega, giga, etc
62export function formatByteSize(bytes: number): string {
63 if (!Number.isFinite(bytes)) return bytes.toString();
64 let prefix = "";
65 if (bytes < 0) ((bytes = -bytes), (prefix = "-"));
66 if (bytes < 1000) return Math.round(bytes * 100) / 100 + "B";
67 let unit = 0;
68 do {
69 bytes /= 1000;
70 unit += 1;
71 } while (bytes >= 1000);
72 return (
73 prefix +
74 (unit === 1
75 ? String(+bytes.toFixed(bytes > 100 ? 1 : 2))
76 : bytes.toFixed(Math.floor(bytes) === bytes ? 0 : bytes > 100 ? 1 : 2)) +
77 byteUnits.charAt(unit - 1) +
78 "B"
79 );
80}
81
82export function formatBinaryByteSize(bytes: number): string {
83 let prefix = "";
84 if (bytes < 0) ((bytes = -bytes), (prefix = "-"));
85 if (bytes < 1024) return Math.round(bytes * 100) / 100 + "B";
86 let unit = 0;
87 do {
88 bytes /= 1024;
89 unit += 1;
90 } while (bytes >= 1024);
91 return prefix + Math.round(bytes * 100) / 100 + byteUnits.charAt(unit - 1) + "iB";
92}
93
94export function formatDurationLetters(seconds: number): string {
95 if (seconds === 0) return "now";
96 if (!Number.isFinite(seconds)) {
97 if (seconds === Infinity) return "forever";
98 if (seconds === -Infinity) return "forever ago";
99 return "NaN";
100 }
101 let ago = "";
102 if (seconds < 0) ((seconds = -seconds), (ago = " ago"));
103 const units = [
104 [31536000, "y"],
105 [86400, "d"],
106 [3600, "h"],
107 [60, "m"],
108 ] as const;
109 let out = "";
110 let remain = seconds;
111 let u = 0;
112 for (const [size, label] of units) {
113 if (remain < size) continue;
114 const value = Math.floor(remain / size);
115 remain -= value * size;
116 out += `${value}${label}`;
117 if ((u += 1) === 2) return out + ago;
118 }
119 if (remain > 0 || out === "") out += `${+remain.toFixed(2)}s`;
120 return out + ago;
121}
packages/clo-lib/string/ansi.ts created+596
...@@ -0,0 +1,596 @@
1/**
2 * ANSI escape sequences can be used to style text in a tty or perform other
3 * interactions. this file contains many constants for escape codes, as well as
4 * functions for ones that take an argument, such as `cursorUp`.
5 *
6 * note that this file only produces escape sequences, and does not yet feature
7 * detection or fallback code.
8 *
9 * @module ansi
10 */
11
12/** resets all ansi styles */
13export const reset: string = `\x1b[0m`;
14
15/**
16 * puts the text in bold. can be combined with `dim` and colors.
17 * reset with `resetWeight`.
18 */
19export const bold: string = `\x1b[1m`;
20/**
21 * puts the text a more faded color, dimming it. can be combined with `bold` to
22 * produce bold-dim text. dim can be combined with any color to make a dimmed
23 * version of that color. reset with `resetWeight`.
24 */
25export const dim: string = `\x1b[2m`;
26/** resets `bold` and `dim` */
27export const resetWeight: string = `\x1b[22m`;
28
29/**
30 * swaps the foreground and background colors, which reads as a highlight in
31 * any color scheme. reset with `resetInverse`.
32 */
33export const inverse: string = `\x1b[7m`;
34/** resets `inverse` */
35export const resetInverse: string = `\x1b[27m`;
36
37/** resets foreground color */
38export const fgReset: string = `\x1b[39m`;
39/** sets the foreground color to black. on light mode terminals, this color may
40 * be visible. */
41export const fgBlack: string = `\x1b[30m`;
42/** sets the foreground color to red */
43export const fgRed: string = `\x1b[31m`;
44/** sets the foreground color to green */
45export const fgGreen: string = `\x1b[32m`;
46/** sets the foreground color to yellow */
47export const fgYellow: string = `\x1b[33m`;
48/** sets the foreground color to blue */
49export const fgBlue: string = `\x1b[34m`;
50/** sets the foreground color to magenta */
51export const fgMagenta: string = `\x1b[35m`;
52/** sets the foreground color to cyan */
53export const fgCyan: string = `\x1b[36m`;
54/**
55 * sets the foreground color to (dark) white / grey / gray. this color is
56 * brighter than `fgBrightBlack`. consider `dim` for greyed out text and
57 * `fgReset` to set the default foreground color.
58 */
59export const fgWhite: string = `\x1b[37m`;
60/**
61 * sets the foreground color to bright black / grey / gray. consider `dim` for
62 * greyed out text.
63 */
64export const fgBrightBlack: string = `\x1b[90m`;
65/** sets the foreground color to bright red */
66export const fgBrightRed: string = `\x1b[91m`;
67/** sets the foreground color to bright green */
68export const fgBrightGreen: string = `\x1b[92m`;
69/** sets the foreground color to bright yellow */
70export const fgBrightYellow: string = `\x1b[93m`;
71/** sets the foreground color to bright blue */
72export const fgBrightBlue: string = `\x1b[94m`;
73/** sets the foreground color to bright magenta */
74export const fgBrightMagenta: string = `\x1b[95m`;
75/** sets the foreground color to bright cyan */
76export const fgBrightCyan: string = `\x1b[96m`;
77/**
78 * sets the foreground color to bright white. consider `dim` for greyed out
79 * text and `fgReset` to set the default foreground color.
80 */
81export const fgBrightWhite: string = `\x1b[97m`;
82/** sets the foreground color to a color in the 256 color palette. */
83export function fg256(n: number): string {
84 return `\x1b[38;5;${n}m`;
85}
86/** sets the foreground color to an rgb color. */
87export function fgRgb(r: number, g: number, b: number): string {
88 return `\x1b[38;2;${r};${g};${b}m`;
89}
90
91/** resets the cell background color. */
92export const bgReset: string = `\x1b[49m`;
93/** sets the cell background to black. consider `bgReset` to clear the background. */
94export const bgBlack: string = `\x1b[40m`;
95/** sets the cell background to red. */
96export const bgRed: string = `\x1b[41m`;
97/** sets the cell background to green. */
98export const bgGreen: string = `\x1b[42m`;
99/** sets the cell background to yellow. */
100export const bgYellow: string = `\x1b[43m`;
101/** sets the cell background to blue. */
102export const bgBlue: string = `\x1b[44m`;
103/** sets the cell background to magenta. */
104export const bgMagenta: string = `\x1b[45m`;
105/** sets the cell background to cyan. */
106export const bgCyan: string = `\x1b[46m`;
107/**
108 * sets the cell background to (dark) white / grey / gray. this color is
109 * brighter than `bgBrightBlack`.
110 */
111export const bgWhite: string = `\x1b[47m`;
112/**
113 * sets the cell background to bright black. consider `bgReset` to clear the
114 * background.
115 */
116export const bgBrightBlack: string = `\x1b[100m`;
117/** sets the cell background to bright red. */
118export const bgBrightRed: string = `\x1b[101m`;
119/** sets the cell background to bright green. */
120export const bgBrightGreen: string = `\x1b[102m`;
121/** sets the cell background to bright yellow. */
122export const bgBrightYellow: string = `\x1b[103m`;
123/** sets the cell background to bright blue. */
124export const bgBrightBlue: string = `\x1b[104m`;
125/** sets the cell background to bright magenta. */
126export const bgBrightMagenta: string = `\x1b[105m`;
127/** sets the cell background to bright cyan. */
128export const bgBrightCyan: string = `\x1b[106m`;
129/** sets the cell background to bright white. */
130export const bgBrightWhite: string = `\x1b[107m`;
131/** sets the background color to a color in the 256 color palette. */
132export function bg256(n: number): string {
133 return `\x1b[48;5;${n}m`;
134}
135/** sets the background color to an rgb color. */
136export function bgRgb(r: number, g: number, b: number): string {
137 return `\x1b[48;2;${r};${g};${b}m`;
138}
139
140// cursor management
141
142/** save the position of the cursor, restored with {@linkcode cursorRestore}. */
143export const cursorSave: string = "\x1b7";
144/** restore the position of the cursor, saved with {@linkcode cursorSave}. */
145export const cursorRestore: string = "\x1b8";
146
147/** move the cursor up `n` units. */
148export function cursorUp(n: number): string {
149 return n ? `\x1b[${n}A` : "";
150}
151/** move the cursor down `n` units. */
152export function cursorDown(n: number): string {
153 return n ? `\x1b[${n}B` : "";
154}
155/** move the cursor right `n` units. */
156export function cursorRight(n: number): string {
157 return n ? `\x1b[${n}C` : "";
158}
159/** move the cursor right `n` units. */
160export function cursorLeft(n: number): string {
161 return n ? `\x1b[${n}D` : "";
162}
163/** move the cursor to the top left of the screen. */
164export const cursorHome: string = "\x1b[H";
165/** move the cursor to the start of it's current line. */
166export const startOfLine: string = "\r";
167/** move the cursor to the start of the next line. */
168export const startOfNextLine: string = "\n";
169
170// erasure
171/** clear from and including the current cell right to the end of the line. */
172export const clearToEndOfLine: string = "\x1b[K";
173/** clear from and including the current cell left to the start of the line. */
174export const clearToStartOfLine: string = "\x1b[1K";
175/** clear the entire line that the cursor is on without moving it. */
176export const clearFullLine: string = "\x1b[2K";
177/** clear from and including the current cell to the end of the screen. */
178export const clearToEndOfScreen: string = "\x1b[0J";
179
180/**
181 * Begin Synchronized Output
182 * https://gist.github.com/christianparpart/d8a62cc1ab659194337d73e399004036
183 */
184export const syncStart: string = "\x1b[?2026h";
185/** End Synchronized Output */
186export const syncEnd: string = "\x1b[?2026l";
187
188/**
189 * Apply ANSI styles to text. `code` is a concatenation of zero or more styles,
190 * which will be cleared as required.
191 */
192export function style(code: string, text: string): string {
193 code = mergeStyles(code);
194 if (!code) return text;
195 const fg = /[\[;][39]/.test(code);
196 const bg = /[\[;](?:4|10)/.test(code);
197 const bold = /[\[;](?:1)[;m]/.test(code);
198 const dim = /[\[;](?:2)[;m]/.test(code);
199 return (
200 code +
201 text +
202 mergeStyles(
203 (fg ? fgReset : "") +
204 (bg ? bgReset : "") +
205 (bold ? resetWeight : "") +
206 (dim ? resetWeight : ""),
207 )
208 );
209}
210
211/**
212 * Given a string of multiple style ansi codes, combines all of them.
213 * Must be used on a string of only ansi style codes.
214 */
215export function mergeStyles(code: string): string {
216 let styles = [];
217 while (code.length) {
218 if (!code.startsWith("\x1b[")) {
219 throw new Error("invalid style string: " + JSON.stringify(code));
220 }
221 const end = code.indexOf("m");
222 if (end === -1) {
223 throw new Error("invalid style string: " + JSON.stringify(code));
224 }
225 styles.push(code.slice(2, end));
226 code = code.slice(end + 1);
227 }
228 return `\x1b[${styles.join(";")}m`;
229}
230
231/**
232 * Convert a string with ansi text into a string where all the codes have been
233 * turned into their original names.
234 */
235export function debugAnsi(text: string): string {
236 // deno-fmt-ignore
237 const verbaitim: (keyof typeof self)[] = [
238 "fgCyan",
239 "fgBlue",
240 "bgReset",
241 "bgBlack",
242 "bgRed",
243 "bgGreen",
244 "bgYellow",
245 "bgBlue",
246 "bgMagenta",
247 "bgCyan",
248 "bgWhite",
249 "bgBrightBlack",
250 "bgBrightRed",
251 "bgBrightGreen",
252 "bgBrightYellow",
253 "bgBrightBlue",
254 "bgBrightMagenta",
255 "bgBrightCyan",
256 "bgBrightWhite",
257 "fgReset",
258 "fgBlack",
259 "fgRed",
260 "fgGreen",
261 "fgYellow",
262 "fgMagenta",
263 "fgWhite",
264 "fgBrightBlack",
265 "fgBrightRed",
266 "fgBrightGreen",
267 "fgBrightYellow",
268 "fgBrightBlue",
269 "fgBrightMagenta",
270 "fgBrightCyan",
271 "fgBrightWhite",
272 "clearToEndOfLine",
273 "clearToStartOfLine",
274 "clearFullLine",
275 "clearToEndOfScreen",
276 "syncStart",
277 "syncEnd",
278 "bold",
279 "dim",
280 "resetWeight",
281 "cursorSave",
282 "cursorRestore",
283 ];
284 for (const key of verbaitim) {
285 text = text.replaceAll(
286 String(self[key]),
287 style(key.includes("cursor") ? fgBlue : fgCyan, `\${${key}}`),
288 );
289 }
290 text = text.replace(/\x1b\[(\d+)A/g, style(fgYellow, "$${cursorUp($1)}"));
291 text = text.replace(/\x1b\[(\d+)B/g, style(fgYellow, "$${cursorDown($1)}"));
292 text = text.replace(/\x1b\[(\d+)C/g, style(fgYellow, "$${cursorRight($1)}"));
293 text = text.replace(/\x1b\[(\d+)D/g, style(fgYellow, "$${cursorLeft($1)}"));
294 text = text.replace(/\n/g, style(fgRed, "\\n"));
295 text = text.replace(/\r/g, style(fgMagenta, "\\r"));
296 return text;
297}
298
299/** Measure the visible length of a string, ignoring ansi codes. */
300export function widthInTerminal(str: string): number {
301 segmenter ??= new Intl.Segmenter();
302 let width = 0;
303 if (str.includes("\x1b")) str = str.replace(ansiEscapes, "");
304 for (let i = 0, { length } = str; i < length; i += 1) {
305 const code = str.charCodeAt(i);
306 if (code < 32) continue;
307 if (code < 127) {
308 width += 1;
309 continue;
310 }
311 // the remainder of the string is measured with the segmenter, which
312 // handles ascii correctly via segmentWidth (controls are zero-width).
313 for (const { segment } of segmenter.segment(str.slice(i))) {
314 width += segmentWidth(segment);
315 }
316 return width;
317 }
318 return width;
319}
320
321/**
322 * Like `str.slice(0, columns)`, but respecting width in a terminal.
323 * preserves ansi escape sequences.
324 */
325export function trimForTerminal(str: string, columns: number): string {
326 segmenter ??= new Intl.Segmenter();
327 let width = 0;
328 let ansi = false;
329 for (let i = 0, { length } = str; i < length; i += 1) {
330 const code = str.charCodeAt(i);
331 if (code < 32) {
332 if (code === 0x1b) {
333 const esc = str.slice(i).match(ansiEscapes);
334 if (esc) ((i += esc[0].length - 1), (ansi = true));
335 }
336 continue;
337 }
338 if (code < 127) {
339 width += 1;
340 if (width >= columns) {
341 const result = str.slice(0, i + 1);
342 return ansi ? result + "\x1b[0m" : result;
343 }
344 continue;
345 }
346 // non-ascii path
347 let out = str.slice(0, i);
348 str = str.slice(i);
349 const segments = segmenter.segment(str);
350 {
351 const { segment } = UNWRAP(segments.containing(0));
352 width += segmentWidth(segment);
353 if (width > columns) return ansi ? out + "\x1b[0m" : out;
354 out += segment;
355 i = segment.length;
356 }
357 for ({ length } = str; i < length; i += 1) {
358 const code = str.charCodeAt(i);
359 if (code === 0x1b) {
360 const esc = str.slice(i).match(ansiEscapes);
361 if (esc) {
362 ((i += esc[0].length - 1), (ansi = true));
363 out += esc[0];
364 continue;
365 }
366 }
367 const { segment } = UNWRAP(segments.containing(i));
368 width += segmentWidth(segment);
369 if (width > columns) return ansi ? out + "\x1b[0m" : out;
370 out += segment;
371 }
372 return out;
373 }
374 return str;
375}
376
377// derived from sindresorhus's wonderful `string-width`
378// https://github.com/sindresorhus/string-width/blob/42e7b697393a9b9ff732f9bd3ab1db87c3c208b2/index.js
379// https://github.com/sindresorhus/get-east-asian-width/blob/6aee3824b5e2ade910a38ed93cf9d89b7b5a8e5b/index.js
380// MIT license
381let segmenter: Intl.Segmenter | null = null;
382const ansiEscapes =
383 /[\u001B\u009B][[\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\d\/\#&.:=?%@~_]+)*|[a-zA-Z\d]+(?:;[-a-zA-Z\d\/\#&.:=?%@~_]*)*)?(?:\u0007|\u001B\u005C|\u009C))|(?:(?:\d{1,4}(?:;\d{0,4})*)?[\dA-PR-TZcf-nq-uy=><~]))/g;
384const zeroWidthClusterRegex =
385 /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v;
386const leadingNonPrintingRegex =
387 /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v;
388const rgiEmojiRegex = /^\p{RGI_Emoji}$/v;
389
390/** remove all ansi escape sequences */
391export function strip(text: string): string {
392 return text.replace(ansiEscapes, "");
393}
394
395/**
396 * ansi text as html, for a browser. the sixteen colors are `var(--ansi0)` to
397 * `var(--ansi15)`, which {@linkcode htmlColors} defines. no element spans a
398 * line break, so the html splits into lines where the text does.
399 */
400export function toHtml(text: string): string {
401 const style = plainStyle();
402 let html = "";
403 let last = 0;
404 for (const match of text.matchAll(sequences)) {
405 html += formatRun(text.slice(last, match.index), style);
406 last = match.index + match[0].length;
407 if (match[2] === "m") applySgr(style, (match[1] || "0").split(/[;:]/).map(Number));
408 }
409 return html + formatRun(text.slice(last), style);
410}
411
412/** the colors that {@linkcode toHtml} refers to, as css declarations that follow `color-scheme` */
413export const htmlColors: string =
414 "--ansi0:light-dark(#000,#666);--ansi1:#cd3131;" +
415 "--ansi2:light-dark(#00a000,#0dbc79);--ansi3:light-dark(#949800,#e5e510);" +
416 "--ansi4:light-dark(#0451a5,#2472c8);--ansi5:#bc3fbc;--ansi6:light-dark(#0598bc,#11a8cd);" +
417 "--ansi7:light-dark(#555,#e5e5e5);--ansi8:#666;--ansi9:light-dark(#cd3131,#f14c4c);" +
418 "--ansi10:light-dark(#14ce14,#23d18b);--ansi11:light-dark(#b5ba00,#f5f543);" +
419 "--ansi12:light-dark(#0451a5,#3b8eea);--ansi13:light-dark(#bc05bc,#d670d6);" +
420 "--ansi14:light-dark(#0598bc,#29b8db);--ansi15:light-dark(#a5a5a5,#e5e5e5);";
421
422interface HtmlStyle {
423 fg: string;
424 bg: string;
425 weight: string;
426 italic: boolean;
427 underline: boolean;
428}
429const plainStyle = (): HtmlStyle => ({
430 fg: "",
431 bg: "",
432 weight: "",
433 italic: false,
434 underline: false,
435});
436
437const sequences =
438 /\x1b(?:\[([\x30-\x3f]*)[\x20-\x2f]*([\x40-\x7e])|\][^\x07\x1b]*(?:\x07|\x1b\\)?|.)/g;
439
440function formatRun(text: string, { fg, bg, weight, italic, underline }: HtmlStyle) {
441 if (!text) return "";
442 const css =
443 (fg && `color:${fg};`) +
444 (bg && `background:${bg};`) +
445 weight +
446 (italic ? "font-style:italic;" : "") +
447 (underline ? "text-decoration:underline;" : "");
448 if (!css) return escapeHtmlContent(text);
449 return text
450 .split("\n")
451 .map((line) => line && `<span style="${css}">${escapeHtmlContent(line)}</span>`)
452 .join("\n");
453}
454
455function applySgr(style: HtmlStyle, codes: number[]) {
456 for (let i = 0; i < codes.length; i += 1) {
457 const code = UNWRAP(codes[i]);
458 if (code === 0) Object.assign(style, plainStyle());
459 else if (code === 1) style.weight = "font-weight:bold;";
460 else if (code === 2) style.weight = "opacity:.65;";
461 else if (code === 22) style.weight = "";
462 else if (code === 3 || code === 23) style.italic = code === 3;
463 else if (code === 4 || code === 24) style.underline = code === 4;
464 else if (code === 39) style.fg = "";
465 else if (code === 49) style.bg = "";
466 else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) {
467 style.fg = `var(--ansi${(code % 10) + (code >= 90 ? 8 : 0)})`;
468 } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) {
469 style.bg = `var(--ansi${(code % 10) + (code >= 100 ? 8 : 0)})`;
470 } else if (code === 38 || code === 48) {
471 const indexed = codes[i + 1] === 5;
472 const [r = 0, g = 0, b = 0] = codes.slice(i + 2, i + 5);
473 const color = indexed ? color256(r) : `rgb(${r} ${g} ${b})`;
474 i += indexed ? 2 : 4;
475 if (code === 38) style.fg = color;
476 else style.bg = color;
477 }
478 }
479}
480
481function color256(n: number) {
482 if (n < 16) return `var(--ansi${n})`;
483 if (n >= 232)
484 return `rgb(${Array(3)
485 .fill(8 + (n - 232) * 10)
486 .join(" ")})`;
487 n -= 16;
488 const level = (c: number) => c && 55 + c * 40;
489 return `rgb(${level(Math.floor(n / 36))} ${level(Math.floor(n / 6) % 6)} ${level(n % 6)})`;
490}
491
492function segmentWidth(segment: string) {
493 if (zeroWidthClusterRegex.test(segment)) return 0;
494 if (rgiEmojiRegex.test(segment)) return 2;
495
496 // deno-fmt-ignore
497 const list = [
498 0x3000, 0x231a, 0x231b, 0x2329, 0x232a, 0x23f0, 0x23f3, 0x25fd, 0x25fe, 0x2614, 0x2615, 0x267f,
499 0x2693, 0x26a1, 0x26aa, 0x26ab, 0x26bd, 0x26be, 0x26c4, 0x26c5, 0x26ce, 0x26d4, 0x26ea, 0x26f2,
500 0x26f3, 0x26f5, 0x26fa, 0x26fd, 0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2757, 0x27b0,
501 0x27bf, 0x2b1b, 0x2b1c, 0x2b50, 0x2b55, 0x1affd, 0x1affe, 0x1b132, 0x1b155, 0x1f004, 0x1f0cf,
502 0x1f18e, 0x1f250, 0x1f251, 0x1f3f4, 0x1f440, 0x1f57a, 0x1f595, 0x1f596, 0x1f5a4, 0x1f6cc,
503 0x1f6eb, 0x1f6ec, 0x1f7f0, 0x1fac8,
504 ];
505 const x = segment.replace(leadingNonPrintingRegex, "").codePointAt(0) ?? 0;
506 return (
507 1 +
508 +(
509 // full
510 (x >= 0xff01 && x <= 0xff60) ||
511 (x >= 0xffe0 && x <= 0xffe6) ||
512 // wide
513 (x >= 0x1100 && x <= 0x115f) ||
514 (x >= 0x23e9 && x <= 0x23ec) ||
515 (x >= 0x2630 && x <= 0x2637) ||
516 (x >= 0x2648 && x <= 0x2653) ||
517 (x >= 0x268a && x <= 0x268f) ||
518 (x >= 0x2753 && x <= 0x2755) ||
519 (x >= 0x2795 && x <= 0x2797) ||
520 (x >= 0x2e80 && x <= 0x2e99) ||
521 (x >= 0x2e9b && x <= 0x2ef3) ||
522 (x >= 0x2f00 && x <= 0x2fd5) ||
523 (x >= 0x2ff0 && x <= 0x2fff) ||
524 (x >= 0x3001 && x <= 0x303e) ||
525 (x >= 0x3041 && x <= 0x3096) ||
526 (x >= 0x3099 && x <= 0x30ff) ||
527 (x >= 0x3105 && x <= 0x312f) ||
528 (x >= 0x3131 && x <= 0x318e) ||
529 (x >= 0x3190 && x <= 0x31e5) ||
530 (x >= 0x31ef && x <= 0x321e) ||
531 (x >= 0x3220 && x <= 0x3247) ||
532 (x >= 0x3250 && x <= 0xa48c) ||
533 (x >= 0xa490 && x <= 0xa4c6) ||
534 (x >= 0xa960 && x <= 0xa97c) ||
535 (x >= 0xac00 && x <= 0xd7a3) ||
536 (x >= 0xf900 && x <= 0xfaff) ||
537 (x >= 0xfe10 && x <= 0xfe19) ||
538 (x >= 0xfe30 && x <= 0xfe52) ||
539 (x >= 0xfe54 && x <= 0xfe66) ||
540 (x >= 0xfe68 && x <= 0xfe6b) ||
541 (x >= 0x16fe0 && x <= 0x16fe4) ||
542 (x >= 0x16ff0 && x <= 0x16ff6) ||
543 (x >= 0x17000 && x <= 0x18cd5) ||
544 (x >= 0x18cff && x <= 0x18d1e) ||
545 (x >= 0x18d80 && x <= 0x18df2) ||
546 (x >= 0x1aff0 && x <= 0x1aff3) ||
547 (x >= 0x1aff5 && x <= 0x1affb) ||
548 (x >= 0x1b150 && x <= 0x1b152) ||
549 (x >= 0x1b164 && x <= 0x1b167) ||
550 (x >= 0x1b170 && x <= 0x1b2fb) ||
551 (x >= 0x1d300 && x <= 0x1d356) ||
552 (x >= 0x1d360 && x <= 0x1d376) ||
553 (x >= 0x1f191 && x <= 0x1f19a) ||
554 (x >= 0x1f200 && x <= 0x1f202) ||
555 (x >= 0x1f210 && x <= 0x1f23b) ||
556 (x >= 0x1f240 && x <= 0x1f248) ||
557 (x >= 0x1f260 && x <= 0x1f265) ||
558 (x >= 0x1f300 && x <= 0x1f320) ||
559 (x >= 0x1f32d && x <= 0x1f335) ||
560 (x >= 0x1f337 && x <= 0x1f37c) ||
561 (x >= 0x1f37e && x <= 0x1f393) ||
562 (x >= 0x1f3a0 && x <= 0x1f3ca) ||
563 (x >= 0x1f3cf && x <= 0x1f3d3) ||
564 (x >= 0x1f3e0 && x <= 0x1f3f0) ||
565 (x >= 0x1f3f8 && x <= 0x1f43e) ||
566 (x >= 0x1f442 && x <= 0x1f4fc) ||
567 (x >= 0x1f4ff && x <= 0x1f53d) ||
568 (x >= 0x1f54b && x <= 0x1f54e) ||
569 (x >= 0x1f550 && x <= 0x1f567) ||
570 (x >= 0x1f5fb && x <= 0x1f64f) ||
571 (x >= 0x1f680 && x <= 0x1f6c5) ||
572 (x >= 0x1f6d0 && x <= 0x1f6d2) ||
573 (x >= 0x1f6d5 && x <= 0x1f6d8) ||
574 (x >= 0x1f6dc && x <= 0x1f6df) ||
575 (x >= 0x1f6f4 && x <= 0x1f6fc) ||
576 (x >= 0x1f7e0 && x <= 0x1f7eb) ||
577 (x >= 0x1f90c && x <= 0x1f93a) ||
578 (x >= 0x1f93c && x <= 0x1f945) ||
579 (x >= 0x1f947 && x <= 0x1f9ff) ||
580 (x >= 0x1fa70 && x <= 0x1fa7c) ||
581 (x >= 0x1fa80 && x <= 0x1fa8a) ||
582 (x >= 0x1fa8e && x <= 0x1fac6) ||
583 (x >= 0x1facd && x <= 0x1fadc) ||
584 (x >= 0x1fadf && x <= 0x1faea) ||
585 (x >= 0x1faef && x <= 0x1faf8) ||
586 (x >= 0x20000 && x <= 0x2fffd) ||
587 (x >= 0x30000 && x <= 0x3fffd) ||
588 (x >= 0x1b000 && x <= 0x1b122) ||
589 list.includes(x)
590 )
591 );
592}
593
594import { UNWRAP } from "../assert.ts";
595import { escapeHtmlContent } from "../string.ts";
596import * as self from "./ansi.ts";
packages/clo-lib/ts.ts created+107
...@@ -0,0 +1,107 @@
1/**
2 * helpers for the typescript language
3 *
4 * @module ts
5 */
6
7/** return type of `setTimeout` regardless of the environment */
8export type Timer = ReturnType<typeof setTimeout>;
9/** return type of `setInterval` regardless of the environment */
10export type Interval = ReturnType<typeof setInterval>;
11/** opposite of the built-in `Readonly` type */
12export type Writeable<T> = { -readonly [P in keyof T]: T[P] };
13/** A type that can be `await`ed to return `T` */
14export type Awaitable<T> = T | PromiseLike<T>;
15/** opposite of the built-in `Readonly` type, but recursive */
16// TODO: tuples?
17export type DeepWriteable<T> = {
18 -readonly [P in keyof T]: T[P] extends ReadonlyArray<infer I> ? Array<I> : DeepWriteable<T[P]>;
19};
20/** opposite of `DeepWriteable`, for values that are shared and must not change */
21export type DeepReadonly<T> = T extends Date
22 ? T
23 : T extends Uint8Array
24 ? Readonly<T>
25 : T extends Map<infer K, infer V>
26 ? ReadonlyMap<DeepReadonly<K>, DeepReadonly<V>>
27 : T extends Set<infer V>
28 ? ReadonlySet<DeepReadonly<V>>
29 : T extends ReadonlyArray<infer I>
30 ? ReadonlyArray<DeepReadonly<I>>
31 : T extends object
32 ? { readonly [P in keyof T]: DeepReadonly<T[P]> }
33 : T;
34
35declare const brand: unique symbol;
36export type Brand<T> = { [brand]: T };
37
38/**
39 * redeclared here because it is only provided by `lib: ["dom"]` and not
40 * the JavaScript standard types.
41 */
42export interface VoidFunction {
43 (): void;
44}
45
46/** convenience type. Construct with `defer` */
47export interface Dispose extends Disposable, VoidFunction {}
48
49/**
50 * ```ts
51 * using _ = ts.defer(() => { ... });
52 * ```
53 * Additionally, this can be used to construct the convenience type
54 * `ts.Dispose`
55 */
56export function defer(fn: () => void): Dispose {
57 const f = fn as Dispose;
58 f[Symbol.dispose] = f;
59 return f;
60}
61
62export type Mixin<A, B> = A & B;
63
64/**
65 * mutates `base` to implement all methods of `other`, using `other` as state.
66 */
67export function mixin<A extends object, B extends object>(base: A, other: B): Mixin<A, B> {
68 const prototype = Object.getPrototypeOf(other);
69 for (const key of [
70 ...Object.getOwnPropertyNames(prototype),
71 ...Object.getOwnPropertySymbols(prototype),
72 ]) {
73 if (key === "constructor") continue;
74 if (key in base) continue;
75 const fn = prototype[key];
76 if (typeof fn !== "function") continue;
77 (base as Record<string | symbol, unknown>)[key] = fn.bind(other);
78 }
79 return base as A & B;
80}
81
82export type EmptyObject = Record<never, unknown>;
83
84/** TODO: this type has many subtle bugs */
85export type ToJson<T> = T extends JsonValue
86 ? T
87 : T extends { toJSON(): infer J }
88 ? ToJson<J>
89 : T extends Set<unknown> | Map<unknown, unknown> | Record<string, never>
90 ? EmptyObject
91 : T extends unknown[]
92 ? {
93 [K in keyof Omit<T, keyof unknown[]>]: T[K] extends undefined | void
94 ? null
95 : ToJson<T[K]>;
96 }
97 : T extends object
98 ? { [K in keyof Omit<T, symbol>]: ToJson<T[K]> }
99 : never;
100export type Json = JsonValue | undefined | void;
101export type JsonValue =
102 | string
103 | number
104 | boolean
105 | null
106 | JsonValue[]
107 | { [k: string]: Exclude<JsonValue, undefined> };
packages/clo-lib/tsconfig.json created+7
...@@ -0,0 +1,7 @@
1{
2 "extends": "../../tsconfig.base.json",
3 "compilerOptions": {
4 "types": ["node"]
5 },
6 "include": ["**/*.ts"]
7}
packages/contracts/src/index.ts+1
...@@ -66,3 +66,4 @@ export * from "./worktreeSetup.ts";...@@ -66,3 +66,4 @@ export * from "./worktreeSetup.ts";
66export * from "./secretRequest.ts";66export * from "./secretRequest.ts";
67export * from "./clientRpcPermissions.ts";67export * from "./clientRpcPermissions.ts";
68export * from "./desktopTerminal.ts";68export * from "./desktopTerminal.ts";
69export * from "./scriptProgress.ts";
packages/contracts/src/rpc.ts+14
...@@ -1,3 +1,8 @@...@@ -1,3 +1,8 @@
1import {
2 ScriptProgressSubscribeInput,
3 ScriptProgressEvent,
4 ScriptProgressStreamError,
5} from "./scriptProgress.ts";
1import {6import {
2 OrchestrationV2SearchThreadError,7 OrchestrationV2SearchThreadError,
3 OrchestrationV2SearchThreadInput,8 OrchestrationV2SearchThreadInput,
...@@ -578,6 +583,7 @@ export const WS_METHODS = {...@@ -578,6 +583,7 @@ export const WS_METHODS = {
578 worktreeSetupCancel: "worktreeSetup.cancel",583 worktreeSetupCancel: "worktreeSetup.cancel",
579 subscribeTerminalEvents: "subscribeTerminalEvents",584 subscribeTerminalEvents: "subscribeTerminalEvents",
580 subscribeTerminalMetadata: "subscribeTerminalMetadata",585 subscribeTerminalMetadata: "subscribeTerminalMetadata",
586 subscribeScriptProgress: "subscribeScriptProgress",
581 subscribePreviewEvents: "subscribePreviewEvents",587 subscribePreviewEvents: "subscribePreviewEvents",
582 subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers",588 subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers",
583 subscribeDeviceState: "subscribeDeviceState",589 subscribeDeviceState: "subscribeDeviceState",
...@@ -1745,6 +1751,13 @@ const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents...@@ -1745,6 +1751,13 @@ const WsSubscribeTerminalEventsRpc = Rpc.make(WS_METHODS.subscribeTerminalEvents
1745 stream: true,1751 stream: true,
1746});1752});
17471753
1754const WsSubscribeScriptProgressRpc = Rpc.make(WS_METHODS.subscribeScriptProgress, {
1755 payload: ScriptProgressSubscribeInput,
1756 success: ScriptProgressEvent,
1757 error: Schema.Union([ScriptProgressStreamError, EnvironmentAuthorizationError]),
1758 stream: true,
1759});
1760
1748const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, {1761const WsSubscribeTerminalMetadataRpc = Rpc.make(WS_METHODS.subscribeTerminalMetadata, {
1749 payload: Schema.Struct({}),1762 payload: Schema.Struct({}),
1750 success: TerminalMetadataStreamEvent,1763 success: TerminalMetadataStreamEvent,
...@@ -2041,6 +2054,7 @@ export const WsRpcGroup = RpcGroup.make(...@@ -2041,6 +2054,7 @@ export const WsRpcGroup = RpcGroup.make(
2041 WsReviewGetDiffPreviewRpc,2054 WsReviewGetDiffPreviewRpc,
2042 WsReviewGetDiffFileContentsRpc,2055 WsReviewGetDiffFileContentsRpc,
2043 WsSubscribeTerminalMetadataRpc,2056 WsSubscribeTerminalMetadataRpc,
2057 WsSubscribeScriptProgressRpc,
2044 WsPreviewOpenRpc,2058 WsPreviewOpenRpc,
2045 WsPreviewNavigateRpc,2059 WsPreviewNavigateRpc,
2046 WsPreviewResizeRpc,2060 WsPreviewResizeRpc,
packages/contracts/src/scriptProgress.ts created+44
...@@ -0,0 +1,44 @@
1import * as Schema from "effect/Schema";
2import { ThreadId } from "./baseSchemas.ts";
3
4export const ScriptProgressSubscribeInput = Schema.Struct({ threadId: ThreadId });
5
6export const ScriptProgressSummary = Schema.Array(
7 Schema.Struct({
8 text: Schema.String,
9 value: Schema.Number,
10 total: Schema.Number,
11 detail: Schema.String,
12 }),
13);
14
15export const ScriptProgressStream = Schema.Struct({
16 id: Schema.String,
17 pid: Schema.Int,
18 label: Schema.String,
19 status: Schema.Literals(["connected", "disconnected"]),
20 summary: ScriptProgressSummary,
21});
22export type ScriptProgressStream = typeof ScriptProgressStream.Type;
23
24export const ScriptProgressEvent = Schema.Union([
25 Schema.Struct({ type: Schema.Literal("snapshot"), streams: Schema.Array(ScriptProgressStream) }),
26 Schema.Struct({ type: Schema.Literal("upsert"), stream: ScriptProgressStream }),
27 Schema.Struct({
28 type: Schema.Literal("progress"),
29 id: Schema.String,
30 event: Schema.Array(Schema.Json),
31 summary: ScriptProgressSummary,
32 }),
33 Schema.Struct({ type: Schema.Literal("remove"), id: Schema.String }),
34]);
35export type ScriptProgressEvent = typeof ScriptProgressEvent.Type;
36
37export class ScriptProgressStreamError extends Schema.TaggedError<ScriptProgressStreamError>()(
38 "ScriptProgressStreamError",
39 {},
40) {
41 override get message(): string {
42 return "Progress updates fell behind. Reconnect to refresh them.";
43 }
44}
packages/provider-acp-registry/src/server/adapter.ts+6-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import {2import {
2 normalizeDevinSessionUpdate,3 normalizeDevinSessionUpdate,
3 normalizeDevinToolCall,4 normalizeDevinToolCall,
...@@ -166,7 +167,10 @@ function makeAcpRegistryRuntime(...@@ -166,7 +167,10 @@ function makeAcpRegistryRuntime(
166 ? resolved.spawn167 ? resolved.spawn
167 : {168 : {
168 ...resolved.spawn,169 ...resolved.spawn,
169 env: { ...resolved.spawn.env, ...processEnvironment },170 env: ScriptProgress.withScriptProgressEnvironment(
171 resolved.spawn.env ?? {},
172 processEnvironment,
173 ),
170 },174 },
171 ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}),175 ...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}),
172 customModels: readCustomModelEntries(options.settings.customModels).map(176 customModels: readCustomModelEntries(options.settings.customModels).map(
...@@ -281,6 +285,7 @@ export type AcpRegistryAdapterV2DriverEnv =...@@ -281,6 +285,7 @@ export type AcpRegistryAdapterV2DriverEnv =
281 | AcpRegistrySupport.AcpRegistryCatalog285 | AcpRegistrySupport.AcpRegistryCatalog
282 | IdAllocator.IdAllocatorV2286 | IdAllocator.IdAllocatorV2
283 | Path.Path287 | Path.Path
288 | ScriptProgress.ScriptProgress
284 | McpProviderSessions.McpProviderSessions289 | McpProviderSessions.McpProviderSessions
285 | ProviderEventLoggers.ProviderEventLoggers290 | ProviderEventLoggers.ProviderEventLoggers
286 | ProviderHost.ProviderHost;291 | ProviderHost.ProviderHost;
packages/provider-acp/src/server/adapter.ts+15-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1// @effect-diagnostics nodeBuiltinImport:off2// @effect-diagnostics nodeBuiltinImport:off
23
3import {4import {
...@@ -1493,11 +1494,23 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (...@@ -1493,11 +1494,23 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
1493 const idAllocator = yield* IdAllocator.IdAllocatorV2;1494 const idAllocator = yield* IdAllocator.IdAllocatorV2;
1494 const host = yield* ProviderHost.ProviderHost;1495 const host = yield* ProviderHost.ProviderHost;
1495 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;1496 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
1497 const scriptProgress = yield* ScriptProgress.ScriptProgress;
1496 const { flavor, selfInvocation: self } = options;1498 const { flavor, selfInvocation: self } = options;
1497 const readMcpContext = (threadId: ThreadId | null) =>1499 const readMcpContext = (threadId: ThreadId | null) =>
1498 threadId === null1500 threadId === null
1499 ? Effect.succeed(acpMcpContext(undefined, self))1501 ? Effect.succeed(acpMcpContext(undefined, self))
1500 : mcpSessions.read(threadId).pipe(Effect.map((session) => acpMcpContext(session, self)));1502 : Effect.gen(function* () {
1503 const context = acpMcpContext(yield* mcpSessions.read(threadId), self);
1504 const environment = yield* scriptProgress.environmentForThread(threadId);
1505 if (Object.keys(environment).length === 0) return context;
1506 return {
1507 ...context,
1508 processEnvironment: ScriptProgress.withScriptProgressEnvironment(
1509 context.processEnvironment ?? {},
1510 environment,
1511 ),
1512 };
1513 });
1501 const driver = flavor.driver;1514 const driver = flavor.driver;
1502 const continuationRequests = options.continuationRequests;1515 const continuationRequests = options.continuationRequests;
1503 const postSettleContinuationEnabled =1516 const postSettleContinuationEnabled =
...@@ -8085,4 +8098,5 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (...@@ -8085,4 +8098,5 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* (
8085export type AcpAdapterV2Env =8098export type AcpAdapterV2Env =
8086 | FileSystem.FileSystem8099 | FileSystem.FileSystem
8087 | IdAllocator.IdAllocatorV28100 | IdAllocator.IdAllocatorV2
8101 | ScriptProgress.ScriptProgress
8088 | McpProviderSessions.McpProviderSessions;8102 | McpProviderSessions.McpProviderSessions;
packages/provider-acp/src/server/clientTerminals.test.ts+47
...@@ -71,6 +71,53 @@ describe("AcpClientTerminals", () => {...@@ -71,6 +71,53 @@ describe("AcpClientTerminals", () => {
71 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),71 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
72 );72 );
7373
74 it.effect("loads per-session progress without losing provider or command Node options", () =>
75 Effect.gen(function* () {
76 const environment = { NODE_OPTIONS: "--no-warnings", PROVIDER_SETTING: "keep" };
77 const preload = "--import=data:text/javascript,globalThis.progressPreloaded=true";
78 const terminals = yield* makeAcpClientTerminals({
79 defaultCwd: process.cwd(),
80 environment,
81 environmentForSession: (sessionId) => ({
82 NODE_OPTIONS: preload,
83 T3_SCRIPT_PROGRESS_TOKEN: sessionId,
84 }),
85 });
86 yield* Effect.addFinalizer(() => terminals.disposeAll);
87 const results = yield* Effect.all(
88 ["session-one", "session-two"].map((sessionId) =>
89 Effect.gen(function* () {
90 const created = yield* terminals.create({
91 sessionId,
92 command: process.execPath,
93 args: [
94 "-e",
95 "console.log(JSON.stringify({token:process.env.T3_SCRIPT_PROGRESS_TOKEN, options:process.env.NODE_OPTIONS, provider:process.env.PROVIDER_SETTING, loaded:globalThis.progressPreloaded}))",
96 ],
97 env: [
98 { name: "NODE_OPTIONS", value: "--trace-warnings" },
99 { name: "T3_SCRIPT_PROGRESS_TOKEN", value: "stale-inherited-token" },
100 ],
101 });
102 yield* terminals.waitForExit({ sessionId, terminalId: created.terminalId });
103 const output = yield* terminals.output({ sessionId, terminalId: created.terminalId });
104 return JSON.parse(output.output);
105 }),
106 ),
107 { concurrency: "unbounded" },
108 );
109 expect(results).toEqual(
110 ["session-one", "session-two"].map((token) => ({
111 token,
112 options: `${preload} --trace-warnings --no-warnings`,
113 provider: "keep",
114 loaded: true,
115 })),
116 );
117 expect(environment).toEqual({ NODE_OPTIONS: "--no-warnings", PROVIDER_SETTING: "keep" });
118 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped),
119 );
120
74 it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () =>121 it.effect("runs Devin shell source with arguments, pipes, and session fallback variables", () =>
75 Effect.gen(function* () {122 Effect.gen(function* () {
76 const terminals = yield* makeAcpClientTerminals({123 const terminals = yield* makeAcpClientTerminals({
packages/provider-acp/src/server/clientTerminals.ts+8-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as Deferred from "effect/Deferred";2import * as Deferred from "effect/Deferred";
2import * as Duration from "effect/Duration";3import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
...@@ -236,7 +237,13 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi...@@ -236,7 +237,13 @@ export const makeAcpClientTerminals = Effect.fn("makeAcpClientTerminals")(functi
236 sessionEnvironment === undefined &&237 sessionEnvironment === undefined &&
237 (request.env?.length ?? 0) === 0238 (request.env?.length ?? 0) === 0
238 ? undefined239 ? undefined
239 : { ...options.environment, ...sessionEnvironment, ...requestEnvironment };240 : ScriptProgress.withScriptProgressEnvironment(
241 ScriptProgress.withScriptProgressEnvironment(
242 options.environment ?? {},
243 requestEnvironment,
244 ),
245 sessionEnvironment ?? {},
246 );
240 // Each terminal owns a scope so the spawned process is reliably reaped:247 // Each terminal owns a scope so the spawned process is reliably reaped:
241 // closing the scope kills a still-running command and frees the handle.248 // closing the scope kills a still-running command and frees the handle.
242 const terminalScope = yield* Scope.make();249 const terminalScope = yield* Scope.make();
packages/provider-core/package.json+4
...@@ -39,6 +39,10 @@...@@ -39,6 +39,10 @@
39 "types": "./src/server/handoffBudget.ts",39 "types": "./src/server/handoffBudget.ts",
40 "import": "./src/server/handoffBudget.ts"40 "import": "./src/server/handoffBudget.ts"
41 },41 },
42 "./server/ScriptProgress": {
43 "types": "./src/server/ScriptProgress.ts",
44 "import": "./src/server/ScriptProgress.ts"
45 },
42 "./server/IdAllocator": {46 "./server/IdAllocator": {
43 "types": "./src/server/IdAllocator.ts",47 "types": "./src/server/IdAllocator.ts",
44 "import": "./src/server/IdAllocator.ts"48 "import": "./src/server/IdAllocator.ts"
packages/provider-core/src/server/ScriptProgress.ts created+50
...@@ -0,0 +1,50 @@
1import type { ScriptProgressEvent, ScriptProgressStreamError, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6
7export class ScriptProgress extends Context.Service<
8 ScriptProgress,
9 {
10 readonly environmentForThread: (threadId: ThreadId) => Effect.Effect<Record<string, string>>;
11 readonly subscribe: (
12 threadId: ThreadId,
13 ) => Stream.Stream<ScriptProgressEvent, ScriptProgressStreamError>;
14 }
15>()("@t3tools/provider-core/server/ScriptProgress") {}
16
17export function withScriptProgressEnvironment(
18 environment: Record<string, string>,
19 progress: Readonly<Record<string, string>>,
20): Record<string, string>;
21export function withScriptProgressEnvironment(
22 environment: NodeJS.ProcessEnv,
23 progress: NodeJS.ProcessEnv,
24): NodeJS.ProcessEnv;
25export function withScriptProgressEnvironment(
26 environment: NodeJS.ProcessEnv,
27 progress: NodeJS.ProcessEnv,
28): NodeJS.ProcessEnv {
29 return {
30 ...environment,
31 ...progress,
32 ...(progress.NODE_OPTIONS
33 ? {
34 NODE_OPTIONS:
35 environment.NODE_OPTIONS === progress.NODE_OPTIONS ||
36 environment.NODE_OPTIONS?.startsWith(`${progress.NODE_OPTIONS} `)
37 ? environment.NODE_OPTIONS
38 : [progress.NODE_OPTIONS, environment.NODE_OPTIONS].filter(Boolean).join(" "),
39 }
40 : {}),
41 };
42}
43
44export const layerUnavailable = Layer.succeed(
45 ScriptProgress,
46 ScriptProgress.of({
47 environmentForThread: () => Effect.succeed({}),
48 subscribe: () => Stream.succeed({ type: "snapshot", streams: [] }),
49 }),
50);
packages/provider-grok/src/server/adapter.ts+7-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { makeProviderFailure } from "@t3tools/provider-core/server/failure";2import { makeProviderFailure } from "@t3tools/provider-core/server/failure";
2import {3import {
3 XAiPromptFailureText,4 XAiPromptFailureText,
...@@ -280,7 +281,10 @@ export function makeGrokAcpAdapterFlavor(...@@ -280,7 +281,10 @@ export function makeGrokAcpAdapterFlavor(
280 ...input,281 ...input,
281 interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,282 interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
282 grokSettings: options.settings,283 grokSettings: options.settings,
283 environment: options.environment,284 environment: ScriptProgress.withScriptProgressEnvironment(
285 options.environment,
286 input.processEnvironment ?? {},
287 ),
284 runtimeMode: grokLaunchRuntimeMode(runtimePolicy),288 runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
285 })),289 })),
286 // In its Auto mode Grok decides routine actions itself and only asks about290 // In its Auto mode Grok decides routine actions itself and only asks about
...@@ -351,6 +355,7 @@ export type GrokAdapterV2DriverEnv =...@@ -351,6 +355,7 @@ export type GrokAdapterV2DriverEnv =
351 | FileSystem.FileSystem355 | FileSystem.FileSystem
352 | IdAllocator.IdAllocatorV2356 | IdAllocator.IdAllocatorV2
353 | Path.Path357 | Path.Path
358 | ScriptProgress.ScriptProgress
354 | McpProviderSessions.McpProviderSessions359 | McpProviderSessions.McpProviderSessions
355 | ProviderEventLoggers.ProviderEventLoggers360 | ProviderEventLoggers.ProviderEventLoggers
356 | ProviderHost.ProviderHost;361 | ProviderHost.ProviderHost;
...@@ -405,6 +410,7 @@ const layer: Layer.Layer<...@@ -405,6 +410,7 @@ const layer: Layer.Layer<
405 | Crypto.Crypto410 | Crypto.Crypto
406 | FileSystem.FileSystem411 | FileSystem.FileSystem
407 | IdAllocator.IdAllocatorV2412 | IdAllocator.IdAllocatorV2
413 | ScriptProgress.ScriptProgress
408 | McpProviderSessions.McpProviderSessions414 | McpProviderSessions.McpProviderSessions
409 | ProviderEventLoggers.ProviderEventLoggers415 | ProviderEventLoggers.ProviderEventLoggers
410 | ProviderHost.ProviderHost416 | ProviderHost.ProviderHost
packages/provider-grok/src/server/driver.test.ts+2-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";3import { expect, it } from "@effect/vitest";
3import { ProviderInstanceId } from "@t3tools/contracts";4import { ProviderInstanceId } from "@t3tools/contracts";
...@@ -20,7 +21,7 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS...@@ -20,7 +21,7 @@ import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderS
20const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(21const layerTest = TestProviderHost.layer({ runBackgroundWork: false }).pipe(
21 Layer.provideMerge(NodeServices.layer),22 Layer.provideMerge(NodeServices.layer),
22 Layer.provideMerge(IdAllocator.layer),23 Layer.provideMerge(IdAllocator.layer),
23 Layer.provideMerge(McpProviderSessions.layer),24 Layer.provideMerge(Layer.merge(McpProviderSessions.layer, ScriptProgress.layerUnavailable)),
24 Layer.provideMerge(ProviderLatestVersions.layer),25 Layer.provideMerge(ProviderLatestVersions.layer),
25 Layer.provideMerge(26 Layer.provideMerge(
26 Layer.succeed(27 Layer.succeed(
packages/provider-muse/src/server/adapter.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import { MspError } from "@muse-code/sdk";4import { MspError } from "@muse-code/sdk";
...@@ -44,6 +45,7 @@ const testLayer = Layer.mergeAll(...@@ -44,6 +45,7 @@ const testLayer = Layer.mergeAll(
44 NodeServices.layer,45 NodeServices.layer,
45 IdAllocator.layer,46 IdAllocator.layer,
46 McpProviderSessions.layer,47 McpProviderSessions.layer,
48 ScriptProgress.layerUnavailable,
47 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),49 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
48);50);
49const MUSE_PROVIDER = ProviderDriverKind.make("muse");51const MUSE_PROVIDER = ProviderDriverKind.make("muse");
packages/provider-muse/src/server/adapter.ts+5-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { MspError, type SendUserTurnOptions } from "@muse-code/sdk";2import { MspError, type SendUserTurnOptions } from "@muse-code/sdk";
2import {3import {
3 MUSE_DEFAULT_MODEL,4 MUSE_DEFAULT_MODEL,
...@@ -276,6 +277,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (...@@ -276,6 +277,7 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
276 const providerHost = yield* ProviderHost.ProviderHost;277 const providerHost = yield* ProviderHost.ProviderHost;
277 const fileSystem = yield* FileSystem.FileSystem;278 const fileSystem = yield* FileSystem.FileSystem;
278 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;279 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
280 const scriptProgress = yield* ScriptProgress.ScriptProgress;
279 const agentScope = yield* AgentScope;281 const agentScope = yield* AgentScope;
280282
281 const protocolError = (detail: string, payload?: unknown) =>283 const protocolError = (detail: string, payload?: unknown) =>
...@@ -1650,9 +1652,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (...@@ -1650,9 +1652,9 @@ export const makeMuseAdapterV2 = Effect.fn("makeMuseAdapterV2")(function* (
1650 const launchHost = Effect.fnUntraced(function* () {1652 const launchHost = Effect.fnUntraced(function* () {
1651 const epoch = ++hostEpoch;1653 const epoch = ++hostEpoch;
1652 const mcpSession = yield* mcpSessions.read(input.threadId);1654 const mcpSession = yield* mcpSessions.read(input.threadId);
1653 const environment = McpProviderSession.withAgentDeviceEnvironment(1655 const environment = ScriptProgress.withScriptProgressEnvironment(
1654 options.environment,1656 McpProviderSession.withAgentDeviceEnvironment(options.environment, mcpSession),
1655 mcpSession,1657 yield* scriptProgress.environmentForThread(input.threadId),
1656 );1658 );
1657 const launch = yield* agentScope.wrap({1659 const launch = yield* agentScope.wrap({
1658 command: options.settings.binaryPath || "muse",1660 command: options.settings.binaryPath || "muse",
packages/provider-muse/src/server/driver.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import type * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { ProviderDriverKind } from "@t3tools/contracts";2import { ProviderDriverKind } from "@t3tools/contracts";
2import { MuseSettings } from "../settings.ts";3import { MuseSettings } from "../settings.ts";
3import * as HostProcess from "@t3tools/shared/HostProcess";4import * as HostProcess from "@t3tools/shared/HostProcess";
...@@ -44,6 +45,7 @@ const decodeMuseSettings = Schema.decodeSync(MuseSettings);...@@ -44,6 +45,7 @@ const decodeMuseSettings = Schema.decodeSync(MuseSettings);
4445
45export type MuseDriverEnv =46export type MuseDriverEnv =
46 | IdAllocator.IdAllocatorV247 | IdAllocator.IdAllocatorV2
48 | ScriptProgress.ScriptProgress
47 | McpProviderSessions.McpProviderSessions49 | McpProviderSessions.McpProviderSessions
48 | ProviderHost.ProviderHost50 | ProviderHost.ProviderHost
49 | ChildProcessSpawner.ChildProcessSpawner51 | ChildProcessSpawner.ChildProcessSpawner
packages/provider-pi/src/server/adapter.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import { assert, describe, it } from "@effect/vitest";2import { assert, describe, it } from "@effect/vitest";
2import * as NodeServices from "@effect/platform-node/NodeServices";3import * as NodeServices from "@effect/platform-node/NodeServices";
3import {4import {
...@@ -54,6 +55,7 @@ const layerTest = Layer.mergeAll(...@@ -54,6 +55,7 @@ const layerTest = Layer.mergeAll(
54 NodeServices.layer,55 NodeServices.layer,
55 IdAllocator.layer,56 IdAllocator.layer,
56 McpProviderSessions.layer,57 McpProviderSessions.layer,
58 ScriptProgress.layerUnavailable,
57 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),59 TestProviderHost.layer().pipe(Layer.provide(NodeServices.layer)),
58);60);
5961
packages/provider-pi/src/server/adapter.ts+7-1
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1/**2/**
2 * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent3 * PiAdapterV2 — orchestrator-v2 adapter for the Pi coding agent
3 * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`.4 * (https://pi.dev), driving `pi --mode rpc` over stdio JSONL via `PiRpc.ts`.
...@@ -498,6 +499,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* (...@@ -498,6 +499,7 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* (
498 const idAllocator = yield* IdAllocator.IdAllocatorV2;499 const idAllocator = yield* IdAllocator.IdAllocatorV2;
499 const host = yield* ProviderHost.ProviderHost;500 const host = yield* ProviderHost.ProviderHost;
500 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;501 const mcpSessions = yield* McpProviderSessions.McpProviderSessions;
502 const scriptProgress = yield* ScriptProgress.ScriptProgress;
501 const { continuationRequests } = options;503 const { continuationRequests } = options;
502504
503 const protocolError = (detail: string, payload?: unknown) =>505 const protocolError = (detail: string, payload?: unknown) =>
...@@ -542,7 +544,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* (...@@ -542,7 +544,10 @@ export const makePiAdapterV2 = Effect.fn("makePiAdapterV2")(function* (
542 }544 }
543 const launch = buildPiRpcLaunch({545 const launch = buildPiRpcLaunch({
544 launchArgs: resolvedLaunchArgs.args,546 launchArgs: resolvedLaunchArgs.args,
545 environment: options.environment,547 environment: ScriptProgress.withScriptProgressEnvironment(
548 options.environment,
549 yield* scriptProgress.environmentForThread(input.threadId),
550 ),
546 mcpSession,551 mcpSession,
547 extensionPath,552 extensionPath,
548 runtimeMode: input.runtimePolicy.runtimeMode,553 runtimeMode: input.runtimePolicy.runtimeMode,
...@@ -3428,6 +3433,7 @@ export type PiAdapterV2DriverEnv =...@@ -3428,6 +3433,7 @@ export type PiAdapterV2DriverEnv =
3428 | ChildProcessSpawner.ChildProcessSpawner3433 | ChildProcessSpawner.ChildProcessSpawner
3429 | FileSystem.FileSystem3434 | FileSystem.FileSystem
3430 | IdAllocator.IdAllocatorV23435 | IdAllocator.IdAllocatorV2
3436 | ScriptProgress.ScriptProgress
3431 | McpProviderSessions.McpProviderSessions3437 | McpProviderSessions.McpProviderSessions
3432 | ProviderHost.ProviderHost;3438 | ProviderHost.ProviderHost;
34333439
packages/provider-pi/src/server/driver.test.ts+2
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import * as ScriptProgress from "@t3tools/provider-core/server/ScriptProgress";
1import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
3import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts";4import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts";
...@@ -29,6 +30,7 @@ const layerTest = Layer.mergeAll(...@@ -29,6 +30,7 @@ const layerTest = Layer.mergeAll(
29 }),30 }),
30 IdAllocator.layer,31 IdAllocator.layer,
31 McpProviderSessions.layer,32 McpProviderSessions.layer,
33 ScriptProgress.layerUnavailable,
32 ProviderLatestVersions.layer,34 ProviderLatestVersions.layer,
33 Layer.succeed(35 Layer.succeed(
34 HttpClient.HttpClient,36 HttpClient.HttpClient,
packages/shared/package.json+4
...@@ -494,6 +494,10 @@...@@ -494,6 +494,10 @@
494 "./browserChromeIdentity": {494 "./browserChromeIdentity": {
495 "types": "./src/browserChromeIdentity.ts",495 "types": "./src/browserChromeIdentity.ts",
496 "import": "./src/browserChromeIdentity.ts"496 "import": "./src/browserChromeIdentity.ts"
497 },
498 "./progressPreload": {
499 "types": "./src/progressPreload.ts",
500 "import": "./src/progressPreload.ts"
497 }501 }
498 },502 },
499 "scripts": {503 "scripts": {
packages/shared/src/progressPreload.test.ts created+559
...@@ -0,0 +1,559 @@
1// @effect-diagnostics nodeBuiltinImport:off - Exercise the actual standalone Node preload and TCP transport.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeEvents from "node:events";
4import * as NodeFSP from "node:fs/promises";
5import * as NodeNet from "node:net";
6import * as NodeOS from "node:os";
7import * as NodePath from "node:path";
8import * as NodeURL from "node:url";
9import { afterEach, describe, expect, it } from "vite-plus/test";
10
11import { decodeEventStream, Root, type StreamEvent } from "../../clo-lib/progress.ts";
12import { progressPreloadSource } from "./progressPreload.ts";
13
14interface Packet {
15 type: "hello" | "progress";
16 token?: string;
17 pid?: number;
18 producerId?: string;
19 streamId?: string;
20 event?: StreamEvent;
21}
22
23const cleanups: Array<() => Promise<unknown>> = [];
24afterEach(async () => {
25 for (const cleanup of cleanups.splice(0).toReversed()) await cleanup();
26});
27
28async function fixture() {
29 const directory = await NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "t3 progress preload "));
30 cleanups.push(() => NodeFSP.rm(directory, { recursive: true, force: true }));
31 const preload = NodePath.join(directory, "preload.cjs");
32 await NodeFSP.writeFile(preload, progressPreloadSource);
33 const packets: Packet[] = [];
34 const sockets = new Set<NodeNet.Socket>();
35 const changes = new Set<() => void>();
36 const server = NodeNet.createServer((socket) => {
37 sockets.add(socket);
38 socket.on("close", () => sockets.delete(socket));
39 socket.on("error", () => {});
40 let pending = "";
41 socket.on("data", (chunk) => {
42 pending += chunk.toString();
43 let index: number;
44 while ((index = pending.indexOf("\n")) !== -1) {
45 packets.push(JSON.parse(pending.slice(0, index)) as Packet);
46 pending = pending.slice(index + 1);
47 }
48 for (const changed of changes) changed();
49 });
50 });
51 server.listen(0, "127.0.0.1");
52 await NodeEvents.EventEmitter.once(server, "listening");
53 const address = server.address();
54 if (!address || typeof address === "string") throw new Error("Missing listener port");
55 cleanups.push(async () => {
56 for (const socket of sockets) socket.destroy();
57 await new Promise<void>((resolve) => server.close(() => resolve()));
58 });
59 const environment = {
60 ...process.env,
61 NODE_OPTIONS: `--require=${JSON.stringify(preload)}`,
62 T3_SCRIPT_PROGRESS_ENDPOINT: `127.0.0.1:${address.port}`,
63 T3_SCRIPT_PROGRESS_TOKEN: "test-progress-token",
64 };
65 const waitFor = (predicate: (rows: readonly Packet[]) => boolean) => {
66 if (predicate(packets)) return Promise.resolve();
67 return new Promise<void>((resolve) => {
68 const changed = () => {
69 if (!predicate(packets)) return;
70 changes.delete(changed);
71 resolve();
72 };
73 changes.add(changed);
74 });
75 };
76 let scriptNumber = 0;
77 function run(
78 source: string,
79 options: { endpoint?: boolean; shebang?: boolean; preload?: boolean } = {},
80 ) {
81 const script = NodePath.join(directory, `script-${++scriptNumber}.mjs`);
82 const childReady = Promise.withResolvers<void>();
83 const children: NodeChildProcess.ChildProcess[] = [];
84 const completion = (async () => {
85 await NodeFSP.writeFile(
86 script,
87 `${options.shebang ? `#!${process.execPath}\n` : ""}${source}`,
88 );
89 if (options.shebang) await NodeFSP.chmod(script, 0o700);
90 const child = NodeChildProcess.spawn(
91 options.shebang ? script : process.execPath,
92 options.shebang ? [] : [script],
93 {
94 cwd: NodeOS.tmpdir(),
95 env: {
96 ...environment,
97 ...(options.endpoint === false ? { T3_SCRIPT_PROGRESS_ENDPOINT: "" } : {}),
98 ...(options.preload === false ? { NODE_OPTIONS: "" } : {}),
99 },
100 stdio: ["ignore", "pipe", "pipe", "ipc"],
101 },
102 );
103 children.push(child);
104 child.on("message", () => childReady.resolve());
105 let stdout = "";
106 let stderr = "";
107 child.stdout?.on("data", (chunk) => {
108 stdout += chunk;
109 });
110 child.stderr?.on("data", (chunk) => {
111 stderr += chunk;
112 });
113 const [code] = await NodeEvents.EventEmitter.once(child, "close");
114 return { code, stdout, stderr };
115 })();
116 cleanups.push(async () => {
117 for (const child of children)
118 if (child.exitCode === null && child.signalCode === null) child.kill();
119 await completion;
120 });
121 return {
122 completion,
123 ready: childReady.promise,
124 send: () => {
125 const child = children[0];
126 if (!child) throw new Error("Child has not started");
127 child.send("continue");
128 },
129 };
130 }
131 return { directory, environment, packets, sockets, waitFor, run };
132}
133
134const localCopies: string[] = JSON.parse(process.env.T3_PROGRESS_TEST_MODULES ?? "[]");
135const copies = [
136 NodeURL.fileURLToPath(new URL("../../clo-lib/progress.ts", import.meta.url)),
137 ...localCopies,
138];
139const hasNode = (packets: readonly Packet[], label: string) =>
140 packets.some((packet) =>
141 packet.event?.some(
142 (part) =>
143 typeof part === "object" && part !== null && !Array.isArray(part) && part.t === label,
144 ),
145 );
146
147async function decodeSnapshot(events: StreamEvent[]) {
148 const root = new Root();
149 let controller: ReadableStreamDefaultController<StreamEvent>;
150 const input = new ReadableStream<StreamEvent>({
151 start(value) {
152 controller = value;
153 },
154 });
155 const decoding = decodeEventStream<void, { checkpoint: [] }>(input, root);
156 const ready = new Promise<void>((resolve) => decoding.on("checkpoint", resolve));
157 for (const event of events) controller!.enqueue(event);
158 controller!.enqueue([0, ["checkpoint"]]);
159 await ready;
160 const result = root.active.map((node) => ({
161 text: node.text,
162 value: node.value,
163 children: node.children.map((child) => ({ text: child.text, value: child.value })),
164 }));
165 decoding.cancel();
166 return result;
167}
168
169describe.each(copies)("progress preload using %s", (copy) => {
170 it("captures global and custom roots, nested updates, and preserves normal output", async () => {
171 const f = await fixture();
172 const child = f.run(`
173 const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)});
174 p.start("global task");
175 const root = new p.Root();
176 const parent = root.start("custom task", { total: 10, value: 4 });
177 parent.start("nested task", { total: 3, value: 1 });
178 console.log("script stdout");
179 console.error("script stderr");
180 process.send("ready");
181 await new Promise(resolve => process.once("message", resolve));
182 parent.value = 0;
183 parent.text = "";
184 process.send("updated");
185 await new Promise(resolve => process.once("message", resolve));
186 process.disconnect();
187 `);
188 await child.ready;
189 await f.waitFor((rows) => hasNode(rows, "global task") && hasNode(rows, "nested task"));
190 expect(f.packets.find((packet) => packet.type === "hello")).toMatchObject({
191 token: "test-progress-token",
192 producerId: expect.any(String),
193 });
194 child.send();
195 await f.waitFor((rows) =>
196 rows.some((row) =>
197 row.event?.some(
198 (part) =>
199 typeof part === "object" &&
200 part !== null &&
201 !Array.isArray(part) &&
202 part.t === "" &&
203 part.v === 0,
204 ),
205 ),
206 );
207 const stream = f.packets.find((packet) => hasNode([packet], "custom task"))?.streamId;
208 expect(stream).toBeDefined();
209 const events = f.packets.flatMap((packet) =>
210 packet.streamId === stream && packet.event ? [packet.event] : [],
211 );
212 expect(await decodeSnapshot(events)).toEqual([
213 { text: "", value: 0, children: [{ text: "nested task", value: 1 }] },
214 ]);
215 child.send();
216 const baseline = f.run(
217 `const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); p.start("global task"); console.log("script stdout"); console.error("script stderr"); process.disconnect();`,
218 { preload: false },
219 );
220 expect(await child.completion).toEqual(await baseline.completion);
221 });
222
223 it("fails open without a configured endpoint", async () => {
224 const f = await fixture();
225 const child = f.run(
226 `const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); p.start("unobserved"); console.log("unchanged"); process.disconnect();`,
227 { endpoint: false },
228 );
229 const baseline = f.run(
230 `const p = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)}); p.start("unobserved"); console.log("unchanged"); process.disconnect();`,
231 { preload: false },
232 );
233 expect(await child.completion).toEqual(await baseline.completion);
234 expect(f.packets).toHaveLength(0);
235 });
236});
237
238it("captures inherited preload in nested absolute Node and executable shebang scripts", async () => {
239 const f = await fixture();
240 const nested = NodePath.join(f.directory, "nested.mjs");
241 await NodeFSP.writeFile(
242 nested,
243 `import { start } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; start("nested process"); console.log("nested stdout");`,
244 );
245 const child = f.run(
246 `
247 import { spawn } from "node:child_process";
248 const child = spawn(process.execPath, [${JSON.stringify(nested)}], { stdio: ["ignore", "inherit", "inherit"] });
249 child.on("exit", code => { process.exitCode = code; process.disconnect(); });
250 `,
251 { shebang: true },
252 );
253 expect(await child.completion).toEqual({ code: 0, stdout: "nested stdout\n", stderr: "" });
254 expect(hasNode(f.packets, "nested process")).toBe(true);
255 expect(f.packets.filter((packet) => packet.type === "hello")).toHaveLength(1);
256});
257
258it("leaves unrelated Node modules and exit status alone", async () => {
259 const f = await fixture();
260 const child = f.run(
261 `import { basename } from "node:path"; console.log(basename("/some/file")); process.disconnect(); process.exit(7);`,
262 );
263 expect(await child.completion).toEqual({ code: 7, stdout: "file\n", stderr: "" });
264 expect(f.packets).toHaveLength(0);
265});
266
267it("resnapshots active roots after the collector connection is replaced", async () => {
268 const f = await fixture();
269 const child = f.run(`
270 import { Root } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)};
271 const root = new Root();
272 root.start("reconnect parent", { total: 8, value: 3 }).start("reconnect child");
273 process.send("ready");
274 await new Promise(resolve => process.once("message", resolve));
275 process.disconnect();
276 `);
277 await child.ready;
278 await f.waitFor((rows) => hasNode(rows, "reconnect child"));
279 const firstCount = f.packets.length;
280 for (const socket of f.sockets) socket.destroy();
281 await f.waitFor(
282 (rows) =>
283 rows.filter((row) => row.type === "hello").length === 2 &&
284 hasNode(rows.slice(firstCount), "reconnect child"),
285 );
286 expect(
287 new Set(f.packets.filter((row) => row.type === "hello").map((row) => row.producerId)).size,
288 ).toBe(1);
289 const snapshot = f.packets.slice(firstCount).find((row) => row.event)?.event;
290 expect(snapshot?.[0]).toBe(-1);
291 expect(await decodeSnapshot(snapshot ? [snapshot] : [])).toEqual([
292 { text: "reconnect parent", value: 3, children: [{ text: "reconnect child", value: 0 }] },
293 ]);
294 child.send();
295 expect((await child.completion).code).toBe(0);
296});
297
298it("observes separate loaded copies without sharing root identities", async () => {
299 const f = await fixture();
300 const url = NodeURL.pathToFileURL(copies[0]!).href;
301 const child = f.run(`
302 const first = await import(${JSON.stringify(url)});
303 const second = await import(${JSON.stringify(`${url}?second-copy`)});
304 first.start("first copy");
305 second.start("second copy");
306 process.send("ready");
307 await new Promise(resolve => process.once("message", resolve));
308 process.disconnect();
309 `);
310 await child.ready;
311 await f.waitFor((rows) => hasNode(rows, "first copy") && hasNode(rows, "second copy"));
312 const ids = f.packets
313 .filter((row) => hasNode([row], "first copy") || hasNode([row], "second copy"))
314 .map((row) => row.streamId);
315 expect(new Set(ids).size).toBe(2);
316 expect(f.packets.filter((row) => row.type === "hello")).toHaveLength(1);
317 child.send();
318 expect((await child.completion).code).toBe(0);
319});
320
321it("fails open when the Node runtime cannot install synchronous module hooks", async () => {
322 const f = await fixture();
323 const shim = NodePath.join(f.directory, "old-runtime.cjs");
324 await NodeFSP.writeFile(
325 shim,
326 `const modules = require("node:module"); modules.registerHooks = undefined; modules.syncBuiltinESMExports();`,
327 );
328 f.environment.NODE_OPTIONS = `--require=${JSON.stringify(shim)} ${f.environment.NODE_OPTIONS}`;
329 const child = f.run(
330 `import { start } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)}; start("not captured"); console.log("still works"); process.disconnect();`,
331 );
332 expect(await child.completion).toEqual({ code: 0, stdout: "still works\n", stderr: "" });
333 expect(f.packets).toHaveLength(0);
334});
335
336it("preserves genuine parent FD forwarding while capturing an independent child root", async () => {
337 const f = await fixture();
338 const url = NodeURL.pathToFileURL(copies[0]!).href;
339 const nested = NodePath.join(f.directory, "forwarded.mjs");
340 await NodeFSP.writeFile(
341 nested,
342 `
343 import * as p from ${JSON.stringify(url)};
344 p.start("forwarded global");
345 new p.Root().start("independent child");
346 process.send("ready");
347 await new Promise(resolve => process.once("message", resolve));
348 process.disconnect();
349 `,
350 );
351 const child = f.run(`
352 import { spawn } from "node:child_process";
353 import { Readable } from "node:stream";
354 import * as p from ${JSON.stringify(url)};
355 const parent = new p.Root().start("parent owns progress");
356 const child = spawn(process.execPath, [${JSON.stringify(nested)}], {
357 env: { ...process.env, ...p.ipcChildEnvironment(3) },
358 stdio: ["ignore", "inherit", "inherit", "pipe", "ipc"],
359 });
360 const decoding = p.decodeAttachToRef(Readable.toWeb(child.stdio[3]), parent);
361 child.on("message", () => process.send("ready"));
362 await new Promise(resolve => process.once("message", resolve));
363 child.send("finish");
364 await new Promise(resolve => child.once("close", resolve));
365 await decoding;
366 process.disconnect();
367 `);
368 await child.ready;
369 await f.waitFor(
370 (rows) => hasNode(rows, "forwarded global") && hasNode(rows, "independent child"),
371 );
372 const forwarded = f.packets.filter((row) => hasNode([row], "forwarded global"));
373 expect(forwarded).toHaveLength(1);
374 const parentStream = f.packets.find((row) => hasNode([row], "parent owns progress"))?.streamId;
375 expect(forwarded[0]?.streamId).toBe(parentStream);
376 expect(f.packets.filter((row) => row.type === "hello")).toHaveLength(2);
377 child.send();
378 expect((await child.completion).code).toBe(0);
379});
380
381it("resolves package imports from the script location independently of the current directory", async () => {
382 const f = await fixture();
383 const packageDirectory = NodePath.join(f.directory, "node_modules", "@clo");
384 await NodeFSP.mkdir(packageDirectory, { recursive: true });
385 await NodeFSP.symlink(
386 NodePath.dirname(copies[0]!),
387 NodePath.join(packageDirectory, "lib"),
388 "dir",
389 );
390 const child = f.run(
391 `import { start } from "@clo/lib/progress"; start("package import"); process.send("ready"); await new Promise(resolve => process.once("message", resolve)); process.disconnect();`,
392 );
393 await child.ready;
394 await f.waitFor((rows) => hasNode(rows, "package import"));
395 child.send();
396 expect((await child.completion).code).toBe(0);
397});
398
399describe.each(copies)("completed root using %s", (copy) => {
400 it("sends exactly one terminal event and closes the observed root", async () => {
401 const f = await fixture();
402 const child = f.run(`
403 const { Root } = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)});
404 const root = new Root();
405 root.start("finite custom task");
406 process.send("ready");
407 await new Promise(resolve => process.once("message", resolve));
408 root.end("finished");
409 process.send("ended");
410 await new Promise(resolve => process.once("message", resolve));
411 process.disconnect();
412 `);
413 await child.ready;
414 await f.waitFor((rows) => hasNode(rows, "finite custom task"));
415 child.send();
416 await f.waitFor((rows) =>
417 rows.some((row) => row.event?.some((part) => Array.isArray(part) && part[0] === "end")),
418 );
419 const terminals = f.packets.flatMap(
420 (row) => row.event?.filter((part) => Array.isArray(part) && part[0] === "end") ?? [],
421 );
422 expect(terminals).toEqual([["end", "finished"]]);
423 child.send();
424 expect((await child.completion).code).toBe(0);
425 });
426});
427
428describe.each(copies)("error semantics using %s", (copy) => {
429 it("preserves the original unhandled root error and nonzero exit status", async () => {
430 const f = await fixture();
431 const source = `
432 const { Root } = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)});
433 const root = new Root();
434 root.start("task");
435 root.error(new Error("deliberate failure"));
436 console.log("SURVIVED");
437 `;
438 const child = f.run(source);
439 const baseline = f.run(source, { preload: false });
440 const original = await baseline.completion;
441 const observed = await child.completion;
442 expect(original.code).toBe(1);
443 expect(observed.code).toBe(original.code);
444 expect(observed.stdout).toBe(original.stdout);
445 expect(observed.stdout).not.toContain("SURVIVED");
446 expect(observed.stderr).toContain("deliberate failure");
447 });
448
449 it("preserves existing handled root errors", async () => {
450 const f = await fixture();
451 const source = `
452 const { Root } = await import(${JSON.stringify(NodeURL.pathToFileURL(copy).href)});
453 const root = new Root();
454 root.on("error", () => console.log("handled"));
455 root.start("task");
456 root.error(new Error("expected failure"));
457 console.log("continued");
458 process.disconnect();
459 `;
460 const child = f.run(source);
461 const baseline = f.run(source, { preload: false });
462 expect(await child.completion).toEqual(await baseline.completion);
463 expect((await child.completion).stdout).toBe("handled\ncontinued\n");
464 });
465});
466
467it("captures a new root after reconnecting and then losing an idle collector connection", async () => {
468 const f = await fixture();
469 const child = f.run(`
470 import { Root } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)};
471 const first = new Root();
472 first.start("first active root");
473 process.send("ready");
474 await new Promise(resolve => process.once("message", resolve));
475 first.end();
476 await new Promise(resolve => process.once("message", resolve));
477 new Root().start("later root");
478 process.send("ready");
479 await new Promise(resolve => process.once("message", resolve));
480 process.disconnect();
481 `);
482 await child.ready;
483 await f.waitFor((rows) => hasNode(rows, "first active root"));
484 for (const socket of f.sockets) socket.destroy();
485 await f.waitFor((rows) => rows.filter((row) => row.type === "hello").length === 2);
486 child.send();
487 await f.waitFor((rows) =>
488 rows.some((row) => row.event?.some((part) => Array.isArray(part) && part[0] === "end")),
489 );
490 await Promise.all(
491 [...f.sockets].map(async (socket) => {
492 const closed = NodeEvents.EventEmitter.once(socket, "close");
493 socket.destroy();
494 await closed;
495 }),
496 );
497 child.send();
498 await f.waitFor(
499 (rows) =>
500 rows.filter((row) => row.type === "hello").length === 3 && hasNode(rows, "later root"),
501 );
502 child.send();
503 expect((await child.completion).code).toBe(0);
504});
505
506it("installs hooks before an existing require preload imports progress", async () => {
507 const f = await fixture();
508 const userPreload = NodePath.join(f.directory, "existing user preload.cjs");
509 await NodeFSP.writeFile(
510 userPreload,
511 `const progress = require(${JSON.stringify(copies[0]!)}); progress.start("user preload task");`,
512 );
513 f.environment.NODE_OPTIONS += ` --require=${JSON.stringify(userPreload)}`;
514 const child = f.run(`
515 import { start } from ${JSON.stringify(NodeURL.pathToFileURL(copies[0]!).href)};
516 start("main task after preload");
517 process.send("ready");
518 await new Promise(resolve => process.once("message", resolve));
519 process.disconnect();
520 `);
521 await child.ready;
522 await f.waitFor(
523 (rows) => hasNode(rows, "user preload task") && hasNode(rows, "main task after preload"),
524 );
525 child.send();
526 expect((await child.completion).code).toBe(0);
527});
528
529it("distinguishes worker-thread producers sharing one process ID", async () => {
530 const f = await fixture();
531 const url = NodeURL.pathToFileURL(copies[0]!).href;
532 const workerSource = `
533 const { parentPort } = require("node:worker_threads");
534 import(${JSON.stringify(url)}).then(({ Root }) => {
535 new Root().start("worker task");
536 parentPort.postMessage("ready");
537 parentPort.once("message", () => parentPort.close());
538 });
539 `;
540 const child = f.run(`
541 import { Worker } from "node:worker_threads";
542 import { Root } from ${JSON.stringify(url)};
543 new Root().start("main thread task");
544 const worker = new Worker(${JSON.stringify(workerSource)}, { eval: true });
545 worker.once("message", () => process.send("ready"));
546 await new Promise(resolve => process.once("message", resolve));
547 worker.postMessage("finish");
548 await new Promise(resolve => worker.once("exit", resolve));
549 process.disconnect();
550 `);
551 await child.ready;
552 await f.waitFor((rows) => hasNode(rows, "main thread task") && hasNode(rows, "worker task"));
553 const producers = f.packets.filter((packet) => packet.type === "hello");
554 expect(producers).toHaveLength(2);
555 expect(new Set(producers.map((packet) => packet.pid)).size).toBe(1);
556 expect(new Set(producers.map((packet) => packet.producerId)).size).toBe(2);
557 child.send();
558 expect((await child.completion).code).toBe(0);
559});
packages/shared/src/progressPreload.ts created+169
...@@ -0,0 +1,169 @@
1/** Standalone Node preload written outside packaged application archives. */
2export const progressPreloadSource = String.raw`
3const modules = require("node:module");
4const { connect } = require("node:net");
5const { fstatSync } = require("node:fs");
6const { basename } = require("node:path");
7const { randomUUID } = require("node:crypto");
8
9const endpoint = /^127\.0\.0\.1:(\d+)$/.exec(process.env.T3_SCRIPT_PROGRESS_ENDPOINT ?? "");
10const token = process.env.T3_SCRIPT_PROGRESS_TOKEN;
11const key = Symbol.for("t3.script-progress.observe");
12if (endpoint && token && typeof modules.registerHooks === "function" && !globalThis[key]) {
13 const port = Number(endpoint[1]);
14 if (port > 0 && port <= 65535) {
15 try { install(port, token); } catch {}
16 }
17}
18
19function install(port, token) {
20 const producerId = randomUUID();
21 const roots = new Map();
22 const observed = new WeakSet();
23 let nextId = 0;
24 let socket;
25 let connected = false;
26 let retry;
27 let queue = [];
28 let queuedBytes = 0;
29 let exiting = false;
30 let flushed = false;
31 const limit = 1024 * 1024;
32
33 function send(record) {
34 try {
35 const line = JSON.stringify(record) + "\n";
36 if (Buffer.byteLength(line) > limit) return;
37 if (connected && socket) {
38 if (socket.writableLength + Buffer.byteLength(line) <= limit) socket.write(line);
39 else socket.destroy();
40 } else if (queuedBytes + Buffer.byteLength(line) <= limit) {
41 queue.push(line);
42 queuedBytes += Buffer.byteLength(line);
43 } else {
44 socket?.destroy();
45 }
46 } catch {}
47 }
48
49 async function read(root, state) {
50 try {
51 // The server coalesces updates; capture must not add a script-side timer.
52 // Observing an error must not consume the emitter's unhandled-error throw.
53 const source = {
54 get active() { return root.active; },
55 delay: root.delay,
56 now: root.now,
57 on(channel, callback) {
58 return channel === "error"
59 ? root.onAny((event, args) => { if (event === "error") callback(...args); })
60 : root.on(channel, callback);
61 },
62 onAny: root.onAny.bind(root),
63 };
64 const reader = state.encode(source, { throttleMs: -Infinity }).getReader();
65 state.reader = reader;
66 while (state.reader === reader) {
67 const { done, value } = await reader.read();
68 if (state.reader !== reader) break;
69 if (done) {
70 roots.delete(root);
71 break;
72 }
73 send({ type: "progress", streamId: state.id, event: value });
74 }
75 } catch {}
76 }
77
78 function open() {
79 if (exiting) return;
80 retry = undefined;
81 const connection = connect({ host: "127.0.0.1", port });
82 socket = connection;
83 connection.unref();
84 connection.on("error", () => {});
85 connection.on("connect", () => {
86 connected = true;
87 connection.write(JSON.stringify({
88 type: "hello", token, producerId, pid: process.pid,
89 label: process.argv[1] ? basename(process.argv[1]) : undefined,
90 }) + "\n");
91 for (const line of queue) connection.write(line);
92 queue = [];
93 queuedBytes = 0;
94 for (const [root, state] of roots) if (!state.reader) void read(root, state);
95 });
96 connection.on("close", () => {
97 if (socket !== connection) return;
98 socket = undefined;
99 connected = false;
100 queue = [];
101 queuedBytes = 0;
102 for (const state of roots.values()) {
103 const reader = state.reader;
104 state.reader = undefined;
105 void reader?.cancel().catch(() => {});
106 }
107 if (!exiting && roots.size) {
108 retry = setTimeout(open, 500);
109 retry.unref();
110 }
111 });
112 }
113
114 Object.defineProperty(globalThis, key, { value(Root, encode, forwardedRoot) {
115 if (typeof Root?.prototype?.start !== "function" || typeof Root.prototype.onAny !== "function" || typeof encode !== "function") return;
116 let forwarded = false;
117 const [fd, parent] = (process.env.CLOVER_PROGRESS ?? "").split(":").map(Number);
118 if (forwardedRoot && parent === process.ppid && fd > 2) {
119 try { forwarded = fstatSync(fd).isFIFO() || fstatSync(fd).isSocket(); } catch {}
120 }
121 const start = Root.prototype.start;
122 Root.prototype.start = function (...args) {
123 if (!(forwarded && this === forwardedRoot) && !observed.has(this)) {
124 observed.add(this);
125 try {
126 const state = { id: String(++nextId), encode };
127 roots.set(this, state);
128 void read(this, state);
129 const off = this.onAny((channel, args) => {
130 if (channel !== "end" && channel !== "error") return;
131 const reader = state.reader;
132 state.reader = undefined;
133 roots.delete(this);
134 try {
135 send({ type: "progress", streamId: state.id, event: [0,
136 channel === "error" ? ["error", String(args[0])] : ["end", args[0] ?? null],
137 ] });
138 } catch {}
139 queueMicrotask(() => { off(); void reader?.cancel().catch(() => {}); });
140 });
141 if (!socket && !retry) open();
142 } catch {}
143 }
144 return start.apply(this, args);
145 };
146 }});
147
148 modules.registerHooks({ load(url, context, nextLoad) {
149 const loaded = nextLoad(url, context);
150 if (!/\/progress\.(?:[cm]?js|ts)(?:\?|$)/.test(url) || !loaded.source) return loaded;
151 const source = typeof loaded.source === "string" ? loaded.source : Buffer.from(loaded.source).toString("utf8");
152 if (!/export\s+class\s+Root\b/.test(source) || !/export\s+function\s+encodeEventStream\b/.test(source)) return loaded;
153 return { ...loaded, source: source + "\n;try { globalThis[Symbol.for('t3.script-progress.observe')]?.(Root, encodeEventStream, typeof tryAttachToParent === 'function' ? global : null); } catch {}\n" };
154 }});
155
156 process.on("beforeExit", () => {
157 if (flushed || !socket) return;
158 flushed = true;
159 exiting = true;
160 clearTimeout(retry);
161 const connection = socket;
162 connection.ref();
163 const deadline = setTimeout(() => connection.destroy(), 100);
164 connection.once("close", () => clearTimeout(deadline));
165 if (connected) connection.end();
166 else connection.once("connect", () => connection.end());
167 });
168}
169`;
pnpm-lock.yaml+15
...@@ -609,6 +609,9 @@ importers:...@@ -609,6 +609,9 @@ importers:
609 '@anthropic-ai/claude-agent-sdk':609 '@anthropic-ai/claude-agent-sdk':
610 specifier: ^0.3.276610 specifier: ^0.3.276
611 version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)611 version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(zod@4.6.5))(zod@4.6.5)
612 '@clo/lib':
613 specifier: workspace:*
614 version: link:../../packages/clo-lib
612 '@cursor/sdk':615 '@cursor/sdk':
613 specifier: 1.0.35616 specifier: 1.0.35
614 version: 1.0.35617 version: 1.0.35
...@@ -760,6 +763,9 @@ importers:...@@ -760,6 +763,9 @@ importers:
760 '@clerk/react':763 '@clerk/react':
761 specifier: 6.16.1764 specifier: 6.16.1
762 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)765 version: 6.16.1(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
766 '@clo/lib':
767 specifier: workspace:*
768 version: link:../../packages/clo-lib
763 '@clo/markdown-editor':769 '@clo/markdown-editor':
764 specifier: workspace:*770 specifier: workspace:*
765 version: link:../../packages/markdown-editor771 version: link:../../packages/markdown-editor
...@@ -1116,6 +1122,15 @@ importers:...@@ -1116,6 +1122,15 @@ importers:
1116 specifier: 1.0.01122 specifier: 1.0.0
1117 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)1123 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1(vitest@5.0.1))(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
11181124
1125 packages/clo-lib:
1126 devDependencies:
1127 '@types/node':
1128 specifier: 24.12.4
1129 version: 24.12.4
1130 vite-plus:
1131 specifier: 1.0.0
1132 version: 1.0.0(@types/node@24.12.4)(@vitest/ui@5.0.1)(bufferutil@4.1.0)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(msw@2.12.11(@types/node@24.12.4)(typescript@7.0.2))(terser@5.48.0)(typescript@7.0.2)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)
1133
1119 packages/contracts:1134 packages/contracts:
1120 dependencies:1135 dependencies:
1121 effect:1136 effect: