Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 58 additions & 14 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [args...]
Expand Down Expand Up @@ -625,33 +626,45 @@ 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.
// 3rd press → hard escape: synchronously rm the mount root and
// 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 {
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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/<name>`, `skills/<name>`) and
Expand Down
7 changes: 6 additions & 1 deletion packages/harness-kit/src/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
}
});
Expand Down
15 changes: 14 additions & 1 deletion packages/harness-kit/src/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,25 @@ export function buildInteractiveSpec(input: BuildInteractiveSpecInput): Interact
// with the persona's prompt + full-provider-form model, selected via
// `--agent <personaId>` 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'
}
}
};
Expand Down
63 changes: 63 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading