From ee1d2773c2b2f9682660367ee5df230fbeb8f809 Mon Sep 17 00:00:00 2001 From: Koshkoshinsk Date: Tue, 7 Jul 2026 14:40:32 +0300 Subject: [PATCH] fix(setup): spawn hostExec so step spinners animate during skill steps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execSync blocks the event loop for the whole command, freezing every clack ticker in the process — skill steps ran with a dead spinner while earlier wizard phases (async spawn) animated fine. Spawn + promise keeps the loop free; the engine's exec seam already awaits. The failure shape is unchanged: `exit : ` first, full stderr below. Also retitles operator notes "Do this" -> "Your turn". --- setup/lib/skill-driver.test.ts | 16 ++++++------ setup/lib/skill-driver.ts | 47 +++++++++++++++++++--------------- 2 files changed, 34 insertions(+), 29 deletions(-) diff --git a/setup/lib/skill-driver.test.ts b/setup/lib/skill-driver.test.ts index 9652f3918..00005b44a 100644 --- a/setup/lib/skill-driver.test.ts +++ b/setup/lib/skill-driver.test.ts @@ -118,25 +118,25 @@ describe('thin skill driver', () => { expect(starts).toEqual([{ kind: 'run', label: 'Wire' }]); }); - it('hostExec puts the project bin/ on PATH so a bare command resolves to it', () => { + it('hostExec puts the project bin/ on PATH so a bare command resolves to it', async () => { const root = mkdtempSync(join(tmpdir(), 'driver-bin-')); mkdirSync(join(root, 'bin')); writeFileSync(join(root, 'bin/greet'), '#!/usr/bin/env bash\necho hi-from-bin\n'); chmodSync(join(root, 'bin/greet'), 0o755); - const out = hostExec(root)('greet'); // bare name, not ./bin/greet + const out = await hostExec(root)('greet'); // bare name, not ./bin/greet expect(String(out).trim()).toBe('hi-from-bin'); }); - it('hostExec returns stdout so a capture run can bind it', () => { + it('hostExec returns stdout so a capture run can bind it', async () => { const root = mkdtempSync(join(tmpdir(), 'driver-cap-')); - expect(String(hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL'); + expect(String(await hostExec(root)('echo D0CHANNEL')).trim()).toBe('D0CHANNEL'); }); - it('hostExec recomposes a failure as `exit : ` with the full stderr kept below', () => { + it('hostExec recomposes a failure as `exit : ` with the full stderr kept below', async () => { const root = mkdtempSync(join(tmpdir(), 'driver-fail-')); - const run = (): string => hostExec(root)('echo "boom: first line" >&2; echo "stack line two" >&2; exit 7'); - expect(run).toThrow(/^exit 7: boom: first line/); // one-line consumers read this - expect(run).toThrow(/stack line two/); // full stderr survives for the agentTask reason + const run = (): Promise => hostExec(root)('echo "boom: first line" >&2; echo "stack line two" >&2; exit 7'); + await expect(run).rejects.toThrow(/^exit 7: boom: first line/); // one-line consumers read this + await expect(run).rejects.toThrow(/stack line two/); // full stderr survives for the agentTask reason }); it('hostExecStream runs a step and captures the terminal status block fields (for effect:step)', async () => { diff --git a/setup/lib/skill-driver.ts b/setup/lib/skill-driver.ts index 6b8baeb17..086d7b374 100644 --- a/setup/lib/skill-driver.ts +++ b/setup/lib/skill-driver.ts @@ -244,29 +244,34 @@ async function reuseFromEnv( * `run capture:` can bind it. Puts the project's `bin/` on PATH so a bare * `ncl …` in a wire directive resolves to `bin/ncl` even when it isn't * symlinked onto the operator's PATH. + * + * Async (spawn, not execSync) so the step spinner keeps animating: a sync exec + * blocks the event loop for the whole command and freezes every ticker in the + * process. A failure rejects with the FIRST line as the actionable summary — + * `exit : ` — and the full stderr kept below, so + * one-line consumers (run-channel-skill's bounce warn) stay readable while the + * agentTask reason an agent fixes from still carries everything. */ -export function hostExec(projectRoot: string): (cmd: string) => string { - return (cmd) => { - try { - return execSync(cmd, { +export function hostExec(projectRoot: string): (cmd: string) => Promise { + return (cmd) => + new Promise((resolve, reject) => { + const child = spawn('bash', ['-c', cmd], { cwd: projectRoot, - shell: '/bin/bash', - encoding: 'utf8', env: { ...process.env, PATH: `${join(projectRoot, 'bin')}:${process.env.PATH ?? ''}` }, + stdio: ['ignore', 'pipe', 'pipe'], }); - } catch (e) { - // Recompose the failure so its FIRST line is the actionable summary — - // `exit : ` — with the full stderr kept below. - // One-line consumers (run-channel-skill's bounce warn) stay readable; - // the agentTask reason an agent fixes from still carries everything. - // (execSync's own message leads with "Command failed: ", burying - // the error under the command text and a stack.) - const err = e as Partial<{ status: number | null; stderr: string | Buffer; message: string }>; - const stderr = String(err.stderr ?? '').trim(); - const head = stderr.split('\n').map((l) => l.trim()).find(Boolean) ?? (err.message ?? String(e)).split('\n')[0]; - throw new Error(`exit ${err.status ?? '?'}: ${head}${stderr ? `\n${stderr}` : ''}`); - } - }; + let out = ''; + let err = ''; + child.stdout.on('data', (c: Buffer) => { out += c.toString('utf8'); }); + child.stderr.on('data', (c: Buffer) => { err += c.toString('utf8'); }); + child.on('error', reject); + child.on('close', (code) => { + if (code === 0) return resolve(out); + const stderr = err.trim(); + const head = stderr.split('\n').map((l) => l.trim()).find(Boolean) ?? 'command failed'; + reject(new Error(`exit ${code ?? '?'}: ${head}${stderr ? `\n${stderr}` : ''}`)); + }); + }); } /** @@ -398,7 +403,7 @@ function defaultOnEvent( return; } // operator: note → URL offer → natural-barrier confirm. - p.note(e.text, 'Do this'); + p.note(e.text, 'Your turn'); const url = extractOfferUrl(e.text); if (url !== undefined && (await confirm(`Open ${url} in your browser?`))) await open(url); const gate = gates.get(e.line); @@ -429,7 +434,7 @@ export interface RunSkillOptions { */ resolveInput?: (name: string, meta: InputMeta) => Promise; /** Defaults to `hostExec`. */ - exec?: (cmd: string) => string | void; + exec?: (cmd: string) => string | void | Promise; /** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */ execStream?: (cmd: string) => Promise; /** Defaults to the fork-aware channels-branch resolver. */