@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.
- package/README.md +125 -8
- package/package.json +19 -11
- package/scripts/bun-runtime-guard.js +35 -0
- package/scripts/control-plane-daemon.ts +29 -6
- package/scripts/harness-bin.js +13 -2
- package/scripts/harness.ts +59 -20
- package/scripts/require-bun.js +7 -0
- package/src/adapters/agent-session-state.ts +3 -3
- package/src/cli/gateway-record.ts +12 -6
- package/src/config/config-core.ts +155 -27
- package/src/config/harness-paths.ts +92 -0
- package/src/config/harness-runtime-migration.ts +130 -0
- package/src/config/harness.config.template.jsonc +10 -2
- package/src/config/secrets-core.ts +27 -9
- package/src/control-plane/codex-telemetry.ts +68 -18
- package/src/control-plane/status/reducer-base.ts +4 -1
- package/src/control-plane/stream-command-parser.ts +23 -0
- package/src/control-plane/stream-protocol.ts +6 -0
- package/src/control-plane/stream-server-command.ts +12 -0
- package/src/control-plane/stream-server-session-runtime.ts +16 -2
- package/src/control-plane/stream-server.ts +155 -19
- package/src/mux/live-mux/args.ts +17 -4
- package/src/mux/live-mux/command-menu.ts +44 -3
- package/src/mux/live-mux/conversation-state.ts +1 -0
- package/src/mux/live-mux/critique-review.ts +101 -0
- package/src/mux/live-mux/gateway-profiler.ts +67 -4
- package/src/mux/live-mux/git-parsing.ts +1 -1
- package/src/mux/live-mux/modal-command-menu-handler.ts +65 -16
- package/src/mux/live-mux/modal-overlays.ts +18 -10
- package/src/mux/live-mux/palette-parsing.ts +4 -1
- package/src/mux/live-mux/render-trace-state.ts +10 -11
- package/src/mux/live-mux/startup-utils.ts +5 -1
- package/src/mux/live-mux/status-timeline-state.ts +10 -20
- package/src/perf/perf-core.ts +8 -1
- package/src/recording/terminal-recording.ts +11 -3
- package/src/services/runtime-command-menu-agent-tools.ts +128 -0
- package/src/services/runtime-process-wiring.ts +3 -2
- package/src/services/startup-state-hydration.ts +0 -5
- package/src/terminal/compat-matrix.ts +3 -4
- package/src/terminal/snapshot-oracle.ts +219 -20
- package/src/ui/mux-theme-presets.json +2499 -0
- package/src/ui/mux-theme-presets.ts +29 -0
- 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
|
|
10
|
-
- Keep
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
- Open
|
|
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
|
-

|
|
20
|
+

|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.6",
|
|
4
4
|
"private": false,
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
"
|
|
27
|
-
|
|
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
|
-
"
|
|
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 =
|
|
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
|
-
?
|
|
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
|
-
|
|
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] = {
|
package/scripts/harness-bin.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
#!/usr/bin/env
|
|
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(
|
|
70
|
+
const child = spawn(bunCommand, runtimeArgs, {
|
|
60
71
|
stdio: 'inherit',
|
|
61
72
|
});
|
|
62
73
|
|
package/scripts/harness.ts
CHANGED
|
@@ -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 = '
|
|
76
|
-
const DEFAULT_SESSION_ROOT_PATH = '
|
|
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
|
|
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:
|
|
334
|
-
|
|
335
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
@@ -313,10 +313,10 @@ export function buildAgentStartArgs(
|
|
|
313
313
|
const argsWithLaunchMode = [...baseArgs];
|
|
314
314
|
if (
|
|
315
315
|
cursorLaunchMode === 'yolo' &&
|
|
316
|
-
!argsWithLaunchMode.includes('--
|
|
317
|
-
!argsWithLaunchMode.includes('
|
|
316
|
+
!argsWithLaunchMode.includes('--force') &&
|
|
317
|
+
!argsWithLaunchMode.includes('-f')
|
|
318
318
|
) {
|
|
319
|
-
argsWithLaunchMode.push('--
|
|
319
|
+
argsWithLaunchMode.push('--force');
|
|
320
320
|
}
|
|
321
321
|
if (
|
|
322
322
|
cursorLaunchMode === 'yolo' &&
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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(
|
|
64
|
-
|
|
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(
|
|
68
|
-
|
|
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);
|