@jmoyers/harness 0.1.1 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +125 -8
  2. package/package.json +19 -11
  3. package/scripts/bun-runtime-guard.js +35 -0
  4. package/scripts/control-plane-daemon.ts +29 -6
  5. package/scripts/harness-bin.js +13 -2
  6. package/scripts/harness.ts +59 -20
  7. package/scripts/require-bun.js +7 -0
  8. package/src/adapters/agent-session-state.ts +3 -3
  9. package/src/cli/gateway-record.ts +12 -6
  10. package/src/config/config-core.ts +155 -27
  11. package/src/config/harness-paths.ts +92 -0
  12. package/src/config/harness-runtime-migration.ts +130 -0
  13. package/src/config/harness.config.template.jsonc +10 -2
  14. package/src/config/secrets-core.ts +27 -9
  15. package/src/control-plane/codex-telemetry.ts +68 -18
  16. package/src/control-plane/status/reducer-base.ts +4 -1
  17. package/src/control-plane/stream-command-parser.ts +23 -0
  18. package/src/control-plane/stream-protocol.ts +6 -0
  19. package/src/control-plane/stream-server-command.ts +12 -0
  20. package/src/control-plane/stream-server-session-runtime.ts +16 -2
  21. package/src/control-plane/stream-server.ts +155 -19
  22. package/src/mux/live-mux/args.ts +17 -4
  23. package/src/mux/live-mux/command-menu.ts +44 -3
  24. package/src/mux/live-mux/conversation-state.ts +1 -0
  25. package/src/mux/live-mux/critique-review.ts +101 -0
  26. package/src/mux/live-mux/gateway-profiler.ts +67 -4
  27. package/src/mux/live-mux/git-parsing.ts +1 -1
  28. package/src/mux/live-mux/modal-command-menu-handler.ts +65 -16
  29. package/src/mux/live-mux/modal-overlays.ts +18 -10
  30. package/src/mux/live-mux/palette-parsing.ts +4 -1
  31. package/src/mux/live-mux/render-trace-state.ts +10 -11
  32. package/src/mux/live-mux/startup-utils.ts +5 -1
  33. package/src/mux/live-mux/status-timeline-state.ts +10 -20
  34. package/src/perf/perf-core.ts +8 -1
  35. package/src/recording/terminal-recording.ts +11 -3
  36. package/src/services/runtime-command-menu-agent-tools.ts +128 -0
  37. package/src/services/runtime-process-wiring.ts +3 -2
  38. package/src/services/startup-state-hydration.ts +0 -5
  39. package/src/terminal/compat-matrix.ts +3 -4
  40. package/src/terminal/snapshot-oracle.ts +219 -20
  41. package/src/ui/mux-theme-presets.json +2499 -0
  42. package/src/ui/mux-theme-presets.ts +29 -0
  43. package/src/ui/mux-theme.ts +16 -107
package/README.md CHANGED
@@ -6,15 +6,18 @@ Use it when you want to move faster than a single chat window: keep multiple thr
6
6
 
7
7
  ## Why teams use it
8
8
 
9
- - Run multiple threads in parallel (`codex`, `claude`, `cursor`, `terminal`, `critique`).
10
- - Keep work scoped to the right project/repo context.
11
- - Reconnect without losing long-running sessions.
12
- - Move quickly between implementation and review loops.
13
- - Open repo/PR actions directly from the same workflow.
9
+ - Run many agent threads in parallel across `codex`, `claude`, `cursor`, `terminal`, and `critique`.
10
+ - Keep native CLI ergonomics in one keyboard-first workspace.
11
+ - Keep long-running threads alive in the detached gateway so reconnects do not kill work.
12
+ - Open a command palette with `ctrl+p`/`cmd+p`, live-filter registered actions, and execute context-aware thread/project/runtime controls.
13
+ - Open a thread-scoped command palette from left-rail `[+ thread]` (same matcher/autocomplete as `ctrl+p`) to start/install agent CLIs per project.
14
+ - Start in the Home pane by default, then jump to projects/threads from the left rail.
15
+ - Open `Set a Theme` from the command palette to launch a second autocomplete picker of canonical OpenCode presets (plus a `default` reset option), with live preview while you navigate.
16
+ - Open or create a GitHub pull request for the currently tracked project branch directly from the command palette.
14
17
 
15
18
  ## Demo
16
19
 
