create-harness-vibe-coding 0.8.17 → 0.8.19

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 (150) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README-CN.md +2 -0
  3. package/README.md +3 -0
  4. package/package.json +5 -2
  5. package/src/generator.js +613 -97
  6. package/src/index.js +556 -309
  7. package/src/prompts.js +18 -0
  8. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  9. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  10. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  11. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  12. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  13. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  14. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  15. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  16. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  17. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  18. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  19. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  20. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  21. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  22. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  23. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  24. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  25. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  26. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  27. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  28. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  29. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  30. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  31. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  32. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  33. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  34. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  35. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  36. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  37. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  38. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  39. package/src/ui/dist/index.html +18 -0
  40. package/src/ui/index.html +17 -0
  41. package/src/ui/package.json +33 -0
  42. package/src/ui/pnpm-lock.yaml +1582 -0
  43. package/src/ui/pnpm-workspace.yaml +2 -0
  44. package/src/ui/src/App.tsx +86 -0
  45. package/src/ui/src/api.ts +93 -0
  46. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  47. package/src/ui/src/components/Footer.tsx +69 -0
  48. package/src/ui/src/components/Header.tsx +175 -0
  49. package/src/ui/src/components/LoadingView.tsx +22 -0
  50. package/src/ui/src/components/RolesRoute.tsx +169 -0
  51. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  52. package/src/ui/src/components/TaskList.tsx +388 -0
  53. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  54. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  55. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  56. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  57. package/src/ui/src/index.css +281 -0
  58. package/src/ui/src/main.tsx +11 -0
  59. package/src/ui/src/types.ts +108 -0
  60. package/src/ui/tsconfig.json +21 -0
  61. package/src/ui/vite.config.ts +19 -0
  62. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  63. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  64. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  65. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  66. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  67. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  68. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  69. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  70. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  71. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  72. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  73. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  74. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  75. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  76. package/src/wf-ui-server/a2a-store.mjs +296 -0
  77. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  78. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  79. package/src/wf-ui-server/runtime-config.mjs +153 -0
  80. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  81. package/src/wf-ui-server/security.mjs +67 -0
  82. package/src/wf-ui-server/server.mjs +849 -0
  83. package/src/wf-ui-server/session-registry.mjs +204 -0
  84. package/src/wf-ui-server/settings.mjs +100 -0
  85. package/src/wf-ui-server/task-parser.mjs +133 -0
  86. package/src/wf-ui-server/terminal-store.mjs +225 -0
  87. package/src/wf-ui-server/token.mjs +41 -0
  88. package/src/wf-ui-server/ws-events.mjs +354 -0
  89. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  90. package/templates/common/.claude/commands/wf-command-create.md +58 -0
  91. package/templates/common/.claude/commands/wf-help.md +5 -0
  92. package/templates/common/.claude/commands/wf-task-archive.md +26 -0
  93. package/templates/common/.claude/commands/wf-task-list.md +26 -0
  94. package/templates/common/.claude/commands/wf-task-record.md +24 -0
  95. package/templates/common/.claude/commands/wf-ui.md +26 -0
  96. package/templates/common/.claude/commands/wf-update.md +54 -9
  97. package/templates/common/.claude/rules/ecc/common.md +1 -1
  98. package/templates/common/.claude/skills/wf-agents-docs/SKILL.md +15 -30
  99. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  100. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  101. package/templates/common/.claude/skills/wf-command-create/SKILL.md +37 -0
  102. package/templates/common/.claude/skills/wf-max/SKILL.md +1 -1
  103. package/templates/common/.claude/skills/wf-review/SKILL.md +29 -2
  104. package/templates/common/.claude/skills/wf-task-archive/SKILL.md +28 -0
  105. package/templates/common/.claude/skills/wf-task-list/SKILL.md +28 -0
  106. package/templates/common/.claude/skills/wf-task-record/SKILL.md +28 -0
  107. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  108. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  109. package/templates/common/.harness-version +105 -47
  110. package/templates/common/.opencode/commands/wf-command-create.md +61 -0
  111. package/templates/common/.opencode/commands/wf-help.md +5 -0
  112. package/templates/common/.opencode/commands/wf-task-archive.md +29 -0
  113. package/templates/common/.opencode/commands/wf-task-list.md +29 -0
  114. package/templates/common/.opencode/commands/wf-task-record.md +27 -0
  115. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  116. package/templates/common/.opencode/commands/wf-update.md +54 -9
  117. package/templates/common/CLAUDE.md +8 -6
  118. package/templates/common/Harness/MEMORY.md +11 -0
  119. package/templates/common/Harness/README.md +21 -38
  120. package/templates/common/Harness/a2a/role-graph.json +79 -0
  121. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  122. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  123. package/templates/common/Harness/ownership.manifest.json +142 -2
  124. package/templates/common/Harness/scripts/README.md +134 -0
  125. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  126. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  127. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  128. package/templates/common/Harness/scripts/task-state.mjs +949 -26
  129. package/templates/common/Harness/scripts/validate-harness.mjs +637 -62
  130. package/templates/common/Harness/scripts/wf-remove.mjs +42 -5
  131. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  132. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  133. package/templates/common/Harness/settings.json +35 -0
  134. package/templates/common/Harness/specs/guides/SETUP.md +8 -0
  135. package/templates/common/Harness/specs/protocols/MEMORY_PROTOCOL.md +15 -0
  136. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +16 -5
  137. package/templates/common/Harness/specs/runtime/command-surface.json +229 -0
  138. package/templates/common/Harness/specs/runtime/subagents.md +6 -0
  139. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  140. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  141. package/templates/common/Harness/specs/workflows/WF-MAX.md +5 -0
  142. package/templates/common/Harness/specs/workflows/WF-STATE.md +66 -0
  143. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  144. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  145. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  146. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  147. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  148. package/templates/common/Harness/tasks/_template/STATE.json +6 -0
  149. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  150. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { validateTaskId } from './security.mjs';
