From 36c2f18cf88833f84f4984f340a8ecfa3e5dcdbb Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Wed, 22 Apr 2026 23:51:35 -0400 Subject: [PATCH 1/2] Replace misleading sync count + add ora spinner during sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled UX bugs in the mount-path session exit: 1. "Synced 15 change(s) back to the repo" was misleading. Per @relayfile/local-mount's onAfterSync contract (launch.d.ts:36-40), the count is *bidirectional* — it sums autosync activity in both directions (inbound project→mount AND outbound mount→project, including deletes) plus the final mount→project syncBack. Phrasing it as "synced back to the repo" overclaimed direction: a session where the agent wrote nothing still reported "15 changes back" because ambient initial-mirror / inbound scan events counted. Rephrase to "N file event(s) during session" so the line does not lie about which way files moved. 2. No visual indicator during the sync pause. First Ctrl-C previously printed a static "⏳ Syncing…" line and then the terminal looked frozen for several seconds while autosync.stop() + final syncBack() walked the trees. Wire ora (sindresorhus/ora v9.4, ESM, Node 20+) as an animated spinner whose lifecycle is: 1st Ctrl-C → spinner.start with "Syncing… (Ctrl-C again to skip)" 2nd Ctrl-C → spinner.text updated to the aborting-warning form + shutdownSignal.abort() 3rd Ctrl-C → spinner.fail, then force-exit (existing behavior) onAfterSync → spinner.succeed with the honest event-count line catch branch → spinner.fail + fall through to existing error msg finally → defensive spinner.stop if none of the above ran Scope limit: the spinner only engages on the SIGINT path. The natural-exit path (agent quits via :q) has no hook between child-exit and sync-start in launchOnMount 0.5.0, so we cannot start a spinner there without patching relayfile upstream. Sync on natural exit is typically fast; the honest count line is the main UX win there. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/cli/package.json | 3 +- packages/cli/src/cli.ts | 72 +++++++++++++++++++++++++++++++-------- pnpm-lock.yaml | 63 ++++++++++++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 15 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 16c9b8b2..3b7c3b63 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 e3bd4986..d85f8dd1 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/pnpm-lock.yaml b/pnpm-lock.yaml index d9600be7..64cf36cb 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 From 63c6556e56f14fe01424d347879b85c212b68e3e Mon Sep 17 00:00:00 2001 From: Will Washburn Date: Thu, 23 Apr 2026 00:14:17 -0400 Subject: [PATCH 2/2] Default opencode agent to permission: 'allow' so it can edit files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Symptom: "the persona agent was not able to make any changes to the codebase." The emitted opencode.json defined model + prompt + mode but omitted the permission field, so opencode applied its restrictive default — blocking edit/write/bash across every tool. Autosync then had nothing outbound to propagate on exit, which is why the session- complete line showed file events but no actual repo changes landed. `opencode agent list` for the built-in `build` agent shows explicit wildcard-allow rules (`permission: '*', action: 'allow', pattern: '*'`) — that is how `build` earns its permissions, not a default. Matching the same shape for persona-generated agents via `permission: 'allow'` (the string form per the PermissionConfig schema at https://opencode.ai/config.json). The mount already sandboxes writes (autosync gates sync-back via ignoredPatterns + shutdown semantics) so wildcard-allow does not escape the session boundary outside of normal outbound propagation. Callers who want a read-only persona (e.g. a code reviewer) can refine this in a follow-up once the workforce PersonaPermissions shape grows opencode support — the harness-kit already warns that "opencode harness is not yet wired for runtime permission injection" so this is consistent with current scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/harness-kit/src/harness.test.ts | 7 ++++++- packages/harness-kit/src/harness.ts | 15 ++++++++++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/harness-kit/src/harness.test.ts b/packages/harness-kit/src/harness.test.ts index d396b8da..a30b4bef 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 1dda75c8..f0495480 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' } } };