docks-kit 0.14.3 → 0.15.0

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.
@@ -3,21 +3,20 @@
3
3
  * avoid a TOML library because reformatting user configs would be a behavior
4
4
  * change. Guard order, message strings, and backup behavior are golden-tested.
5
5
  */
6
- import { spawnSync } from "node:child_process"
7
6
  import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs"
8
7
 
9
8
  import { syncCodexEffort, syncCodexModel, replaceTopLevelSettingInFile } from "./codexToml"
10
- import { p } from "./exec"
9
+ import { p, spawnProcess } from "./exec"
11
10
  import type { Ctx } from "./index"
12
11
  import { compareCodepoints, isObject, jqStringify, parseJson, type Json } from "./jq"
13
12
  import { payloadBytes, payloadDisplayPath, payloadPaths, payloadText, type PayloadPath } from "../payload"
14
13
 
15
- export function codexSync(ctx: Ctx): void {
14
+ export async function codexSync(ctx: Ctx): Promise<void> {
16
15
  const codexDir = p(ctx.home, ".codex")
17
16
  const sotConfig = payloadText("SoT/.codex/config.toml")
18
17
  const userConfig = p(codexDir, "config.toml")
19
18
 
20
- ensureBubblewrap(ctx)
19
+ await ensureBubblewrap(ctx)
21
20
  if (!ctx.dryRun) mkdirSync(codexDir, { recursive: true })
22
21
  syncConfig(ctx, sotConfig, userConfig)
23
22
  syncCodexModel(ctx, ctx.codexModel)
@@ -25,13 +24,13 @@ export function codexSync(ctx: Ctx): void {
25
24
  syncRules(ctx, payloadPaths("SoT/.codex/rules/"), p(codexDir, "rules"))
26
25
  syncAgentsMd(ctx, payloadText("SoT/.codex/AGENTS.md"), p(codexDir, "AGENTS.md"))
27
26
  syncMarketplace(ctx, payloadText("SoT/.codex/plugins/marketplace.json"), p(ctx.agentsDir, "plugins", "marketplace.json"))
28
- removeLegacyDocksMarketplace(ctx, userConfig)
29
- syncPlugins(ctx, sotConfig)
27
+ await removeLegacyDocksMarketplace(ctx, userConfig)
28
+ await syncPlugins(ctx, sotConfig)
30
29
  }
31
30
 
32
31
  // ---------------------------------------------------------- bubblewrap ----
33
32
 
34
- function ensureBubblewrap(ctx: Ctx): void {
33
+ async function ensureBubblewrap(ctx: Ctx): Promise<void> {
35
34
  const { change, echo, warn } = ctx.services.logger
36
35
  if (!bwrapSupportedOs(ctx)) return
37
36
 
@@ -58,8 +57,10 @@ function ensureBubblewrap(ctx: Ctx): void {
58
57
  }
59
58
 
60
59
  warn(`bubblewrap not installed - recommended for Codex Linux sandbox. Running: ${pmInstall} (sudo prompt may appear)`)
61
- const res = spawnSync("bash", ["-c", pmInstall], { stdio: ["inherit", "inherit", "inherit"] })
62
- if (res.status !== 0) {
60
+ const runInstaller = () =>
61
+ spawnProcess("bash", ["-c", pmInstall], { stdio: ["inherit", "inherit", "inherit"] })
62
+ const res = await (ctx.terminalLease?.withExclusive(runInstaller) ?? runInstaller())
63
+ if (res.exitCode !== 0) {
63
64
  warn(`Failed to auto-install bubblewrap. Install manually: ${pmInstall}`)
64
65
  return
65
66
  }
@@ -69,8 +70,8 @@ function ensureBubblewrap(ctx: Ctx): void {
69
70
  return
70
71
  }
71
72
 
72
- if (spawnSync("unshare", ["-Ur", "true"], { stdio: "ignore" }).status === 0) {
73
- change(`bubblewrap installed and functional (${ctx.services.deps.version("bwrap")})`)
73
+ if ((await spawnProcess("unshare", ["-Ur", "true"], { stdio: "ignore" })).exitCode === 0) {
74
+ change(`bubblewrap installed and functional (${await ctx.services.deps.version("bwrap")})`)
74
75
  } else {
75
76
  warn(
76
77
  "bubblewrap installed but unprivileged user namespaces appear blocked. On Ubuntu 24.04+, prefer loading the AppArmor bwrap-userns-restrict profile; fallback: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0"
@@ -411,7 +412,7 @@ export function marketplaceSource(marketplace: string, configFile: string): stri
411
412
  return ""
412
413
  }
413
414
 
414
- function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
415
+ async function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): Promise<void> {
415
416
  const { change, echo, warn } = ctx.services.logger
416
417
  if (ctx.dryRun) {
417
418
  echo("[dry-run] remove legacy configured Codex Docks marketplace when personal marketplace is deployed")
@@ -422,8 +423,8 @@ function removeLegacyDocksMarketplace(ctx: Ctx, userConfig: string): void {
422
423
 
423
424
  const source = marketplaceSource("docks", userConfig)
424
425
  if (source !== "https://github.com/DocksDocks/docks.git" && source !== "DocksDocks/docks") return
425
- const res = spawnSync("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
426
- if (res.error === undefined && res.status === 0) {
426
+ const res = await spawnProcess("codex", ["plugin", "marketplace", "remove", "docks"], { stdio: "ignore" })
427
+ if (res.error === undefined && res.exitCode === 0) {
427
428
  change("Removed legacy configured Codex Docks marketplace; using personal marketplace file")
428
429
  ctx.nextStepTriggers.codexRestart = true
429
430
  } else {
@@ -474,13 +475,12 @@ function manualPluginRefreshCommand(sotConfigText: string): string {
474
475
  return first !== undefined ? `codex plugin add ${first}` : "codex plugin add <plugin@marketplace>"
475
476
  }
476
477
 
477
- function installedPluginIdsFromCli(): Set<string> | undefined {
478
- const result = spawnSync("codex", ["plugin", "list", "--json"], {
479
- encoding: "utf8",
478
+ async function installedPluginIdsFromCli(): Promise<Set<string> | undefined> {
479
+ const result = await spawnProcess("codex", ["plugin", "list", "--json"], {
480
480
  stdio: ["ignore", "pipe", "ignore"]
481
481
  })
482
- if (result.error !== undefined || result.status !== 0) return undefined
483
- const value = parseJson(result.stdout ?? "")
482
+ if (result.error !== undefined || result.exitCode !== 0) return undefined
483
+ const value = parseJson(result.stdout)
484
484
  if (value === undefined || !isObject(value) || !Array.isArray(value["installed"])) return undefined
485
485
  const ids = new Set<string>()
486
486
  for (const row of value["installed"]) {
@@ -490,7 +490,7 @@ function installedPluginIdsFromCli(): Set<string> | undefined {
490
490
  return ids
491
491
  }
492
492
 
493
- function syncPlugins(ctx: Ctx, sotConfigText: string): void {
493
+ async function syncPlugins(ctx: Ctx, sotConfigText: string): Promise<void> {
494
494
  const { change, clearProgress, echo, progress, verbose, warn } = ctx.services.logger
495
495
  if (ctx.dryRun) {
496
496
  echo(
@@ -520,7 +520,7 @@ function syncPlugins(ctx: Ctx, sotConfigText: string): void {
520
520
  let pluginIds = desiredPluginIds
521
521
  if (ctx.skipPluginRefresh) {
522
522
  progress("Checking installed Codex plugins...")
523
- const installedPluginIds = installedPluginIdsFromCli()
523
+ const installedPluginIds = await installedPluginIdsFromCli()
524
524
  clearProgress()
525
525
  if (installedPluginIds === undefined) {
526
526
  warn("Codex plugin inventory unavailable — falling back to the full refresh path")
@@ -533,10 +533,10 @@ function syncPlugins(ctx: Ctx, sotConfigText: string): void {
533
533
  let failed = 0
534
534
  for (const pluginId of pluginIds) {
535
535
  progress(`Updating Codex plugin ${pluginId}...`)
536
- const res = spawnSync("codex", ["plugin", "add", pluginId], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] })
536
+ const res = await spawnProcess("codex", ["plugin", "add", pluginId], { stdio: ["ignore", "pipe", "pipe"] })
537
537
  clearProgress()
538
- const addOut = `${res.stdout ?? ""}${res.stderr ?? ""}`
539
- if (res.error === undefined && res.status === 0) {
538
+ const addOut = `${res.stdout}${res.stderr}`
539
+ if (res.error === undefined && res.exitCode === 0) {
540
540
  refreshed++
541
541
  } else if (addOut.includes("could not find a Codex CLI binary")) {
542
542
  warn(
@@ -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