4
+
5
+ /**
6
+ * Validate a peer ID string.
7
+ * Same rules as taskId: alphanumeric, dash, underscore only, no path traversal.
8
+ * @param {string} peerId
9
+ * @returns {boolean}
10
+ */
11
+ export function validatePeerId(peerId) {
12
+ // Peer IDs follow the same rules as task IDs
13
+ return validateTaskId(peerId);
14
+ }
15
+
16
+ /**
17
+ * Validate peer capsule identifiers and throw on invalid input.
18
+ * @param {string} taskId
19
+ * @param {string} peerId
20
+ * @throws {Error} If taskId or peerId is invalid
21
+ */
22
+ function validateIds(taskId, peerId) {
23
+ if (!validateTaskId(taskId)) {
24
+ throw new Error(`Invalid taskId: '${taskId}'. Must match [a-zA-Z0-9_-]+`);
25
+ }
26
+ if (!validatePeerId(peerId)) {
27
+ throw new Error(`Invalid peerId: '${peerId}'. Must match [a-zA-Z0-9_-]+`);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Get the capsule directory path for a peer.
33
+ * @param {string} projectRoot
34
+ * @param {string} taskId
35
+ * @param {string} peerId
36
+ * @returns {string}
37
+ */
38
+ function capsuleDir(projectRoot, taskId, peerId) {
39
+ return path.join(projectRoot, 'Harness', 'tasks', taskId, 'peers', peerId);
40
+ }
41
+
42
+ /**
43
+ * Create a peer capsule directory with REQUEST.json and STATE.json.
44
+ *
45
+ * @param {string} projectRoot
46
+ * @param {string} taskId
47
+ * @param {string} peerId
48
+ * @param {object} request - The request payload (must include runtime, taskId, peerId)
49
+ * @returns {{ capsuleDir: string, request: object, state: object }}
50
+ */
51
+ export function createPeerCapsule(projectRoot, taskId, peerId, request) {
52
+ validateIds(taskId, peerId);
53
+
54
+ const dir = capsuleDir(projectRoot, taskId, peerId);
55
+ fs.mkdirSync(dir, { recursive: true });
56
+
57
+ const requestPayload = {
58
+ ...request,
59
+ taskId,
60
+ peerId,
61
+ createdAt: new Date().toISOString(),
62
+ };
63
+
64
+ fs.writeFileSync(path.join(dir, 'REQUEST.json'), JSON.stringify(requestPayload, null, 2), 'utf-8');
65
+
66
+ const state = {
67
+ status: 'starting',
68
+ createdAt: new Date().toISOString(),
69
+ updatedAt: new Date().toISOString(),
70
+ };
71
+
72
+ fs.writeFileSync(path.join(dir, 'STATE.json'), JSON.stringify(state, null, 2), 'utf-8');
73
+
74
+ return { capsuleDir: dir, request: requestPayload, state };
75
+ }
76
+
77
+ /**
78
+ * Append an event to the peer's events.jsonl file.
79
+ *
80
+ * @param {string} projectRoot
81
+ * @param {string} taskId
82
+ * @param {string} peerId
83
+ * @param {object} event
84
+ */
85
+ export function writePeerEvent(projectRoot, taskId, peerId, event) {
86
+ validateIds(taskId, peerId);
87
+
88
+ const dir = capsuleDir(projectRoot, taskId, peerId);
89
+ if (!fs.existsSync(dir)) {
90
+ throw new Error(`Capsule directory does not exist for ${taskId}/${peerId}`);
91
+ }
92
+
93
+ const eventLine = JSON.stringify({
94
+ ...event,
95
+ timestamp: event.timestamp || new Date().toISOString(),
96
+ });
97
+
98
+ fs.appendFileSync(path.join(dir, 'events.jsonl'), eventLine + '\n', 'utf-8');
99
+ }
100
+
101
+ /**
102
+ * Write a result payload to the peer's RESULT.json file.
103
+ *
104
+ * @param {string} projectRoot
105
+ * @param {string} taskId
106
+ * @param {string} peerId
107
+ * @param {object} result
108
+ */
109
+ export function writePeerResult(projectRoot, taskId, peerId, result) {
110
+ validateIds(taskId, peerId);
111
+
112
+ const dir = capsuleDir(projectRoot, taskId, peerId);
113
+ if (!fs.existsSync(dir)) {
114
+ throw new Error(`Capsule directory does not exist for ${taskId}/${peerId}`);
115
+ }
116
+
117
+ const resultPayload = {
118
+ ...result,
119
+ completedAt: new Date().toISOString(),
120
+ };
121
+
122
+ fs.writeFileSync(path.join(dir, 'RESULT.json'), JSON.stringify(resultPayload, null, 2), 'utf-8');
123
+ }
124
+
125
+ /**
126
+ * Mark a peer as blocked: update STATE.json and write PROGRESS.md with the reason.
127
+ *
128
+ * @param {string} projectRoot
129
+ * @param {string} taskId
130
+ * @param {string} peerId
131
+ * @param {string} reason
132
+ */
133
+ export function blockPeer(projectRoot, taskId, peerId, reason) {
134
+ validateIds(taskId, peerId);
135
+
136
+ const dir = capsuleDir(projectRoot, taskId, peerId);
137
+ if (!fs.existsSync(dir)) {
138
+ throw new Error(`Capsule directory does not exist for ${taskId}/${peerId}`);
139
+ }
140
+
141
+ const now = new Date().toISOString();
142
+
143
+ // Update STATE.json
144
+ const statePath = path.join(dir, 'STATE.json');
145
+ let state = {};
146
+ if (fs.existsSync(statePath)) {
147
+ state = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
148
+ }
149
+ state.status = 'blocked';
150
+ state.blockedReason = reason;
151
+ state.updatedAt = now;
152
+ fs.writeFileSync(statePath, JSON.stringify(state, null, 2), 'utf-8');
153
+
154
+ // Write PROGRESS.md
155
+ const progressContent = `# Peer Capsule: ${peerId}
156
+
157
+ **Status:** BLOCKED
158
+
159
+ **Block reason:** ${reason}
160
+
161
+ **Timestamp:** ${now}
162
+ `;
163
+ fs.writeFileSync(path.join(dir, 'PROGRESS.md'), progressContent, 'utf-8');
164
+ }
165
+
166
+ /**
167
+ * Read a peer capsule's files and return the full capsule.
168
+ *
169
+ * @param {string} projectRoot
170
+ * @param {string} taskId
171
+ * @param {string} peerId
172
+ * @returns {{ request: object|null, state: object|null, events: object[], result: object|null, progress: string|null }|null}
173
+ */
174
+ export function getPeerCapsule(projectRoot, taskId, peerId) {
175
+ validateIds(taskId, peerId);
176
+
177
+ const dir = capsuleDir(projectRoot, taskId, peerId);
178
+ if (!fs.existsSync(dir)) {
179
+ return null;
180
+ }
181
+
182
+ const readJSON = (filePath) => {
183
+ if (!fs.existsSync(filePath)) return null;
184
+ try {
185
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
186
+ } catch {
187
+ return null;
188
+ }
189
+ };
190
+
191
+ const readText = (filePath) => {
192
+ if (!fs.existsSync(filePath)) return null;
193
+ return fs.readFileSync(filePath, 'utf-8');
194
+ };
195
+
196
+ // Read events from jsonl
197
+ const eventsPath = path.join(dir, 'events.jsonl');
198
+ let events = [];
199
+ if (fs.existsSync(eventsPath)) {
200
+ const content = fs.readFileSync(eventsPath, 'utf-8').trim();
201
+ if (content) {
202
+ events = content.split('\n').map(line => JSON.parse(line));
203
+ }
204
+ }
205
+
206
+ return {
207
+ request: readJSON(path.join(dir, 'REQUEST.json')),
208
+ state: readJSON(path.join(dir, 'STATE.json')),
209
+ events,
210
+ result: readJSON(path.join(dir, 'RESULT.json')),
211
+ progress: readText(path.join(dir, 'PROGRESS.md')),
212
+ };
213
+ }
@@ -0,0 +1,155 @@
1
+ import { ALLOWED_RUNTIMES, generateSessionId } from './session-registry.mjs';
2
+ import path from 'node:path';
3
+ import {
4
+ getRuntimeDefinition,
5
+ isRuntimeLaunchable,
6
+ resolveRuntimeCommand,
7
+ resolveRuntimeLaunchArgs,
8
+ } from './runtime-detector.mjs';
9
+
10
+ async function loadPtyModule() {
11
+ const attempts = [
12
+ {
13
+ id: 'node-pty',
14
+ hint: 'Install node-pty or @homebridge/node-pty-prebuilt-multiarch.',
15
+ load: () => import('node-pty'),
16
+ },
17
+ {
18
+ id: '@homebridge/node-pty-prebuilt-multiarch',
19
+ hint: 'Install optional dependency @homebridge/node-pty-prebuilt-multiarch and approve its build scripts for pnpm.',
20
+ load: () => import('@homebridge/node-pty-prebuilt-multiarch'),
21
+ },
22
+ ];
23
+
24
+ const errors = [];
25
+ for (const attempt of attempts) {
26
+ try {
27
+ const loaded = await attempt.load();
28
+ const module = loaded.default || loaded;
29
+ if (module && typeof module.spawn === 'function') {
30
+ return { module, provider: attempt.id };
31
+ }
32
+ errors.push(`${attempt.id}: spawn export missing`);
33
+ } catch (err) {
34
+ errors.push(`${attempt.id}: ${err.message}`);
35
+ }
36
+ }
37
+
38
+ return {
39
+ blocked: true,
40
+ reason: 'pty-adapter-missing',
41
+ hint: `No PTY backend could be loaded. ${attempts[1].hint}`,
42
+ details: errors,
43
+ };
44
+ }
45
+
46
+ export function resolvePtyCommand(executable, args) {
47
+ if (process.platform !== 'win32') return { executable, args };
48
+ const ext = path.extname(executable).toLowerCase();
49
+ if (ext !== '.cmd' && ext !== '.bat') return { executable, args };
50
+ return {
51
+ executable: 'cmd.exe',
52
+ args: ['/d', '/c', 'call', executable, ...args],
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Spawn a PTY for a given runtime agent.
58
+ *
59
+ * Dynamically imports node-pty, then the prebuilt Homebridge fork. If both fail,
60
+ * returns a BLOCKED response. Never silently falls back to a fake terminal.
61
+ *
62
+ * @param {object} opts
63
+ * @param {string} opts.runtime - Agent runtime (claude, codex, opencode)
64
+ * @param {string|null} opts.taskId - Optional task ID for the session
65
+ * @param {string} opts.peerId - Peer identifier
66
+ * @param {string} opts.projectRoot - Project root directory
67
+ * @param {string} [opts.command] - Resolved executable command from detector
68
+ * @param {string[]} [opts.commandArgs] - Extra args appended after runtime launch args
69
+ * @param {string} [opts.model] - Optional model override for runtimes that support it
70
+ * @param {number} [opts.cols=120] - Terminal columns
71
+ * @param {number} [opts.rows=32] - Terminal rows
72
+ * @param {function} opts.onData - Callback for PTY output data (chunk) => void
73
+ * @param {function} opts.onExit - Callback for PTY exit ({ exitCode, signal }) => void
74
+ * @returns {Promise<{sessionId: string, pid: number, ptyProcess: object}|{blocked: boolean, reason: string, hint: string}>}
75
+ * @throws {Error} If runtime is not in ALLOWED_RUNTIMES
76
+ */
77
+ export async function spawnPty({
78
+ runtime,
79
+ taskId,
80
+ peerId,
81
+ projectRoot,
82
+ sessionId,
83
+ command,
84
+ commandArgs = [],
85
+ model = '',
86
+ cols = 120,
87
+ rows = 32,
88
+ onData,
89
+ onExit,
90
+ }) {
91
+ // Validate runtime before anything else
92
+ if (!ALLOWED_RUNTIMES.has(runtime)) {
93
+ throw new Error(`Invalid runtime '${runtime}'. Must be one of: ${[...ALLOWED_RUNTIMES].join(', ')}`);
94
+ }
95
+
96
+ const definition = getRuntimeDefinition(runtime);
97
+ if (!isRuntimeLaunchable(runtime)) {
98
+ return {
99
+ blocked: true,
100
+ reason: 'runtime-adapter-needed',
101
+ hint: `${definition?.label || runtime} is detectable but does not yet have a launch adapter.`,
102
+ };
103
+ }
104
+
105
+ const loadedPty = await loadPtyModule();
106
+ if (loadedPty.blocked) return loadedPty;
107
+ const nodePty = loadedPty.module;
108
+
109
+ const resolvedSessionId = sessionId || generateSessionId();
110
+
111
+ const requestedExecutable = command || resolveRuntimeCommand(runtime);
112
+ const requestedArgs = [...resolveRuntimeLaunchArgs(runtime, { model }), ...commandArgs];
113
+ const { executable, args: launchArgs } = resolvePtyCommand(requestedExecutable, requestedArgs);
114
+
115
+ let ptyProcess;
116
+ try {
117
+ ptyProcess = nodePty.spawn(executable, launchArgs, {
118
+ name: 'xterm-256color',
119
+ cols,
120
+ rows,
121
+ cwd: projectRoot || process.cwd(),
122
+ env: {
123
+ ...process.env,
124
+ HARNESS_PEER_RUNTIME: runtime,
125
+ CLAUDE_PEER_TASK_ID: taskId || '',
126
+ HARNESS_PEER_TASK_ID: taskId || '',
127
+ CLAUDE_PEER_ID: peerId,
128
+ HARNESS_PEER_SESSION_ID: resolvedSessionId,
129
+ TERM: 'xterm-256color',
130
+ },
131
+ });
132
+ } catch (err) {
133
+ return {
134
+ blocked: true,
135
+ reason: 'runtime-spawn-failed',
136
+ hint: `${runtime} executable not found or failed to start: ${err.message}`,
137
+ };
138
+ }
139
+
140
+ const pid = ptyProcess.pid;
141
+
142
+ ptyProcess.onData((data) => {
143
+ if (typeof onData === 'function') {
144
+ onData(data);
145
+ }
146
+ });
147
+
148
+ ptyProcess.onExit(({ exitCode, signal }) => {
149
+ if (typeof onExit === 'function') {
150
+ onExit({ exitCode, signal });
151
+ }
152
+ });
153
+
154
+ return { sessionId: resolvedSessionId, pid, ptyProcess, ptyProvider: loadedPty.provider };
155
+ }
@@ -0,0 +1,153 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getRuntimeDefinition } from './runtime-detector.mjs';
4
+
5
+ function resolveConfigPath(projectRoot, configFile) {
6
+ const rawPath = configFile.resolvedPath || configFile.path;
7
+ return path.isAbsolute(rawPath) ? rawPath : path.join(projectRoot, rawPath);
8
+ }
9
+
10
+ function readJson(filePath) {
11
+ if (!fs.existsSync(filePath)) return {};
12
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
13
+ }
14
+
15
+ function atomicWrite(filePath, content) {
16
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
17
+ const tmpPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
18
+ fs.writeFileSync(tmpPath, content, 'utf8');
19
+ fs.renameSync(tmpPath, filePath);
20
+ }
21
+
22
+ function backupIfExists(filePath) {
23
+ if (!fs.existsSync(filePath)) return null;
24
+ const backupPath = `${filePath}.${Date.now()}.bak`;
25
+ fs.copyFileSync(filePath, backupPath);
26
+ return backupPath;
27
+ }
28
+
29
+ function getPathValue(object, dottedPath) {
30
+ const parts = String(dottedPath || '').split('.').filter(Boolean);
31
+ let current = object;
32
+ for (const part of parts) {
33
+ if (!current || typeof current !== 'object' || !(part in current)) return undefined;
34
+ current = current[part];
35
+ }
36
+ return current;
37
+ }
38
+
39
+ function setPathValue(object, dottedPath, value) {
40
+ const parts = String(dottedPath || '').split('.').filter(Boolean);
41
+ if (parts.length === 0) return;
42
+ let current = object;
43
+ for (const part of parts.slice(0, -1)) {
44
+ if (!current[part] || typeof current[part] !== 'object' || Array.isArray(current[part])) {
45
+ current[part] = {};
46
+ }
47
+ current = current[part];
48
+ }
49
+ current[parts[parts.length - 1]] = value;
50
+ }
51
+
52
+ function escapeRegExp(value) {
53
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
54
+ }
55
+
56
+ function readTomlFields(filePath, fields) {
57
+ if (!fs.existsSync(filePath)) return {};
58
+ const text = fs.readFileSync(filePath, 'utf8');
59
+ const values = {};
60
+ for (const [alias, field] of Object.entries(fields || {})) {
61
+ if (String(field).includes('.')) continue;
62
+ const match = text.match(new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*(.*?)\\s*(?:#.*)?$`, 'm'));
63
+ if (!match) continue;
64
+ const raw = match[1].trim();
65
+ values[alias] = raw.replace(/^['"]|['"]$/g, '');
66
+ }
67
+ return values;
68
+ }
69
+
70
+ function updateTomlField(text, field, value) {
71
+ if (String(field).includes('.')) return text;
72
+ const nextLine = `${field} = ${JSON.stringify(String(value))}`;
73
+ const regex = new RegExp(`^\\s*${escapeRegExp(field)}\\s*=\\s*.*$`, 'm');
74
+ if (regex.test(text)) return text.replace(regex, nextLine);
75
+ return `${text.replace(/\s*$/, '')}\n${nextLine}\n`;
76
+ }
77
+
78
+ function configFilesForRuntime(projectRoot, runtimeId) {
79
+ const definition = getRuntimeDefinition(runtimeId);
80
+ if (!definition) throw new Error(`Unknown runtime: ${runtimeId}`);
81
+ return (definition.configFiles || []).map((configFile) => {
82
+ const absolutePath = resolveConfigPath(projectRoot, configFile);
83
+ const fields = configFile.fields || {};
84
+ let values = {};
85
+ if (configFile.format === 'json') {
86
+ try {
87
+ const json = readJson(absolutePath);
88
+ values = Object.fromEntries(
89
+ Object.entries(fields)
90
+ .map(([alias, dottedPath]) => [alias, getPathValue(json, dottedPath)])
91
+ .filter(([, value]) => value !== undefined),
92
+ );
93
+ } catch {
94
+ values = {};
95
+ }
96
+ } else if (configFile.format === 'toml') {
97
+ values = readTomlFields(absolutePath, fields);
98
+ }
99
+
100
+ return {
101
+ scope: configFile.scope,
102
+ path: configFile.path,
103
+ absolutePath,
104
+ format: configFile.format,
105
+ fields,
106
+ values,
107
+ exists: fs.existsSync(absolutePath),
108
+ writable: Object.keys(fields).length > 0,
109
+ };
110
+ });
111
+ }
112
+
113
+ export function readRuntimeConfig(projectRoot, runtimeId) {
114
+ return {
115
+ runtime: runtimeId,
116
+ files: configFilesForRuntime(projectRoot, runtimeId),
117
+ };
118
+ }
119
+
120
+ export function writeRuntimeConfig(projectRoot, runtimeId, payload = {}) {
121
+ const scope = String(payload.scope || 'user');
122
+ const values = payload.values || {};
123
+ const configFile = configFilesForRuntime(projectRoot, runtimeId)
124
+ .find((candidate) => candidate.scope === scope && candidate.writable);
125
+
126
+ if (!configFile) throw new Error(`No writable ${scope} config is known for ${runtimeId}`);
127
+
128
+ const backupPath = backupIfExists(configFile.absolutePath);
129
+ if (configFile.format === 'json') {
130
+ const data = readJson(configFile.absolutePath);
131
+ for (const [alias, dottedPath] of Object.entries(configFile.fields)) {
132
+ if (values[alias] !== undefined) setPathValue(data, dottedPath, String(values[alias]));
133
+ }
134
+ atomicWrite(configFile.absolutePath, `${JSON.stringify(data, null, 2)}\n`);
135
+ } else if (configFile.format === 'toml') {
136
+ let text = fs.existsSync(configFile.absolutePath) ? fs.readFileSync(configFile.absolutePath, 'utf8') : '';
137
+ for (const [alias, field] of Object.entries(configFile.fields)) {
138
+ if (values[alias] !== undefined) text = updateTomlField(text, field, values[alias]);
139
+ }
140
+ atomicWrite(configFile.absolutePath, text);
141
+ } else {
142
+ throw new Error(`Unsupported config format: ${configFile.format}`);
143
+ }
144
+
145
+ return {
146
+ runtime: runtimeId,
147
+ scope,
148
+ path: configFile.path,
149
+ absolutePath: configFile.absolutePath,
150
+ backupPath,
151
+ values,
152
+ };
153
+ }