docks-kit 0.14.3 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,7 +49,7 @@ export interface DependencyLocation {
49
49
 
50
50
  export interface ProbeExecutor {
51
51
  readonly commandExists: (name: string) => boolean
52
- readonly capture: (cmd: string, args: ReadonlyArray<string>) => string
52
+ readonly capture: (cmd: string, args: ReadonlyArray<string>) => Promise<string>
53
53
  readonly which: (name: string) => string
54
54
  }
55
55
 
@@ -60,17 +60,17 @@ export interface DependencySpec {
60
60
  /** Platform-correct one-line install command (param injectable for tests). */
61
61
  readonly installHint: (platform?: NodeJS.Platform) => string
62
62
  readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
63
- readonly version?: (exec: ProbeExecutor) => string
64
- readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => DependencyLocation
65
- readonly latest?: (exec: ProbeExecutor) => string
63
+ readonly version?: (exec: ProbeExecutor) => Promise<string>
64
+ readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => Promise<DependencyLocation>
65
+ readonly latest?: (exec: ProbeExecutor) => Promise<string>
66
66
  }
67
67
 
68
68
  interface SpecOptions {
69
69
  readonly versionArgs?: ReadonlyArray<string>
70
70
  readonly resolve?: (exec: ProbeExecutor, platform: NodeJS.Platform) => ProbeResult
71
- readonly version?: (exec: ProbeExecutor) => string
72
- readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => DependencyLocation
73
- readonly latest?: (exec: ProbeExecutor) => string
71
+ readonly version?: (exec: ProbeExecutor) => Promise<string>
72
+ readonly locate?: (exec: ProbeExecutor, platform: NodeJS.Platform) => Promise<DependencyLocation>
73
+ readonly latest?: (exec: ProbeExecutor) => Promise<string>
74
74
  }
75
75
 
76
76
  const spec = (
@@ -99,8 +99,8 @@ const versionProbe = (
99
99
  id: string,
100
100
  versionArgs: ReadonlyArray<string> = ["--version"],
101
101
  parse: (out: string) => string = (out) => out
102
- ): ((exec: ProbeExecutor) => string) =>
103
- (exec) => parse(exec.capture(id, versionArgs))
102
+ ): ((exec: ProbeExecutor) => Promise<string>) =>
103
+ async (exec) => parse(await exec.capture(id, versionArgs))
104
104
 
105
105
  const home = (): string => {
106
106
  const envHome = process.env["HOME"]
@@ -144,45 +144,49 @@ const resolveEffectSolutions = (exec: ProbeExecutor): ProbeResult => {
144
144
  const versionBunCommand = (exec: ProbeExecutor): string =>
145
145
  exec.commandExists("bun") ? "bun" : p(home(), ".bun", "bin", "bun")
146
146
 
147
- const versionEffectSolutions = (exec: ProbeExecutor): string => {
147
+ const versionEffectSolutions = async (exec: ProbeExecutor): Promise<string> => {
148
148
  const bun = versionBunCommand(exec)
149
149
  if (bun !== "bun" && exec.which(bun) === "") return ""
150
- const match = /effect-solutions@([0-9][0-9.]*)/.exec(exec.capture(bun, ["pm", "-g", "ls"]))
150
+ const match = /effect-solutions@([0-9][0-9.]*)/.exec(await exec.capture(bun, ["pm", "-g", "ls"]))
151
151
  return match?.[1] ?? ""
152
152
  }
153
153
 
154
- const locateEffectSolutions = (exec: ProbeExecutor): DependencyLocation => {
154
+ const locateEffectSolutions = async (exec: ProbeExecutor): Promise<DependencyLocation> => {
155
155
  const strictBun = findBun(exec)
156
156
  const pathBun = exec.which("bun")
157
157
  const bun = strictBun ?? (pathBun !== "" ? { command: "bun", path: pathBun } : undefined)
158
158
  if (bun === undefined) return { path: "", binDir: "" }
159
- const globalBin = exec.capture(bun.command, ["pm", "-g", "bin"])
159
+ const globalBin = await exec.capture(bun.command, ["pm", "-g", "bin"])
160
160
  const path = globalBin !== "" ? p(globalBin, "effect-solutions") : ""
161
161
  const resolved = path !== "" && exec.which(path) !== "" ? path : ""
162
162
  return { path: resolved, binDir: globalBin }
163
163
  }
164
164
 
165
- const npmGlobalCache = new WeakMap<ProbeExecutor, { [k: string]: string }>()
165
+ const npmGlobalCache = new WeakMap<ProbeExecutor, Promise<{ [k: string]: string }>>()
166
166
 
167
- const npmGlobalVersions = (exec: ProbeExecutor): { [k: string]: string } => {
167
+ const npmGlobalVersions = (exec: ProbeExecutor): Promise<{ [k: string]: string }> => {
168
168
  const hit = npmGlobalCache.get(exec)
169
169
  if (hit !== undefined) return hit
170
- const out: { [k: string]: string } = {}
171
- if (exec.commandExists("npm")) {
172
- const doc = parseJson(exec.capture("npm", ["ls", "-g", "--depth=0", "--json"]))
173
- const deps = doc !== undefined && isObject(doc) && isObject(doc["dependencies"]) ? doc["dependencies"] : {}
174
- for (const [name, value] of Object.entries(deps)) {
175
- if (isObject(value) && typeof value["version"] === "string") out[name] = value["version"]
170
+ const pending = (async (): Promise<{ [k: string]: string }> => {
171
+ const out: { [k: string]: string } = {}
172
+ if (exec.commandExists("npm")) {
173
+ const doc = parseJson(await exec.capture("npm", ["ls", "-g", "--depth=0", "--json"]))
174
+ const deps = doc !== undefined && isObject(doc) && isObject(doc["dependencies"]) ? doc["dependencies"] : {}
175
+ for (const [name, value] of Object.entries(deps)) {
176
+ if (isObject(value) && typeof value["version"] === "string") out[name] = value["version"]
177
+ }
176
178
  }
177
- }
178
- npmGlobalCache.set(exec, out)
179
- return out
179
+ return out
180
+ })()
181
+ npmGlobalCache.set(exec, pending)
182
+ return pending
180
183
  }
181
184
 
182
- const versionNpmGlobal = (pkg: string) => (exec: ProbeExecutor): string => npmGlobalVersions(exec)[pkg] ?? ""
185
+ const versionNpmGlobal = (pkg: string) => async (exec: ProbeExecutor): Promise<string> =>
186
+ (await npmGlobalVersions(exec))[pkg] ?? ""
183
187
 
184
- const latestNpm = (id: "effect-solutions") => (exec: ProbeExecutor): string =>
185
- exec.commandExists("npm") ? exec.capture("npm", ["view", id, "version"]) : ""
188
+ const latestNpm = (id: "effect-solutions") => async (exec: ProbeExecutor): Promise<string> =>
189
+ exec.commandExists("npm") ? await exec.capture("npm", ["view", id, "version"]) : ""
186
190
 
187
191
  export const defaultProbeExecutor: ProbeExecutor = { commandExists, capture, which }
188
192
 
@@ -230,7 +234,7 @@ export const DEPENDENCIES: Record<ToolId, DependencySpec> = {
230
234
  {
231
235
  resolve: resolveBun,
232
236
  version: (exec) => exec.capture(versionBunCommand(exec), ["--version"]),
233
- locate: (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
237
+ locate: async (exec) => ({ path: findBun(exec)?.path ?? "", binDir: "" })
234
238
  }
235
239
  ),
236
240
  bwrap: spec("bwrap", "optional", () => "sudo apt install -y bubblewrap (or dnf/pacman/zypper equivalent)", {
@@ -277,21 +281,25 @@ export function resolveDependency(
277
281
  return (specification.resolve ?? pathProbe(specification.id))(exec, platform)
278
282
  }
279
283
 
280
- export function resolveVersion(specification: DependencySpec, exec: ProbeExecutor): string {
284
+ export async function resolveVersion(specification: DependencySpec, exec: ProbeExecutor): Promise<string> {
281
285
  if (resolveDependency(specification, exec).state !== "present") return ""
282
- return (specification.version ?? versionProbe(specification.id, specification.versionArgs))(exec)
286
+ return await (specification.version ?? versionProbe(specification.id, specification.versionArgs))(exec)
283
287
  }
284
288
 
285
- export function resolveLocation(
289
+ export async function resolveLocation(
286
290
  specification: DependencySpec,
287
291
  exec: ProbeExecutor,
288
292
  platform: NodeJS.Platform = rawPlatform()
289
- ): DependencyLocation {
290
- if (specification.locate !== undefined) return specification.locate(exec, platform)
293
+ ): Promise<DependencyLocation> {
294
+ if (specification.locate !== undefined) return await specification.locate(exec, platform)
291
295
  const result = resolveDependency(specification, exec, platform)
292
296
  return { path: result.state === "present" ? (result.path ?? exec.which(specification.id)) : "", binDir: "" }
293
297
  }
294
298
 
295
- export function resolvePath(specification: DependencySpec, exec: ProbeExecutor, platform?: NodeJS.Platform): string {
296
- return resolveLocation(specification, exec, platform).path
299
+ export async function resolvePath(
300
+ specification: DependencySpec,
301
+ exec: ProbeExecutor,
302
+ platform?: NodeJS.Platform
303
+ ): Promise<string> {
304
+ return (await resolveLocation(specification, exec, platform)).path
297
305
  }
@@ -3,7 +3,7 @@
3
3
  * the intended binary with deterministic argv, and capture() mirrors command
4
4
  * substitution: stdout with trailing newlines stripped, empty on failure.
5
5
  */
6
- import { spawnSync } from "node:child_process"
6
+ import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process"
7
7
  import { accessSync, chmodSync, constants, existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
8
8
  import { delimiter, isAbsolute, join } from "node:path"
9
9
 
@@ -12,10 +12,66 @@ export function p(...parts: Array<string>): string {
12
12
  return parts.join("/")
13
13
  }
14
14
 
15
- export function capture(cmd: string, args: ReadonlyArray<string>): string {
16
- const res = spawnSync(cmd, args as Array<string>, { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] })
17
- if (res.error !== undefined || res.status !== 0) return ""
18
- return (res.stdout ?? "").replace(/[\r\n]+$/, "")
15
+ export interface AsyncProcessResult {
16
+ readonly exitCode: number | null
17
+ readonly stdout: string
18
+ readonly stderr: string
19
+ readonly error?: Error
20
+ }
21
+
22
+ export interface AsyncProcessOptions {
23
+ readonly stdio?: SpawnOptions["stdio"]
24
+ }
25
+
26
+ export function spawnProcess(
27
+ cmd: string,
28
+ args: ReadonlyArray<string>,
29
+ options: AsyncProcessOptions = {}
30
+ ): Promise<AsyncProcessResult> {
31
+ const { promise, resolve } = Promise.withResolvers<AsyncProcessResult>()
32
+ let child: ChildProcess
33
+ try {
34
+ child = spawn(cmd, [...args], { stdio: options.stdio ?? ["ignore", "pipe", "ignore"] })
35
+ } catch (cause) {
36
+ const error = cause instanceof Error ? cause : new Error(String(cause))
37
+ resolve({ exitCode: null, stdout: "", stderr: "", error })
38
+ return promise
39
+ }
40
+ let stdout = ""
41
+ let stderr = ""
42
+ let error: Error | undefined
43
+
44
+ if (child.stdout !== null) {
45
+ child.stdout.setEncoding("utf8")
46
+ child.stdout.on("data", (chunk: string) => {
47
+ stdout += chunk
48
+ })
49
+ child.stdout.on("error", (cause) => {
50
+ error ??= cause
51
+ })
52
+ }
53
+ if (child.stderr !== null) {
54
+ child.stderr.setEncoding("utf8")
55
+ child.stderr.on("data", (chunk: string) => {
56
+ stderr += chunk
57
+ })
58
+ child.stderr.on("error", (cause) => {
59
+ error ??= cause
60
+ })
61
+ }
62
+ child.once("error", (cause) => {
63
+ error ??= cause
64
+ })
65
+ child.once("close", (exitCode) => {
66
+ resolve({ exitCode, stdout, stderr, ...(error !== undefined ? { error } : {}) })
67
+ })
68
+ return promise
69
+ }
70
+
71
+ export async function capture(cmd: string, args: ReadonlyArray<string>): Promise<string> {
72
+ const res = await spawnProcess(cmd, args, { stdio: ["ignore", "pipe", "ignore"] })
73
+ if (res.error !== undefined || res.exitCode !== 0) return ""
74
+ return res.stdout.replace(/[\r\n]+$/, "")
19
75
  }
20
76
 
21
77
  /** `command -v` — resolve an executable name on PATH. */
@@ -9,11 +9,13 @@ import { p } from "./exec"
9
9
  import { homedir } from "node:os"
10
10
 
11
11
  import { kitHome } from "../kitHome"
12
+ import { payloadText } from "../payload"
12
13
  import { makeEngineServices, type EngineServices, type Logger } from "./services"
14
+ import type { TerminalLease } from "./logger"
13
15
  import type { BunRuntimeState } from "./bun"
14
16
  import { claudeNextSteps, claudeSummary, claudeSync, type ClaudeRuntimeState } from "./claudeSync"
15
17
  import { codexNextSteps, codexSummary, codexSync } from "./codexSync"
16
- import { skillsNextSteps, skillsSummary, skillsSync, type SkillsState } from "./skillsSync"
18
+ import { normalizeManifest, skillsNextSteps, skillsSummary, skillsSync, type SkillsState } from "./skillsSync"
17
19
  import { modeModel, modeToolchain } from "./modes"
18
20
  import { ExitError, parseArgs, validateModifierFlags } from "./parseArgs"
19
21
 
@@ -24,6 +26,54 @@ export type ModifierFlag =
24
26
  | "--codex-model"
25
27
  | "--codex-effort"
26
28
 
29
+ export type SyncConcurrency = 1 | 2 | 3
30
+ export type SyncTask<T> = () => Promise<T>
31
+
32
+ /**
33
+ * Run input-ordered tasks with bounded overlap. Once one task rejects, queued
34
+ * tasks stay queued while already-started tasks drain; the earliest rejection
35
+ * in input order is then propagated.
36
+ */
37
+ export async function runBounded<T>(
38
+ tasks: ReadonlyArray<SyncTask<T>>,
39
+ concurrency: SyncConcurrency
40
+ ): Promise<Array<T>> {
41
+ const results = new Array<T>(tasks.length)
42
+ const failures = new Map<number, unknown>()
43
+ let nextIndex = 0
44
+ let stopped = false
45
+
46
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, async () => {
47
+ for (;;) {
48
+ if (stopped || nextIndex >= tasks.length) return
49
+ const index = nextIndex
50
+ nextIndex += 1
51
+ try {
52
+ results[index] = await tasks[index]!()
53
+ } catch (error) {
54
+ failures.set(index, error)
55
+ stopped = true
56
+ }
57
+ }
58
+ })
59
+ await Promise.all(workers)
60
+
61
+ for (let index = 0; index < tasks.length; index += 1) {
62
+ if (failures.has(index)) throw failures.get(index)
63
+ }
64
+ return results
65
+ }
66
+
67
+ export function syncConcurrencyForManifest(
68
+ configured: SyncConcurrency,
69
+ manifest: string,
70
+ claudeSelected: boolean,
71
+ skillsSelected: boolean
72
+ ): SyncConcurrency {
73
+ if (!claudeSelected || !skillsSelected || normalizeManifest(manifest).length === 0) return configured
74
+ return 1
75
+ }
76
+
27
77
  export interface Ctx {
28
78
  readonly repoDir: string
29
79
  readonly home: string
@@ -47,7 +97,9 @@ export interface Ctx {
47
97
  modifierFlags?: Set<ModifierFlag>
48
98
  /** Injected capability seam (logger/deps/platform) — see services.ts. */
49
99
  readonly services: EngineServices
50
- bunRuntime?: BunRuntimeState
100
+ syncConcurrency: SyncConcurrency
101
+ terminalLease?: TerminalLease
102
+ bunRuntime?: Promise<BunRuntimeState>
51
103
  targetFilterSet: boolean
52
104
  syncClaude: boolean
53
105
  syncCodex: boolean
@@ -85,6 +137,7 @@ function makeCtx(services: EngineServices): Ctx {
85
137
  codexModel: env["CODEX_MODEL"] ?? "",
86
138
  codexEffort: "",
87
139
  modifierFlags: new Set(),
140
+ syncConcurrency: 3,
88
141
  services,
89
142
  targetFilterSet: false,
90
143
  syncClaude: false,
@@ -94,40 +147,94 @@ function makeCtx(services: EngineServices): Ctx {
94
147
  }
95
148
  }
96
149
 
97
- function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
98
- const { clearProgress, echo, progress } = ctx.services.logger
150
+ async function engineSync(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
151
+ const { acquireTerminal, echo, err } = ctx.services.logger
99
152
  parseArgs(ctx, args)
100
153
  validateModifierFlags(ctx)
101
154
 
102
- const claudeRan = ctx.syncClaude
103
- let claudeRuntime: ClaudeRuntimeState | undefined
104
- if (claudeRan) {
105
- progress("Syncing Claude...")
106
- try {
107
- claudeRuntime = claudeSync(ctx)
108
- } finally {
109
- clearProgress()
110
- }
155
+ const configuredConcurrency = process.env["DOCKS_KIT_SYNC_CONCURRENCY"]
156
+ if (configuredConcurrency === undefined || configuredConcurrency === "") {
157
+ ctx.syncConcurrency = 3
158
+ } else if (
159
+ configuredConcurrency === "1" ||
160
+ configuredConcurrency === "2" ||
161
+ configuredConcurrency === "3"
162
+ ) {
163
+ ctx.syncConcurrency = Number(configuredConcurrency) as SyncConcurrency
164
+ } else {
165
+ err("DOCKS_KIT_SYNC_CONCURRENCY must be 1, 2, or 3")
166
+ throw new ExitError(2)
111
167
  }
112
168
 
113
- const codexRan = ctx.syncCodex
114
- if (codexRan) {
115
- progress("Syncing Codex...")
116
- try {
117
- codexSync(ctx)
118
- } finally {
119
- clearProgress()
120
- }
169
+ type PipelineResult =
170
+ | { readonly kind: "claude"; readonly runtime: ClaudeRuntimeState }
171
+ | { readonly kind: "codex" }
172
+ | { readonly kind: "skills"; readonly state: SkillsState }
173
+ interface SelectedPipeline {
174
+ readonly name: string
175
+ readonly run: SyncTask<PipelineResult>
121
176
  }
122
177
 
123
- let skillsState: SkillsState | undefined
178
+ const selected: Array<SelectedPipeline> = []
179
+ if (ctx.syncClaude) {
180
+ selected.push({
181
+ name: "Claude",
182
+ run: async () => ({ kind: "claude", runtime: await claudeSync(ctx) })
183
+ })
184
+ }
185
+ if (ctx.syncCodex) {
186
+ selected.push({
187
+ name: "Codex",
188
+ run: async () => {
189
+ await codexSync(ctx)
190
+ return { kind: "codex" }
191
+ }
192
+ })
193
+ }
124
194
  if (ctx.syncAgents) {
125
- progress("Syncing skills...")
126
- try {
127
- skillsState = skillsSync(ctx)
128
- } finally {
129
- clearProgress()
130
- }
195
+ selected.push({
196
+ name: "skills",
197
+ run: async () => ({ kind: "skills", state: await skillsSync(ctx) })
198
+ })
199
+ }
200
+
201
+ // A populated skills manifest deploys with `-a claude-code codex`, and symlink healing also writes into Claude's tree.
202
+ ctx.syncConcurrency = syncConcurrencyForManifest(
203
+ ctx.syncConcurrency,
204
+ payloadText("SoT/.agents/skills.txt"),
205
+ ctx.syncClaude,
206
+ ctx.syncAgents
207
+ )
208
+
209
+ const remaining = new Set(selected.map(({ name }) => name))
210
+ const lease = acquireTerminal(`Syncing ${[...remaining].join(", ")}...`)
211
+ ctx.terminalLease = lease
212
+ let results: Array<PipelineResult>
213
+ try {
214
+ const tasks = selected.map(
215
+ ({ name, run }): SyncTask<PipelineResult> =>
216
+ async () => {
217
+ try {
218
+ return await run()
219
+ } finally {
220
+ remaining.delete(name)
221
+ if (remaining.size > 0) lease.update(`Syncing ${[...remaining].join(", ")}...`)
222
+ }
223
+ }
224
+ )
225
+ results = await runBounded(tasks, ctx.syncConcurrency)
226
+ } finally {
227
+ lease.release()
228
+ ctx.terminalLease = undefined
229
+ }
230
+
231
+ const claudeRan = ctx.syncClaude
232
+ const codexRan = ctx.syncCodex
233
+ let claudeRuntime: ClaudeRuntimeState | undefined
234
+ let skillsState: SkillsState | undefined
235
+ for (const result of results) {
236
+ if (result.kind === "claude") claudeRuntime = result.runtime
237
+ else if (result.kind === "skills") skillsState = result.state
131
238
  }
132
239
 
133
240
  echo("")
@@ -150,7 +257,7 @@ function engineSync(ctx: Ctx, args: ReadonlyArray<string>): number {
150
257
  }
151
258
 
152
259
 
153
- export function runEngineNative(argv: ReadonlyArray<string>, services?: EngineServices): number {
260
+ export async function runEngineNative(argv: ReadonlyArray<string>, services?: EngineServices): Promise<number> {
154
261
  let ctx!: Ctx
155
262
  const baseServices = services ?? makeEngineServices()
156
263
  const baseLogger = baseServices.logger
@@ -163,7 +270,8 @@ export function runEngineNative(argv: ReadonlyArray<string>, services?: EngineSe
163
270
  },
164
271
  warn: (msg) => baseLogger.warn(msg),
165
272
  err: (msg) => baseLogger.err(msg),
166
- echo: (line) => baseLogger.echo(line)
273
+ echo: (line) => baseLogger.echo(line),
274
+ acquireTerminal: (message) => baseLogger.acquireTerminal(message)
167
275
  }
168
276
  const runServices: EngineServices = {
169
277
  logger,
@@ -176,11 +284,11 @@ export function runEngineNative(argv: ReadonlyArray<string>, services?: EngineSe
176
284
  case "model":
177
285
  return modeModel(ctx, argv.slice(1))
178
286
  case "toolchain":
179
- return modeToolchain(ctx, argv.slice(1))
287
+ return await modeToolchain(ctx, argv.slice(1))
180
288
  case "sync":
181
- return engineSync(ctx, argv.slice(1))
289
+ return await engineSync(ctx, argv.slice(1))
182
290
  default:
183
- return engineSync(ctx, argv)
291
+ return await engineSync(ctx, argv)
184
292
  }
185
293
  } catch (e) {
186
294
  if (e instanceof ExitError) return e.code
@@ -1,11 +1,22 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks"
2
+
1
3
  /**
2
- * Leveled stderr logger, transient progress writer, and stdout data writer —
3
- * the Output Policy contract in DESIGN.md. Filtering is explicit and
4
+ * Leveled stderr logger, one run-scoped terminal lease, and stdout data
5
+ * writer — the Output Policy contract in DESIGN.md. Filtering is explicit and
4
6
  * synchronous: engine code is imperative, so fiber-scoped Effect log levels
5
7
  * cannot see these writes. Transient progress is active only through an
6
8
  * injected progress sink or an interactive stderr. The prefixes and ANSI codes
7
9
  * are stable golden surface; the level controls visibility only.
8
10
  */
11
+ export interface TerminalLease {
12
+ /** Replace the coordinator-owned transient line. */
13
+ readonly update: (message: string) => void
14
+ /** Serialize terminal input owners and suspend transient redraw while held. */
15
+ readonly withExclusive: <T>(action: () => T | Promise<T>) => Promise<T>
16
+ /** Clear coordinator progress and return ownership to ordinary progress calls. */
17
+ readonly release: () => void
18
+ }
19
+
9
20
 
10
21
  export interface Logger {
11
22
  /** `[ok]` green — an operation actually mutated something. Always visible. */
@@ -20,6 +31,8 @@ export interface Logger {
20
31
  readonly err: (msg: string) => void
21
32
  /** stdout data line (dry-run report, summary, usage) — never filtered. */
22
33
  readonly echo: (line: string) => void
34
+ /** Acquire the run-scoped coordinator lease for terminal progress and input. */
35
+ readonly acquireTerminal: (message: string) => TerminalLease
23
36
  }
24
37
 
25
38
  export interface LoggerSinks {
@@ -63,36 +76,123 @@ export function makeLogger(sinks: LoggerSinks): Logger {
63
76
  ? (chunk: string) => writeIgnoringEpipe(process.stderr, chunk)
64
77
  : undefined)
65
78
  let progressPending = false
79
+ let activeLease: TerminalLease | undefined
80
+ let exclusiveActive = false
81
+ const exclusiveContext = new AsyncLocalStorage<{ active: boolean }>()
82
+ const durableBuffer: Array<() => void> = []
66
83
 
67
- const clearProgress = (): void => {
84
+
85
+ const eraseProgress = (): void => {
68
86
  if (!progressPending || progressWrite === undefined) return
69
87
  progressWrite("\r\x1b[2K")
70
88
  progressPending = false
71
89
  }
90
+ const drawProgress = (message: string): void => {
91
+ if (progressWrite === undefined) return
92
+ progressWrite(`\r\x1b[2K\x1b[2m${message}\x1b[0m`)
93
+ progressPending = true
94
+ }
95
+ const clearProgress = (): void => {
96
+ if (activeLease !== undefined) return
97
+ eraseProgress()
98
+ }
99
+ const writeDurable = (write: () => void): void => {
100
+ if (exclusiveActive) {
101
+ durableBuffer.push(write)
102
+ return
103
+ }
104
+ eraseProgress()
105
+ write()
106
+ }
107
+ const flushDurable = (): void => {
108
+ let firstError: unknown
109
+ while (durableBuffer.length > 0) {
110
+ for (const write of durableBuffer.splice(0)) {
111
+ try {
112
+ write()
113
+ } catch (error) {
114
+ firstError ??= error
115
+ }
116
+ }
117
+ }
118
+ if (firstError !== undefined) throw firstError
119
+ }
72
120
  const ok = (msg: string): void => {
73
- clearProgress()
74
- errWrite(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`)
121
+ writeDurable(() => errWrite(`\x1b[1;32m[ok]\x1b[0m ${msg}\n`))
75
122
  }
123
+
124
+ const acquireTerminal = (message: string): TerminalLease => {
125
+ if (activeLease !== undefined) throw new Error("terminal lease already acquired")
126
+
127
+ let released = false
128
+ let coordinatorMessage = message
129
+ let exclusiveTail = Promise.resolve()
130
+ let exclusivePending = 0
131
+ const lease: TerminalLease = {
132
+ update: (nextMessage) => {
133
+ if (released) return
134
+ coordinatorMessage = nextMessage
135
+ if (exclusivePending === 0) drawProgress(coordinatorMessage)
136
+ },
137
+ withExclusive: async <T>(action: () => T | Promise<T>): Promise<T> => {
138
+ if (exclusiveContext.getStore()?.active === true) {
139
+ throw new Error("terminal lease withExclusive cannot be re-entered")
140
+ }
141
+ if (released) return await action()
142
+ exclusivePending += 1
143
+ const previous = exclusiveTail
144
+ let unlock!: () => void
145
+ exclusiveTail = new Promise<void>((resolve) => {
146
+ unlock = resolve
147
+ })
148
+ await previous
149
+ eraseProgress()
150
+ exclusiveActive = true
151
+ const owner = { active: true }
152
+ try {
153
+ return await exclusiveContext.run(owner, action)
154
+ } finally {
155
+ owner.active = false
156
+ try {
157
+ flushDurable()
158
+ } finally {
159
+ exclusiveActive = false
160
+ exclusivePending -= 1
161
+ unlock()
162
+ if (!released && exclusivePending === 0) drawProgress(coordinatorMessage)
163
+ }
164
+ }
165
+ },
166
+ release: () => {
167
+ if (released) return
168
+ released = true
169
+ if (activeLease === lease) activeLease = undefined
170
+ eraseProgress()
171
+ }
172
+ }
173
+ activeLease = lease
174
+ drawProgress(coordinatorMessage)
175
+ return lease
176
+ }
177
+
76
178
  return {
77
179
  change: ok,
78
180
  verbose: ok,
79
181
  progress: (msg) => {
80
- if (progressWrite === undefined) return
81
- progressWrite(`\r\x1b[2K\x1b[2m${msg}\x1b[0m`)
82
- progressPending = true
182
+ if (activeLease !== undefined) return
183
+ drawProgress(msg)
83
184
  },
84
185
  clearProgress,
85
186
  warn: (msg) => {
86
- clearProgress()
87
- errWrite(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`)
187
+ writeDurable(() => errWrite(`\x1b[1;33m[warn]\x1b[0m ${msg}\n`))
88
188
  },
89
189
  err: (msg) => {
90
- clearProgress()
190
+ eraseProgress()
91
191
  errWrite(`\x1b[1;31m[err]\x1b[0m ${msg}\n`)
92
192
  },
93
193
  echo: (line) => {
94
- clearProgress()
95
- outWrite(`${line}\n`)
96
- }
194
+ writeDurable(() => outWrite(`${line}\n`))
195
+ },
196
+ acquireTerminal
97
197
  }
98
198
  }
@@ -108,7 +108,7 @@ function tomlModelText(text: string): string {
108
108
  return ""
109
109
  }
110
110
 
111
- export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
111
+ export async function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): Promise<number> {
112
112
  const { err } = ctx.services.logger
113
113
  const words = args.filter((a) => !a.startsWith("--"))
114
114
  const op = words[0] ?? args[0] ?? "check"
@@ -121,7 +121,7 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
121
121
  }
122
122
 
123
123
  if (op === "check") {
124
- report(ctx)
124
+ await report(ctx)
125
125
  return 0
126
126
  }
127
127
  if (op !== "ensure") {
@@ -134,9 +134,9 @@ export function modeToolchain(ctx: Ctx, args: ReadonlyArray<string>): number {
134
134
  }
135
135
  switch (tool) {
136
136
  case "bun":
137
- return bunBootstrap(ctx, ctx.services).kind === "ready" ? 0 : 1
137
+ return (await bunBootstrap(ctx, ctx.services)).kind === "ready" ? 0 : 1
138
138
  case "effect-solutions":
139
- return ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
139
+ return await ensure(ctx, "effect-solutions", effectSolutionsInstall(ctx))
140
140
  default:
141
141
  err("toolchain ensure supports managed tools only (bun, effect-solutions)")
142
142
  return 2