fix(setup): spawn hostExec so step spinners animate during skill steps

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 <code>: <first stderr line>` first, full stderr below.

Also retitles operator notes "Do this" -> "Your turn".
This commit is contained in:
Koshkoshinsk
2026-07-07 14:40:32 +03:00
parent 405643f8a7
commit ee1d2773c2
2 changed files with 34 additions and 29 deletions
+8 -8
View File
@@ -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 <code>: <first stderr line>` with the full stderr kept below', () => {
it('hostExec recomposes a failure as `exit <code>: <first stderr line>` 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<string> => 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 () => {
+26 -21
View File
@@ -244,29 +244,34 @@ async function reuseFromEnv(
* `run capture:<var>` 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 <code>: <first stderr line>` — 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<string> {
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 <code>: <first stderr line>` — 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: <cmd>", 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<string | undefined>;
/** Defaults to `hostExec`. */
exec?: (cmd: string) => string | void;
exec?: (cmd: string) => string | void | Promise<string | void>;
/** Defaults to `hostExecStream`. Streaming exec for `nc:run effect:step`. */
execStream?: (cmd: string) => Promise<StepOutcome>;
/** Defaults to the fork-aware channels-branch resolver. */