diff --git a/packages/cli/package.json b/packages/cli/package.json index 16c9b8b..3b7c3b6 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -15,7 +15,8 @@ "dependencies": { "@agentworkforce/harness-kit": "workspace:*", "@agentworkforce/workload-router": "workspace:*", - "@relayfile/local-mount": "^0.5.0" + "@relayfile/local-mount": "^0.5.0", + "ora": "^9.4.0" }, "repository": { "type": "git", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e3bd498..d85f8dd 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -30,6 +30,7 @@ import { type InteractiveSpec } from '@agentworkforce/harness-kit'; import { launchOnMount } from '@relayfile/local-mount'; +import ora, { type Ora } from 'ora'; import { loadLocalPersonas, type PersonaSource } from './local-personas.js'; const USAGE = `Usage: agent-workforce [args...] @@ -625,8 +626,12 @@ async function runInteractive( // both trees and can take several seconds on a large repo — during // which the terminal otherwise looks frozen. // - // 1st press → announce "syncing…" so the user knows the pause is real. - // 2nd press → abort `shutdownSignal`, which local-mount 0.5+ respects + // 1st press → start an ora spinner so the pause is visibly live + // (replaces the prior static print). onAfterSync below + // transitions the spinner into a succeed/fail state once + // relayfile reports the sync result. + // 2nd press → update the spinner text to the "aborting" warning and + // abort `shutdownSignal`, which local-mount 0.5+ respects // by skipping autosync's draining reconcile and returning // the partial count from the final syncBack. Cleanup // still runs, so no leaked mount dir. @@ -634,24 +639,32 @@ async function runInteractive( // process.exit(130) in case the abort never resolves. const shutdownController = new AbortController(); let sigintCount = 0; + let syncSpinner: Ora | undefined; const forceExitHandler = () => { sigintCount += 1; if (sigintCount === 1) { - process.stderr.write( - '\n⏳ Syncing session changes back to the repo… (press Ctrl-C again to skip sync)\n' - ); + syncSpinner = ora({ + text: 'Syncing session changes back to the repo… (Ctrl-C again to skip)', + stream: process.stderr + }).start(); return; } if (sigintCount === 2) { - process.stderr.write( - '\n⚠ Aborting sync — partial changes will be propagated. (press Ctrl-C again to force quit)\n' - ); + if (syncSpinner) { + syncSpinner.text = + 'Aborting sync — partial changes will be propagated. (Ctrl-C again to force quit)'; + } shutdownController.abort(); return; } - process.stderr.write( - '\n✗ Force-quit: mount teardown skipped. Session dir may be left behind.\n' - ); + if (syncSpinner) { + syncSpinner.fail('Force-quit: mount teardown skipped. Session dir may be left behind.'); + syncSpinner = undefined; + } else { + process.stderr.write( + '\n✗ Force-quit: mount teardown skipped. Session dir may be left behind.\n' + ); + } // Node-native removal rather than `rm -rf` so the emergency path // works on Windows too. try { @@ -680,10 +693,28 @@ async function runInteractive( shutdownSignal: shutdownController.signal, // Report sync stats so the user sees confirmation rather than a // silent pause between the child exiting and the CLI returning. + // + // NOTE: `count` is bidirectional per relayfile's onAfterSync + // contract (see @relayfile/local-mount launch.d.ts) — it sums + // autosync activity in *both* directions (inbound project→mount + // and outbound mount→project, including deletes) plus the final + // mount→project syncBack. Phrasing this as "synced back to the + // repo" earlier misled sessions where inbound events dominated: + // a user who did no edits still saw "Synced 15 changes back" + // because ambient initial-mirror traffic counted. Phrase as "file + // events during session" so we don't overclaim direction. onAfterSync: (count) => { - if (count > 0) { - const qualifier = shutdownController.signal.aborted ? ' (partial)' : ''; - process.stderr.write(`✓ Synced ${count} change(s) back to the repo${qualifier}.\n`); + const aborted = shutdownController.signal.aborted; + const qualifier = aborted ? ' (partial)' : ''; + const message = + count > 0 + ? `Session complete — ${count} file event${count === 1 ? '' : 's'} during session${qualifier}.` + : 'Session complete — no file events.'; + if (syncSpinner) { + syncSpinner.succeed(message); + syncSpinner = undefined; + } else { + process.stderr.write(`✓ ${message}\n`); } }, ...(deferInstallToMount || hasConfigFiles @@ -707,6 +738,12 @@ async function runInteractive( }); return result.exitCode; } catch (err) { + // If the spinner is still live when we error out, mark it failed so + // the pending animation doesn't hang around under the error message. + if (syncSpinner) { + syncSpinner.fail('Sync did not complete'); + syncSpinner = undefined; + } // InstallCommandError carries the real install exit code — surfacing // it (rather than collapsing onto 127) lets callers distinguish a // failed `npx prpm install` from a missing harness binary. @@ -724,6 +761,13 @@ async function runInteractive( process.stderr.write(`Failed to launch sandbox mount: ${e.message}\n`); return 1; } finally { + // Defensive: if neither onAfterSync nor the catch branch stopped the + // spinner (e.g. unexpected exit path), stop it cleanly here so the + // terminal is not left in spinner state. + if (syncSpinner) { + syncSpinner.stop(); + syncSpinner = undefined; + } process.removeListener('SIGINT', forceExitHandler); // When the install ran inside the mount, its cleanup paths are // mount-relative (e.g. `.skills/`, `skills/`) and diff --git a/packages/harness-kit/src/harness.test.ts b/packages/harness-kit/src/harness.test.ts index d396b8d..a30b4be 100644 --- a/packages/harness-kit/src/harness.test.ts +++ b/packages/harness-kit/src/harness.test.ts @@ -149,7 +149,12 @@ test('opencode configFiles carries a well-formed opencode.json with the agent de 'test-persona': { model: 'opencode/minimax-m2.5', prompt: 'you are a test', - mode: 'primary' + mode: 'primary', + // Wildcard-allow across opencode's tool set — matches the built-in + // `build` agent. Without this, opencode's restrictive default kept + // agents from making any edits and autosync had nothing to + // propagate on exit. + permission: 'allow' } } }); diff --git a/packages/harness-kit/src/harness.ts b/packages/harness-kit/src/harness.ts index 1dda75c..f049548 100644 --- a/packages/harness-kit/src/harness.ts +++ b/packages/harness-kit/src/harness.ts @@ -194,12 +194,25 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact // with the persona's prompt + full-provider-form model, selected via // `--agent ` at launch. We emit that file via configFiles // so the CLI can drop it into the mount dir before exec. + // `permission: 'allow'` is wildcard-allow across every opencode tool + // (read / edit / bash / webfetch / etc.), matching the built-in + // `build` agent's effective permissions. Without this, opencode + // applies its restrictive default and agent-side edits never reach + // the mount (the user-visible symptom: "I asked the agent to change + // files and nothing synced"). The mount already sandboxes writes + // so wildcard-allow does not escape to the real repo outside of + // autosync, and callers who want a read-only persona (e.g. a code + // reviewer) can override this in a follow-up PR that threads a + // richer permission spec through the persona config — the current + // harness-kit PersonaPermissions shape is claude-specific and + // already warned about for opencode. const agentConfig = { agent: { [personaId]: { model, prompt: systemPrompt, - mode: 'primary' + mode: 'primary', + permission: 'allow' } } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9600be..64cf36c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@relayfile/local-mount': specifier: ^0.5.0 version: 0.5.0 + ora: + specifier: ^9.4.0 + version: 9.4.0 packages/harness-kit: dependencies: @@ -215,6 +218,10 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chalk@5.6.2: + resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} @@ -223,6 +230,10 @@ packages: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} + cli-spinners@3.4.0: + resolution: {integrity: sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==} + engines: {node: '>=18.20'} + cli-truncate@5.2.0: resolution: {integrity: sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==} engines: {node: '>=20'} @@ -276,10 +287,22 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + listr2@10.2.1: resolution: {integrity: sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==} engines: {node: '>=22.13.0'} + log-symbols@7.0.1: + resolution: {integrity: sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==} + engines: {node: '>=18'} + log-update@6.1.0: resolution: {integrity: sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==} engines: {node: '>=18'} @@ -303,6 +326,10 @@ packages: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} + ora@9.4.0: + resolution: {integrity: sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==} + engines: {node: '>=20'} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -332,6 +359,10 @@ packages: resolution: {integrity: sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==} engines: {node: '>=20'} + stdin-discarder@0.3.2: + resolution: {integrity: sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==} + engines: {node: '>=18'} + string-width@7.2.0: resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} engines: {node: '>=18'} @@ -389,6 +420,10 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yoctocolors@2.1.2: + resolution: {integrity: sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==} + engines: {node: '>=18'} + zod-to-json-schema@3.25.2: resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} peerDependencies: @@ -543,12 +578,16 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chalk@5.6.2: {} + chownr@3.0.0: {} cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 + cli-spinners@3.4.0: {} + cli-truncate@5.2.0: dependencies: slice-ansi: 8.0.0 @@ -586,6 +625,10 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-interactive@2.0.0: {} + + is-unicode-supported@2.1.0: {} + listr2@10.2.1: dependencies: cli-truncate: 5.2.0 @@ -594,6 +637,11 @@ snapshots: rfdc: 1.4.1 wrap-ansi: 10.0.0 + log-symbols@7.0.1: + dependencies: + is-unicode-supported: 2.1.0 + yoctocolors: 2.1.2 + log-update@6.1.0: dependencies: ansi-escapes: 7.3.0 @@ -616,6 +664,17 @@ snapshots: dependencies: mimic-function: 5.0.1 + ora@9.4.0: + dependencies: + chalk: 5.6.2 + cli-cursor: 5.0.0 + cli-spinners: 3.4.0 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 7.0.1 + stdin-discarder: 0.3.2 + string-width: 8.2.0 + picocolors@1.1.1: {} picomatch@4.0.4: {} @@ -641,6 +700,8 @@ snapshots: ansi-styles: 6.2.3 is-fullwidth-code-point: 5.1.0 + stdin-discarder@0.3.2: {} + string-width@7.2.0: dependencies: emoji-regex: 10.6.0 @@ -690,6 +751,8 @@ snapshots: yaml@2.8.3: {} + yoctocolors@2.1.2: {} + zod-to-json-schema@3.25.2(zod@3.25.76): dependencies: zod: 3.25.76