17
- ![Harness multi-thread recording](assets/poem-recording.gif)
20
+ ![Harness multi-thread recording](https://raw.githubusercontent.com/jmoyers/harness/main/assets/poem-recording.gif)
18
21
 
19
22
  ## Quick start
20
23
 
@@ -24,7 +27,22 @@ Use it when you want to move faster than a single chat window: keep multiple thr
24
27
  - Rust toolchain
25
28
  - At least one installed agent CLI (`codex`, `claude`, `cursor`, or `critique`)
26
29
 
27
- ### Install
30
+ ### Install (npm package)
31
+
32
+ > Note: Harness requires Bun. It does not work with Node.js alone.
33
+
34
+ ```bash
35
+ # One-line bootstrap (installs missing Bun/Rust deps, then installs Harness globally)
36
+ curl -fsSL https://raw.githubusercontent.com/jmoyers/harness/main/install.sh | bash
37
+
38
+ # Run directly with bunx (no install needed)
39
+ bunx @jmoyers/harness@latest
40
+
41
+ # Or install globally
42
+ bun add -g --trust @jmoyers/harness
43
+ ```
44
+
45
+ ### Install (from source)
28
46
 
29
47
  ```bash
30
48
  bun install
@@ -50,9 +68,108 @@ harness --session my-session
50
68
  3. Use the command palette (`ctrl+p` / `cmd+p`) to jump, run actions, and manage project context.
51
69
  4. Open the repo or PR actions from inside Harness when GitHub auth is available.
52
70
 
71
+ ## Critique threads
72
+
73
+ - Available in the thread-scoped command palette (`[+ thread]`).
74
+ - Runs with `--watch` by default.
75
+ - Install actions are availability-aware and config-driven (`*.install.command`), opening a terminal thread to run the configured install command when a tool is missing.
76
+ - Global command palette (`ctrl+p` / `cmd+p`) includes:
77
+ - `Critique AI Review: Staged Changes`
78
+ - `Critique AI Review: Current Branch vs Base`
79
+ - These start a terminal thread and run `critique review ...`, preferring `claude` when available and otherwise using `opencode` when installed.
80
+ - `mux.conversation.critique.open-or-create` is bound to `ctrl+g` by default.
81
+
82
+ `ctrl+g` behavior is project-aware:
83
+
84
+ - If a critique thread exists for the current project, it selects it.
85
+ - If not, it creates and opens one in the main pane.
86
+
87
+ `session.interrupt` is also surfaced as a mux keybinding action (`mux.conversation.interrupt`) so teams can bind a dedicated in-client thread interrupt shortcut without overloading quit semantics.
88
+
89
+ ## GitHub PR Integration
90
+
91
+ When GitHub auth is available (`GITHUB_TOKEN` or an authenticated `gh` CLI), Harness can:
92
+
93
+ - Detect the tracked branch for the active project and show `Open PR` (if an open PR exists) or `Create PR` in the command palette.
94
+ - Continuously sync open PR CI/check status into the control-plane store for realtime clients.
95
+ - If auth is unavailable, PR actions fail quietly and show a lightweight hint instead of surfacing hard errors.
96
+
97
+ ## API for Automation
98
+
99
+ Harness exposes a typed realtime client for orchestrators, policy agents, and dashboards:
100
+
101
+ ```ts
102
+ import { connectHarnessAgentRealtimeClient } from './src/control-plane/agent-realtime-api.ts';
103
+
104
+ const client = await connectHarnessAgentRealtimeClient({
105
+ host: '127.0.0.1',
106
+ port: 7777,
107
+ subscription: { includeOutput: false },
108
+ });
109
+
110
+ client.on('session.status', ({ observed }) => {
111
+ console.log(observed.sessionId, observed.status);
112
+ });
113
+
114
+ await client.close();
115
+ ```
116
+
117
+ Key orchestration calls are available in the same client:
118
+
119
+ - `client.tasks.pull(...)`
120
+ - `client.projects.status(projectId)`
121
+ - `client.projects.settings.get(projectId)` / `client.projects.settings.update(projectId, update)`
122
+ - `client.automation.getPolicy(...)` / `client.automation.setPolicy(...)`
123
+
53
124
  ## Configuration
54
125
 
55
- Harness is config-first via `harness.config.jsonc` and bootstraps it automatically on first run.
126
+ Runtime behavior is config-first via `harness.config.jsonc`.
127
+ GitHub project/PR integration is enabled by default and configured under `github.*`.
128
+
129
+ Example (install commands + critique defaults + hotkey override + OpenCode theme selection):
130
+
131
+ ```jsonc
132
+ {
133
+ "codex": {
134
+ "install": {
135
+ "command": "bunx @openai/codex@latest"
136
+ }
137
+ },
138
+ "claude": {
139
+ "install": {
140
+ "command": "bunx @anthropic-ai/claude-code@latest"
141
+ }
142
+ },
143
+ "cursor": {
144
+ "install": {
145
+ "command": null
146
+ }
147
+ },
148
+ "critique": {
149
+ "launch": {
150
+ "defaultArgs": ["--watch"]
151
+ },
152
+ "install": {
153
+ "command": "bun add --global critique@latest"
154
+ }
155
+ },
156
+ "mux": {
157
+ "ui": {
158
+ "theme": {
159
+ "preset": "tokyonight",
160
+ "mode": "dark",
161
+ "customThemePath": null
162
+ }
163
+ },
164
+ "keybindings": {
165
+ "mux.conversation.critique.open-or-create": ["ctrl+g"]
166
+ }
167
+ }
168
+ }
169
+ ```
170
+
171
+ `mux.ui.theme.customThemePath` can point to any local JSON file that follows the OpenCode theme schema (`https://opencode.ai/theme.json`).
172
+ Built-in presets now mirror the canonical OpenCode set (for example `aura`, `ayu`, `carbonfox`, `catppuccin`, `dracula`, `everforest`, `github`, `gruvbox`, `nightowl`, `nord`, `one-dark`, `opencode`, `tokyonight`, `vesper`, `zenburn`, and more), plus a special `default` picker option for the legacy default mux theme.
56
173
 
57
174
  ## Documentation
58
175
 
package/package.json CHANGED
@@ -1,14 +1,17 @@
1
1
  {
2
2
  "name": "@jmoyers/harness",
3
- "version": "0.1.1",
3
+ "version": "0.1.6",
4
4
  "private": false,
5
- "type": "module",
6
- "packageManager": "bun@1.3.9",
7
- "publishConfig": {
8
- "access": "public"
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/jmoyers/harness"
8
+ },
9
+ "bin": {
10
+ "harness": "scripts/harness-bin.js"
9
11
  },
10
12
  "files": [
11
13
  "src",
14
+ "scripts/bun-runtime-guard.js",
12
15
  "scripts/build-ptyd.sh",
13
16
  "scripts/control-plane-daemon.ts",
14
17
  "scripts/cursor-hook-relay.ts",
@@ -17,20 +20,23 @@
17
20
  "scripts/harness-core.ts",
18
21
  "scripts/harness-inspector.ts",
19
22
  "scripts/harness.ts",
23
+ "scripts/require-bun.js",
20
24
  "native/ptyd/Cargo.lock",
21
25
  "native/ptyd/Cargo.toml",
22
26
  "native/ptyd/src",
23
27
  "README.md",
24
28
  "LICENSE"
25
29
  ],
26
- "bin": {
27
- "harness": "scripts/harness-bin.js"
30
+ "type": "module",
31
+ "publishConfig": {
32
+ "access": "public"
28
33
  },
29
34
  "scripts": {
30
35
  "migrate:bun": "bash ./scripts/migrate-to-bun.sh",
31
36
  "harness": "bun scripts/harness.ts",
32
37
  "harness:core": "bun scripts/harness-core.ts",
33
38
  "build:ptyd": "bash ./scripts/build-ptyd.sh",
39
+ "preinstall": "node scripts/require-bun.js",
34
40
  "postinstall": "bun run build:ptyd",
35
41
  "codex:live": "bun scripts/codex-live.ts",
36
42
  "codex:pty:vte:passthrough": "bun scripts/codex-pty-vte-passthrough.ts",
@@ -74,17 +80,19 @@
74
80
  "verify": "bun run format:check && bun run lint && bun run typecheck && bun run deadcode && bun run test:coverage",
75
81
  "verify:strict": "bun run verify && bun run loc:verify:enforce"
76
82
  },
83
+ "dependencies": {
84
+ "@napi-rs/canvas": "^0.1.92",
85
+ "gifenc": "^1.0.3"
86
+ },
77
87
  "devDependencies": {
78
88
  "@ai-sdk/anthropic": "^3.0.45",
79
89
  "@types/node": "^22.17.0",
80
90
  "ai": "^6.0.91",
91
+ "fast-check": "^4.5.3",
81
92
  "oxfmt": "^0.33.0",
82
93
  "oxlint": "^1.48.0",
83
94
  "typescript": "^5.9.2",
84
95
  "zod": "^4.3.6"
85
96
  },
86
- "dependencies": {
87
- "@napi-rs/canvas": "^0.1.92",
88
- "gifenc": "^1.0.3"
89
- }
97
+ "packageManager": "bun@1.3.9"
90
98
  }
@@ -0,0 +1,35 @@
1
+ import { spawnSync } from 'node:child_process';
2
+
3
+ export const BUN_INSTALL_DOCS_URL = 'https://bun.sh/docs/installation';
4
+
5
+ export function formatBunRequiredMessage() {
6
+ return [
7
+ '[harness] Bun is required to install and run Harness.',
8
+ `[harness] install Bun: ${BUN_INSTALL_DOCS_URL}`,
9
+ '[harness] then verify: bun --version',
10
+ ].join('\n');
11
+ }
12
+
13
+ export function isBunAvailable(command = 'bun') {
14
+ const check = spawnSync(command, ['--version'], {
15
+ stdio: 'ignore',
16
+ });
17
+ return check.status === 0;
18
+ }
19
+
20
+ export function ensureBunAvailable(options = {}) {
21
+ const command =
22
+ typeof options.command === 'string' && options.command.trim().length > 0
23
+ ? options.command.trim()
24
+ : 'bun';
25
+ const stderr = options.stderr ?? process.stderr;
26
+ if (isBunAvailable(command)) {
27
+ return true;
28
+ }
29
+ stderr.write(`${formatBunRequiredMessage()}\n`);
30
+ const onMissing = options.onMissing;
31
+ if (typeof onMissing === 'function') {
32
+ onMissing();
33
+ }
34
+ return false;
35
+ }
@@ -2,6 +2,8 @@ import { resolve } from 'node:path';
2
2
  import { startCodexLiveSession } from '../src/codex/live-session.ts';
3
3
  import { startControlPlaneStreamServer } from '../src/control-plane/stream-server.ts';
4
4
  import { loadHarnessConfig } from '../src/config/config-core.ts';
5
+ import { resolveHarnessRuntimePath } from '../src/config/harness-paths.ts';
6
+ import { migrateLegacyHarnessLayout } from '../src/config/harness-runtime-migration.ts';
5
7
  import { loadHarnessSecrets } from '../src/config/secrets-core.ts';
6
8
  import {
7
9
  configurePerfCore,
@@ -39,11 +41,14 @@ function configureProcessPerf(invocationDirectory: string): void {
39
41
  const loadedConfig = loadHarnessConfig({ cwd: invocationDirectory });
40
42
  const configEnabled = loadedConfig.config.debug.enabled && loadedConfig.config.debug.perf.enabled;
41
43
  const perfEnabled = parseBooleanEnv(process.env.HARNESS_PERF_ENABLED, configEnabled);
42
- const configuredPath = resolve(invocationDirectory, loadedConfig.config.debug.perf.filePath);
44
+ const configuredPath = resolveHarnessRuntimePath(
45
+ invocationDirectory,
46
+ loadedConfig.config.debug.perf.filePath,
47
+ );
43
48
  const envPath = process.env.HARNESS_PERF_FILE_PATH;
44
49
  const perfFilePath =
45
50
  typeof envPath === 'string' && envPath.trim().length > 0
46
- ? resolve(invocationDirectory, envPath)
51
+ ? resolveHarnessRuntimePath(invocationDirectory, envPath)
47
52
  : configuredPath;
48
53
 
49
54
  configurePerfCore({
@@ -58,12 +63,14 @@ function configureProcessPerf(invocationDirectory: string): void {
58
63
  });
59
64
  }
60
65
 
61
- function parseArgs(argv: string[]): DaemonOptions {
66
+ function parseArgs(argv: string[], invocationDirectory: string): DaemonOptions {
62
67
  const defaultHost = process.env.HARNESS_CONTROL_PLANE_HOST ?? '127.0.0.1';
63
68
  const defaultPortRaw = process.env.HARNESS_CONTROL_PLANE_PORT ?? '7777';
64
69
  const defaultAuthToken = process.env.HARNESS_CONTROL_PLANE_AUTH_TOKEN ?? null;
65
- const defaultStateDbPath =
66
- process.env.HARNESS_CONTROL_PLANE_DB_PATH ?? '.harness/control-plane.sqlite';
70
+ const defaultStateDbPath = resolveHarnessRuntimePath(
71
+ invocationDirectory,
72
+ process.env.HARNESS_CONTROL_PLANE_DB_PATH ?? '.harness/control-plane.sqlite',
73
+ );
67
74
 
68
75
  let host = defaultHost;
69
76
  let portRaw = defaultPortRaw;
@@ -133,6 +140,7 @@ function parseArgs(argv: string[]): DaemonOptions {
133
140
 
134
141
  async function main(): Promise<number> {
135
142
  const invocationDirectory = resolveInvocationDirectory();
143
+ migrateLegacyHarnessLayout(invocationDirectory, process.env);
136
144
  loadHarnessSecrets({ cwd: invocationDirectory });
137
145
  configureProcessPerf(invocationDirectory);
138
146
  const loadedConfig = loadHarnessConfig({ cwd: invocationDirectory });
@@ -155,7 +163,7 @@ async function main(): Promise<number> {
155
163
  recordPerfEvent('daemon.startup.begin', {
156
164
  process: 'daemon',
157
165
  });
158
- const options = parseArgs(process.argv.slice(2));
166
+ const options = parseArgs(process.argv.slice(2), invocationDirectory);
159
167
 
160
168
  const listenSpan = startPerfSpan('daemon.startup.listen', {
161
169
  process: 'daemon',
@@ -171,6 +179,12 @@ async function main(): Promise<number> {
171
179
  directoryModes: codexLaunchDirectoryModes,
172
180
  },
173
181
  critique: loadedConfig.config.critique,
182
+ agentInstall: {
183
+ codex: loadedConfig.config.codex.install,
184
+ claude: loadedConfig.config.claude.install,
185
+ cursor: loadedConfig.config.cursor.install,
186
+ critique: loadedConfig.config.critique.install,
187
+ },
174
188
  cursorLaunch: {
175
189
  defaultMode: loadedConfig.config.cursor.launch.defaultMode,
176
190
  directoryModes: cursorLaunchDirectoryModes,
@@ -181,6 +195,15 @@ async function main(): Promise<number> {
181
195
  maxConcurrency: loadedConfig.config.mux.git.maxConcurrency,
182
196
  minDirectoryRefreshMs: Math.max(loadedConfig.config.mux.git.idlePollMs, 30_000),
183
197
  },
198
+ github: {
199
+ enabled: loadedConfig.config.github.enabled,
200
+ apiBaseUrl: loadedConfig.config.github.apiBaseUrl,
201
+ tokenEnvVar: loadedConfig.config.github.tokenEnvVar,
202
+ pollMs: loadedConfig.config.github.pollMs,
203
+ maxConcurrency: loadedConfig.config.github.maxConcurrency,
204
+ branchStrategy: loadedConfig.config.github.branchStrategy,
205
+ viewerLogin: loadedConfig.config.github.viewerLogin,
206
+ },
184
207
  lifecycleHooks: loadedConfig.config.hooks.lifecycle,
185
208
  startSession: (input) => {
186
209
  const sessionOptions: Parameters<typeof startCodexLiveSession>[0] = {
@@ -1,9 +1,10 @@
1
- #!/usr/bin/env bun
1
+ #!/usr/bin/env node
2
2
 
3
3
  import { existsSync, readFileSync } from 'node:fs';
4
4
  import { spawn } from 'node:child_process';
5
5
  import { dirname, resolve } from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
+ import { ensureBunAvailable } from './bun-runtime-guard.js';
7
8
 
8
9
  const LEGACY_LOCKFILES = [
9
10
  'package-lock.json',
@@ -53,10 +54,20 @@ function maybePrintBunMigrationHint() {
53
54
 
54
55
  maybePrintBunMigrationHint();
55
56
 
57
+ const bunCommand =
58
+ typeof process.env.HARNESS_BUN_COMMAND === 'string' &&
59
+ process.env.HARNESS_BUN_COMMAND.trim().length > 0
60
+ ? process.env.HARNESS_BUN_COMMAND.trim()
61
+ : 'bun';
62
+
63
+ if (!ensureBunAvailable({ command: bunCommand })) {
64
+ process.exit(1);
65
+ }
66
+
56
67
  const here = dirname(fileURLToPath(import.meta.url));
57
68
  const scriptPath = resolve(here, './harness.ts');
58
69
  const runtimeArgs = [scriptPath, ...process.argv.slice(2)];
59
- const child = spawn(process.execPath, runtimeArgs, {
70
+ const child = spawn(bunCommand, runtimeArgs, {
60
71
  stdio: 'inherit',
61
72
  });
62
73
 
@@ -5,6 +5,7 @@ import {
5
5
  mkdirSync,
6
6
  openSync,
7
7
  readFileSync,
8
+ renameSync,
8
9
  unlinkSync,
9
10
  writeFileSync,
10
11
  } from 'node:fs';
@@ -33,6 +34,11 @@ import {
33
34
  type GatewayRecord,
34
35
  } from '../src/cli/gateway-record.ts';
35
36
  import { loadHarnessConfig } from '../src/config/config-core.ts';
37
+ import {
38
+ resolveHarnessRuntimePath,
39
+ resolveHarnessWorkspaceDirectory,
40
+ } from '../src/config/harness-paths.ts';
41
+ import { migrateLegacyHarnessLayout } from '../src/config/harness-runtime-migration.ts';
36
42
  import { loadHarnessSecrets } from '../src/config/secrets-core.ts';
37
43
  import {
38
44
  buildCursorManagedHookRelayCommand,
@@ -72,8 +78,8 @@ const DEFAULT_GATEWAY_START_RETRY_WINDOW_MS = 6000;
72
78
  const DEFAULT_GATEWAY_START_RETRY_DELAY_MS = 40;
73
79
  const DEFAULT_GATEWAY_STOP_TIMEOUT_MS = 5000;
74
80
  const DEFAULT_GATEWAY_STOP_POLL_MS = 50;
75
- const DEFAULT_PROFILE_ROOT_PATH = '.harness/profiles';
76
- const DEFAULT_SESSION_ROOT_PATH = '.harness/sessions';
81
+ const DEFAULT_PROFILE_ROOT_PATH = 'profiles';
82
+ const DEFAULT_SESSION_ROOT_PATH = 'sessions';
77
83
  const PROFILE_STATE_FILE_NAME = 'active-profile.json';
78
84
  const PROFILE_CLIENT_FILE_NAME = 'client.cpuprofile';
79
85
  const PROFILE_GATEWAY_FILE_NAME = 'gateway.cpuprofile';
@@ -316,35 +322,50 @@ function resolveSessionPaths(
316
322
  invocationDirectory: string,
317
323
  sessionName: string | null,
318
324
  ): SessionPaths {
319
- const statusTimelineStatePath = resolveStatusTimelineStatePath(invocationDirectory, sessionName);
325
+ const workspaceDirectory = resolveHarnessWorkspaceDirectory(invocationDirectory, process.env);
326
+ const statusTimelineStatePath = resolveStatusTimelineStatePath(
327
+ invocationDirectory,
328
+ sessionName,
329
+ process.env,
330
+ );
320
331
  const defaultStatusTimelineOutputPath = resolveDefaultStatusTimelineOutputPath(
321
332
  invocationDirectory,
322
333
  sessionName,
334
+ process.env,
335
+ );
336
+ const renderTraceStatePath = resolveRenderTraceStatePath(
337
+ invocationDirectory,
338
+ sessionName,
339
+ process.env,
323
340
  );
324
- const renderTraceStatePath = resolveRenderTraceStatePath(invocationDirectory, sessionName);
325
341
  const defaultRenderTraceOutputPath = resolveDefaultRenderTraceOutputPath(
326
342
  invocationDirectory,
327
343
  sessionName,
344
+ process.env,
328
345
  );
329
346
  if (sessionName === null) {
330
347
  return {
331
- recordPath: resolveGatewayRecordPath(invocationDirectory),
332
- logPath: resolveGatewayLogPath(invocationDirectory),
333
- defaultStateDbPath: resolve(invocationDirectory, DEFAULT_GATEWAY_DB_PATH),
334
- profileDir: resolve(invocationDirectory, DEFAULT_PROFILE_ROOT_PATH),
335
- profileStatePath: resolve(invocationDirectory, '.harness', PROFILE_STATE_FILE_NAME),
348
+ recordPath: resolveGatewayRecordPath(invocationDirectory, process.env),
349
+ logPath: resolveGatewayLogPath(invocationDirectory, process.env),
350
+ defaultStateDbPath: resolveHarnessRuntimePath(
351
+ invocationDirectory,
352
+ DEFAULT_GATEWAY_DB_PATH,
353
+ process.env,
354
+ ),
355
+ profileDir: resolve(workspaceDirectory, DEFAULT_PROFILE_ROOT_PATH),
356
+ profileStatePath: resolve(workspaceDirectory, PROFILE_STATE_FILE_NAME),
336
357
  statusTimelineStatePath,
337
358
  defaultStatusTimelineOutputPath,
338
359
  renderTraceStatePath,
339
360
  defaultRenderTraceOutputPath,
340
361
  };
341
362
  }
342
- const sessionRoot = resolve(invocationDirectory, DEFAULT_SESSION_ROOT_PATH, sessionName);
363
+ const sessionRoot = resolve(workspaceDirectory, DEFAULT_SESSION_ROOT_PATH, sessionName);
343
364
  return {
344
365
  recordPath: resolve(sessionRoot, 'gateway.json'),
345
366
  logPath: resolve(sessionRoot, 'gateway.log'),
346
367
  defaultStateDbPath: resolve(sessionRoot, 'control-plane.sqlite'),
347
- profileDir: resolve(invocationDirectory, DEFAULT_PROFILE_ROOT_PATH, sessionName),
368
+ profileDir: resolve(workspaceDirectory, DEFAULT_PROFILE_ROOT_PATH, sessionName),
348
369
  profileStatePath: resolve(sessionRoot, PROFILE_STATE_FILE_NAME),
349
370
  statusTimelineStatePath,
350
371
  defaultStatusTimelineOutputPath,
@@ -830,9 +851,24 @@ function readGatewayRecord(recordPath: string): GatewayRecord | null {
830
851
  }
831
852
  }
832
853
 
854
+ function writeTextFileAtomically(filePath: string, text: string): void {
855
+ mkdirSync(dirname(filePath), { recursive: true });
856
+ const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}-${randomUUID()}`;
857
+ try {
858
+ writeFileSync(tempPath, text, 'utf8');
859
+ renameSync(tempPath, filePath);
860
+ } catch (error: unknown) {
861
+ try {
862
+ unlinkSync(tempPath);
863
+ } catch {
864
+ // Best-effort cleanup only.
865
+ }
866
+ throw error;
867
+ }
868
+ }
869
+
833
870
  function writeGatewayRecord(recordPath: string, record: GatewayRecord): void {
834
- mkdirSync(dirname(recordPath), { recursive: true });
835
- writeFileSync(recordPath, serializeGatewayRecord(record), 'utf8');
871
+ writeTextFileAtomically(recordPath, serializeGatewayRecord(record));
836
872
  }
837
873
 
838
874
  function removeGatewayRecord(recordPath: string): void {
@@ -916,8 +952,7 @@ function readActiveProfileState(profileStatePath: string): ActiveProfileState |
916
952
  }
917
953
 
918
954
  function writeActiveProfileState(profileStatePath: string, state: ActiveProfileState): void {
919
- mkdirSync(dirname(profileStatePath), { recursive: true });
920
- writeFileSync(profileStatePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
955
+ writeTextFileAtomically(profileStatePath, `${JSON.stringify(state, null, 2)}\n`);
921
956
  }
922
957
 
923
958
  function removeActiveProfileState(profileStatePath: string): void {
@@ -947,8 +982,7 @@ function readActiveStatusTimelineState(statePath: string): ActiveStatusTimelineS
947
982
  }
948
983
 
949
984
  function writeActiveStatusTimelineState(statePath: string, state: ActiveStatusTimelineState): void {
950
- mkdirSync(dirname(statePath), { recursive: true });
951
- writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
985
+ writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
952
986
  }
953
987
 
954
988
  function removeActiveStatusTimelineState(statePath: string): void {
@@ -979,8 +1013,7 @@ function readActiveRenderTraceState(statePath: string): ActiveRenderTraceState |
979
1013
  }
980
1014
 
981
1015
  function writeActiveRenderTraceState(statePath: string, state: ActiveRenderTraceState): void {
982
- mkdirSync(dirname(statePath), { recursive: true });
983
- writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
1016
+ writeTextFileAtomically(statePath, `${JSON.stringify(state, null, 2)}\n`);
984
1017
  }
985
1018
 
986
1019
  function removeActiveRenderTraceState(statePath: string): void {
@@ -1312,7 +1345,7 @@ function resolveGatewaySettings(
1312
1345
  overrides.stateDbPath ?? record?.stateDbPath ?? env.HARNESS_CONTROL_PLANE_DB_PATH,
1313
1346
  defaultStateDbPath,
1314
1347
  );
1315
- const stateDbPath = resolve(invocationDirectory, stateDbPathRaw);
1348
+ const stateDbPath = resolveHarnessRuntimePath(invocationDirectory, stateDbPathRaw, env);
1316
1349
 
1317
1350
  const envToken =
1318
1351
  typeof env.HARNESS_CONTROL_PLANE_AUTH_TOKEN === 'string' &&
@@ -2392,6 +2425,12 @@ async function runCursorHooksCommandEntry(
2392
2425
 
2393
2426
  async function main(): Promise<number> {
2394
2427
  const invocationDirectory = resolveInvocationDirectory(process.env, process.cwd());
2428
+ const migration = migrateLegacyHarnessLayout(invocationDirectory, process.env);
2429
+ if (migration.migrated) {
2430
+ process.stdout.write(
2431
+ `[migration] local .harness migrated to global runtime layout (${String(migration.migratedEntries)} entries, configCopied=${String(migration.configCopied)}, secretsCopied=${String(migration.secretsCopied)})\n`,
2432
+ );
2433
+ }
2395
2434
  loadHarnessSecrets({ cwd: invocationDirectory });
2396
2435
  const runtimeOptions = resolveInspectRuntimeOptions(invocationDirectory);
2397
2436
  const daemonScriptPath = resolveScriptPath(
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { ensureBunAvailable } from './bun-runtime-guard.js';
4
+
5
+ if (!ensureBunAvailable()) {
6
+ process.exit(1);
7
+ }
@@ -313,10 +313,10 @@ export function buildAgentStartArgs(
313
313
  const argsWithLaunchMode = [...baseArgs];
314
314
  if (
315
315
  cursorLaunchMode === 'yolo' &&
316
- !argsWithLaunchMode.includes('--yolo') &&
317
- !argsWithLaunchMode.includes('--force')
316
+ !argsWithLaunchMode.includes('--force') &&
317
+ !argsWithLaunchMode.includes('-f')
318
318
  ) {
319
- argsWithLaunchMode.push('--yolo');
319
+ argsWithLaunchMode.push('--force');
320
320
  }
321
321
  if (
322
322
  cursorLaunchMode === 'yolo' &&
@@ -1,4 +1,4 @@
1
- import { resolve } from 'node:path';
1
+ import { resolveHarnessRuntimePath } from '../config/harness-paths.ts';
2
2
 
3
3
  export const GATEWAY_RECORD_VERSION = 1;
4
4
  export const DEFAULT_GATEWAY_HOST = '127.0.0.1';
@@ -60,12 +60,18 @@ export function resolveInvocationDirectory(env: NodeJS.ProcessEnv, cwd: string):
60
60
  return env.HARNESS_INVOKE_CWD ?? env.INIT_CWD ?? cwd;
61
61
  }
62
62
 
63
- export function resolveGatewayRecordPath(workspaceRoot: string): string {
64
- return resolve(workspaceRoot, DEFAULT_GATEWAY_RECORD_PATH);
63
+ export function resolveGatewayRecordPath(
64
+ workspaceRoot: string,
65
+ env: NodeJS.ProcessEnv = process.env,
66
+ ): string {
67
+ return resolveHarnessRuntimePath(workspaceRoot, DEFAULT_GATEWAY_RECORD_PATH, env);
65
68
  }
66
69
 
67
- export function resolveGatewayLogPath(workspaceRoot: string): string {
68
- return resolve(workspaceRoot, DEFAULT_GATEWAY_LOG_PATH);
70
+ export function resolveGatewayLogPath(
71
+ workspaceRoot: string,
72
+ env: NodeJS.ProcessEnv = process.env,
73
+ ): string {
74
+ return resolveHarnessRuntimePath(workspaceRoot, DEFAULT_GATEWAY_LOG_PATH, env);
69
75
  }
70
76
 
71
77
  export function normalizeGatewayHost(
@@ -93,7 +99,7 @@ export function normalizeGatewayPort(
93
99
  return fallback;
94
100
  }
95
101
  const trimmed = input.trim();
96
- if (trimmed.length === 0) {
102
+ if (trimmed.length === 0 || !/^\d+$/u.test(trimmed)) {
97
103
  return fallback;
98
104
  }
99
105
  const parsed = Number.parseInt(trimmed, 10);