create-harness-vibe-coding 0.8.18 → 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 (128) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +1 -0
  3. package/package.json +5 -2
  4. package/src/index.js +383 -267
  5. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js +17 -0
  6. package/src/ui/dist/assets/AgentsRoute-CuSKYmFu.js.map +1 -0
  7. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js +7 -0
  8. package/src/ui/dist/assets/RolesRoute-DOzTYVRm.js.map +1 -0
  9. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js +12 -0
  10. package/src/ui/dist/assets/SettingsRoute-DPU7jSQm.js.map +1 -0
  11. package/src/ui/dist/assets/TaskList-BN4r8s1i.js +50 -0
  12. package/src/ui/dist/assets/TaskList-BN4r8s1i.js.map +1 -0
  13. package/src/ui/dist/assets/TerminalDrawer-6GBZ9nXN.css +32 -0
  14. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js +74 -0
  15. package/src/ui/dist/assets/TerminalDrawer-Cej4PxsH.js.map +1 -0
  16. package/src/ui/dist/assets/WorkflowRoute-BnuhLJ6X.css +1 -0
  17. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js +39 -0
  18. package/src/ui/dist/assets/WorkflowRoute-C-clKe-j.js.map +1 -0
  19. package/src/ui/dist/assets/index-BOVYWntB.js +117 -0
  20. package/src/ui/dist/assets/index-BOVYWntB.js.map +1 -0
  21. package/src/ui/dist/assets/index-DaUObq0H.css +1 -0
  22. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js +7 -0
  23. package/src/ui/dist/assets/maximize-2-LNrr_rKr.js.map +1 -0
  24. package/src/ui/dist/assets/plus-QuTJyoT3.js +7 -0
  25. package/src/ui/dist/assets/plus-QuTJyoT3.js.map +1 -0
  26. package/src/ui/dist/assets/proxy-D0vUbds5.js +2 -0
  27. package/src/ui/dist/assets/proxy-D0vUbds5.js.map +1 -0
  28. package/src/ui/dist/assets/refresh-cw-JNmExijd.js +7 -0
  29. package/src/ui/dist/assets/refresh-cw-JNmExijd.js.map +1 -0
  30. package/src/ui/dist/assets/terminal-BBQvnOAK.js +7 -0
  31. package/src/ui/dist/assets/terminal-BBQvnOAK.js.map +1 -0
  32. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js +7 -0
  33. package/src/ui/dist/assets/useReducedMotion-BqmH0Tf7.js.map +1 -0
  34. package/src/ui/dist/assets/x-DoIOGAJz.js +7 -0
  35. package/src/ui/dist/assets/x-DoIOGAJz.js.map +1 -0
  36. package/src/ui/dist/index.html +18 -0
  37. package/src/ui/index.html +17 -0
  38. package/src/ui/package.json +33 -0
  39. package/src/ui/pnpm-lock.yaml +1582 -0
  40. package/src/ui/pnpm-workspace.yaml +2 -0
  41. package/src/ui/src/App.tsx +86 -0
  42. package/src/ui/src/api.ts +93 -0
  43. package/src/ui/src/components/AgentsRoute.tsx +503 -0
  44. package/src/ui/src/components/Footer.tsx +69 -0
  45. package/src/ui/src/components/Header.tsx +175 -0
  46. package/src/ui/src/components/LoadingView.tsx +22 -0
  47. package/src/ui/src/components/RolesRoute.tsx +169 -0
  48. package/src/ui/src/components/SettingsRoute.tsx +166 -0
  49. package/src/ui/src/components/TaskList.tsx +388 -0
  50. package/src/ui/src/components/TerminalDrawer.tsx +611 -0
  51. package/src/ui/src/components/WorkflowRoute.tsx +670 -0
  52. package/src/ui/src/hooks/useReducedMotion.ts +17 -0
  53. package/src/ui/src/hooks/useServerConnection.ts +114 -0
  54. package/src/ui/src/index.css +281 -0
  55. package/src/ui/src/main.tsx +11 -0
  56. package/src/ui/src/types.ts +108 -0
  57. package/src/ui/tsconfig.json +21 -0
  58. package/src/ui/vite.config.ts +19 -0
  59. package/src/wf-ui-server/__tests__/a2a-store.test.mjs +79 -0
  60. package/src/wf-ui-server/__tests__/peer-capsule.test.mjs +268 -0
  61. package/src/wf-ui-server/__tests__/pty-adapter.test.mjs +70 -0
  62. package/src/wf-ui-server/__tests__/runtime-config.test.mjs +43 -0
  63. package/src/wf-ui-server/__tests__/runtime-detector.test.mjs +90 -0
  64. package/src/wf-ui-server/__tests__/security.test.mjs +59 -0
  65. package/src/wf-ui-server/__tests__/server.integration.test.mjs +150 -0
  66. package/src/wf-ui-server/__tests__/session-registry.test.mjs +238 -0
  67. package/src/wf-ui-server/__tests__/settings.test.mjs +135 -0
  68. package/src/wf-ui-server/__tests__/task-parser.test.mjs +200 -0
  69. package/src/wf-ui-server/__tests__/terminal-store.test.mjs +138 -0
  70. package/src/wf-ui-server/__tests__/token.test.mjs +48 -0
  71. package/src/wf-ui-server/__tests__/ws-events.integration.test.mjs +419 -0
  72. package/src/wf-ui-server/__tests__/ws-terminal.integration.test.mjs +383 -0
  73. package/src/wf-ui-server/a2a-store.mjs +296 -0
  74. package/src/wf-ui-server/peer-capsule.mjs +213 -0
  75. package/src/wf-ui-server/pty-adapter.mjs +155 -0
  76. package/src/wf-ui-server/runtime-config.mjs +153 -0
  77. package/src/wf-ui-server/runtime-detector.mjs +374 -0
  78. package/src/wf-ui-server/security.mjs +67 -0
  79. package/src/wf-ui-server/server.mjs +849 -0
  80. package/src/wf-ui-server/session-registry.mjs +204 -0
  81. package/src/wf-ui-server/settings.mjs +100 -0
  82. package/src/wf-ui-server/task-parser.mjs +133 -0
  83. package/src/wf-ui-server/terminal-store.mjs +225 -0
  84. package/src/wf-ui-server/token.mjs +41 -0
  85. package/src/wf-ui-server/ws-events.mjs +354 -0
  86. package/src/wf-ui-server/ws-terminal.mjs +473 -0
  87. package/templates/common/.claude/commands/wf-help.md +1 -0
  88. package/templates/common/.claude/commands/wf-task-list.md +2 -0
  89. package/templates/common/.claude/commands/wf-ui.md +26 -0
  90. package/templates/common/.claude/commands/wf-update.md +54 -9
  91. package/templates/common/.claude/rules/ecc/common.md +1 -1
  92. package/templates/common/.claude/skills/wf-auto/SKILL.md +3 -3
  93. package/templates/common/.claude/skills/wf-auto-spark/SKILL.md +2 -2
  94. package/templates/common/.claude/skills/wf-ui/SKILL.md +78 -0
  95. package/templates/common/.claude/skills/wf-update/SKILL.md +55 -58
  96. package/templates/common/.harness-version +63 -39
  97. package/templates/common/.opencode/commands/wf-help.md +1 -0
  98. package/templates/common/.opencode/commands/wf-task-list.md +2 -0
  99. package/templates/common/.opencode/commands/wf-ui.md +26 -0
  100. package/templates/common/.opencode/commands/wf-update.md +54 -9
  101. package/templates/common/CLAUDE.md +1 -1
  102. package/templates/common/Harness/MEMORY.md +2 -0
  103. package/templates/common/Harness/README.md +6 -4
  104. package/templates/common/Harness/a2a/role-graph.json +79 -0
  105. package/templates/common/Harness/a2a/runtime-registry.json +5 -0
  106. package/templates/common/Harness/a2a/skills/terminal-control.json +15 -0
  107. package/templates/common/Harness/ownership.manifest.json +57 -2
  108. package/templates/common/Harness/scripts/README.md +134 -0
  109. package/templates/common/Harness/scripts/a2a-terminal.mjs +191 -0
  110. package/templates/common/Harness/scripts/context-budget.mjs +1 -1
  111. package/templates/common/Harness/scripts/sync-host-global.mjs +278 -0
  112. package/templates/common/Harness/scripts/task-state.mjs +556 -23
  113. package/templates/common/Harness/scripts/validate-harness.mjs +229 -19
  114. package/templates/common/Harness/scripts/wf-remove.mjs +8 -3
  115. package/templates/common/Harness/scripts/wf-update-check.mjs +37 -9
  116. package/templates/common/Harness/scripts/wf-update-runner.mjs +325 -0
  117. package/templates/common/Harness/settings.json +35 -0
  118. package/templates/common/Harness/specs/protocols/TASK_ARCHIVE.md +7 -2
  119. package/templates/common/Harness/specs/runtime/command-surface.json +14 -0
  120. package/templates/common/Harness/specs/workflows/WF-AUTO-SPARK.md +8 -8
  121. package/templates/common/Harness/specs/workflows/WF-AUTO.md +12 -12
  122. package/templates/common/Harness/tasks/_template/NAMING.md +16 -16
  123. package/templates/common/Harness/tasks/_template/PLAN.md +17 -60
  124. package/templates/common/Harness/tasks/_template/PROBLEM.md +18 -0
  125. package/templates/common/Harness/tasks/_template/PROGRESS.md +9 -14
  126. package/templates/common/Harness/tasks/_template/REFERENCES.md +26 -0
  127. package/templates/common/Harness/tasks/_template/ARTIFACTS.md +0 -3
  128. package/templates/common/Harness/tasks/_template/NOTES.md +0 -3
@@ -0,0 +1,374 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ const HOME = os.homedir();
6
+
7
+ function homePath(...parts) {
8
+ return path.join(HOME, ...parts);
9
+ }
10
+
11
+ function projectPath(...parts) {
12
+ return path.join(...parts);
13
+ }
14
+
15
+ const COMMON_CAPABILITIES = ['terminal', 'workflow', 'a2a-files', 'terminal-read', 'terminal-write'];
16
+
17
+ function jsonConfig(scope, displayPath, resolvedPath, fields = {}) {
18
+ return { scope, path: displayPath, resolvedPath, format: 'json', fields };
19
+ }
20
+
21
+ function tomlConfig(scope, displayPath, resolvedPath, fields = {}) {
22
+ return { scope, path: displayPath, resolvedPath, format: 'toml', fields };
23
+ }
24
+
25
+ export const RUNTIME_DEFINITIONS = [
26
+ {
27
+ id: 'claude',
28
+ label: 'Claude Code',
29
+ commands: ['claude'],
30
+ launchable: true,
31
+ adapterStatus: 'ready',
32
+ modelArg: '--model',
33
+ resumeArgs: (sessionId) => sessionId ? ['--resume', sessionId] : ['--continue'],
34
+ capabilities: [...COMMON_CAPABILITIES, 'skills', 'built-in-subagents'],
35
+ configFiles: [
36
+ jsonConfig('user', '~/.claude/settings.json', homePath('.claude', 'settings.json'), { model: 'model' }),
37
+ jsonConfig('project', '.claude/settings.json', projectPath('.claude', 'settings.json'), { model: 'model' }),
38
+ jsonConfig('local', '.claude/settings.local.json', projectPath('.claude', 'settings.local.json'), { model: 'model' }),
39
+ jsonConfig('user-private', '~/.claude.json', homePath('.claude.json'), {}),
40
+ ],
41
+ },
42
+ {
43
+ id: 'cc',
44
+ label: 'Claude Code (cc)',
45
+ commands: ['cc'],
46
+ launchable: true,
47
+ adapterStatus: 'ready',
48
+ modelArg: '--model',
49
+ resumeArgs: (sessionId) => sessionId ? ['--resume', sessionId] : ['--continue'],
50
+ capabilities: [...COMMON_CAPABILITIES, 'skills', 'built-in-subagents'],
51
+ configFiles: [
52
+ jsonConfig('user', '~/.claude/settings.json', homePath('.claude', 'settings.json'), { model: 'model' }),
53
+ jsonConfig('project', '.claude/settings.json', projectPath('.claude', 'settings.json'), { model: 'model' }),
54
+ jsonConfig('local', '.claude/settings.local.json', projectPath('.claude', 'settings.local.json'), { model: 'model' }),
55
+ jsonConfig('user-private', '~/.claude.json', homePath('.claude.json'), {}),
56
+ ],
57
+ },
58
+ {
59
+ id: 'codex',
60
+ label: 'Codex',
61
+ commands: ['codex'],
62
+ launchable: true,
63
+ adapterStatus: 'ready',
64
+ modelArg: '--model',
65
+ resumeArgs: (sessionId) => sessionId ? ['resume', sessionId] : ['resume', '--last'],
66
+ capabilities: [...COMMON_CAPABILITIES, 'skills'],
67
+ configFiles: [
68
+ tomlConfig('user', '~/.codex/config.toml', homePath('.codex', 'config.toml'), { model: 'model', provider: 'model_provider' }),
69
+ tomlConfig('project', '.codex/config.toml', projectPath('.codex', 'config.toml'), { model: 'model', provider: 'model_provider' }),
70
+ ],
71
+ },
72
+ {
73
+ id: 'opencode',
74
+ label: 'OpenCode',
75
+ commands: ['opencode'],
76
+ launchable: true,
77
+ adapterStatus: 'ready',
78
+ modelArg: '--model',
79
+ resumeArgs: (sessionId) => sessionId ? ['--session', sessionId] : ['--continue'],
80
+ capabilities: [...COMMON_CAPABILITIES, 'skills'],
81
+ configFiles: [
82
+ jsonConfig('user', '~/.config/opencode/opencode.json', homePath('.config', 'opencode', 'opencode.json'), { model: 'model' }),
83
+ jsonConfig('tui', '~/.config/opencode/tui.json', homePath('.config', 'opencode', 'tui.json'), {}),
84
+ jsonConfig('project', 'opencode.json', projectPath('opencode.json'), { model: 'model' }),
85
+ ],
86
+ },
87
+ {
88
+ id: 'gemini',
89
+ label: 'Gemini CLI',
90
+ commands: ['gemini'],
91
+ launchable: true,
92
+ adapterStatus: 'ready',
93
+ modelArg: '--model',
94
+ capabilities: [...COMMON_CAPABILITIES, 'mcp'],
95
+ configFiles: [
96
+ jsonConfig('user', '~/.gemini/settings.json', homePath('.gemini', 'settings.json'), { model: 'model.name' }),
97
+ jsonConfig('project', '.gemini/settings.json', projectPath('.gemini', 'settings.json'), { model: 'model.name' }),
98
+ ],
99
+ },
100
+ {
101
+ id: 'qwen',
102
+ label: 'Qwen Code',
103
+ commands: ['qwen'],
104
+ launchable: true,
105
+ adapterStatus: 'ready',
106
+ modelArg: '--model',
107
+ capabilities: [...COMMON_CAPABILITIES, 'skills', 'built-in-subagents'],
108
+ configFiles: [
109
+ jsonConfig('user', '~/.qwen/settings.json', homePath('.qwen', 'settings.json'), { model: 'model.name' }),
110
+ jsonConfig('project', '.qwen/settings.json', projectPath('.qwen', 'settings.json'), { model: 'model.name' }),
111
+ ],
112
+ },
113
+ {
114
+ id: 'deepseek',
115
+ label: 'DeepSeek TUI',
116
+ commands: ['deepseek', 'deepseek-tui'],
117
+ launchable: true,
118
+ adapterStatus: 'ready',
119
+ modelArg: '--model',
120
+ capabilities: [...COMMON_CAPABILITIES, 'mcp'],
121
+ configFiles: [
122
+ tomlConfig('user', '~/.deepseek/config.toml', homePath('.deepseek', 'config.toml'), {
123
+ model: 'default_text_model',
124
+ provider: 'provider',
125
+ baseUrl: 'base_url',
126
+ }),
127
+ ],
128
+ },
129
+ {
130
+ id: 'pi',
131
+ label: 'Pi',
132
+ commands: ['pi'],
133
+ launchable: true,
134
+ adapterStatus: 'ready',
135
+ capabilities: [...COMMON_CAPABILITIES, 'minimal-tools'],
136
+ configFiles: [],
137
+ },
138
+ {
139
+ id: 'aider',
140
+ label: 'Aider',
141
+ commands: ['aider'],
142
+ launchable: true,
143
+ adapterStatus: 'ready',
144
+ modelArg: '--model',
145
+ capabilities: [...COMMON_CAPABILITIES],
146
+ configFiles: [],
147
+ },
148
+ {
149
+ id: 'goose',
150
+ label: 'Goose',
151
+ commands: ['goose'],
152
+ launchable: true,
153
+ adapterStatus: 'ready',
154
+ capabilities: [...COMMON_CAPABILITIES, 'sessions'],
155
+ configFiles: [],
156
+ },
157
+ {
158
+ id: 'amp',
159
+ label: 'Amp',
160
+ commands: ['amp'],
161
+ launchable: true,
162
+ adapterStatus: 'ready',
163
+ capabilities: [...COMMON_CAPABILITIES],
164
+ configFiles: [],
165
+ },
166
+ {
167
+ id: 'crush',
168
+ label: 'Crush',
169
+ commands: ['crush'],
170
+ launchable: true,
171
+ adapterStatus: 'ready',
172
+ capabilities: [...COMMON_CAPABILITIES],
173
+ configFiles: [],
174
+ },
175
+ {
176
+ id: 'cline',
177
+ label: 'Cline CLI',
178
+ commands: ['cline'],
179
+ launchable: true,
180
+ adapterStatus: 'ready',
181
+ modelArg: '--model',
182
+ capabilities: [...COMMON_CAPABILITIES, 'kanban'],
183
+ configFiles: [],
184
+ },
185
+ {
186
+ id: 'plandex',
187
+ label: 'Plandex',
188
+ commands: ['plandex'],
189
+ launchable: true,
190
+ adapterStatus: 'ready',
191
+ capabilities: [...COMMON_CAPABILITIES],
192
+ configFiles: [],
193
+ },
194
+ {
195
+ id: 'openclaw',
196
+ label: 'OpenClaw',
197
+ commands: ['openclaw'],
198
+ launchable: true,
199
+ adapterStatus: 'ready',
200
+ capabilities: [...COMMON_CAPABILITIES, 'assistant-gateway'],
201
+ configFiles: [],
202
+ },
203
+ {
204
+ id: 'chatgpt',
205
+ label: 'ChatGPT CLI',
206
+ commands: ['chatgpt'],
207
+ launchable: true,
208
+ adapterStatus: 'ready',
209
+ capabilities: [...COMMON_CAPABILITIES, 'generic-chat'],
210
+ configFiles: [],
211
+ },
212
+ {
213
+ id: 'openai',
214
+ label: 'OpenAI CLI',
215
+ commands: ['openai'],
216
+ launchable: true,
217
+ adapterStatus: 'ready',
218
+ capabilities: ['terminal', 'api-cli'],
219
+ configFiles: [],
220
+ },
221
+ ];
222
+
223
+ export const RUNTIME_IDS = new Set(RUNTIME_DEFINITIONS.map((runtime) => runtime.id));
224
+ const RUNTIME_BY_ID = new Map(RUNTIME_DEFINITIONS.map((runtime) => [runtime.id, runtime]));
225
+ const DEFAULT_RUNTIME_CACHE_TTL_MS = 12000;
226
+ const runtimeCache = new Map();
227
+ const runtimeRefreshInFlight = new Set();
228
+
229
+ function defaultRunner(command, args, options = {}) {
230
+ return spawnSync(command, args, {
231
+ encoding: 'utf8',
232
+ timeout: options.timeout || 2000,
233
+ windowsHide: true,
234
+ shell: false,
235
+ env: options.env || process.env,
236
+ });
237
+ }
238
+
239
+ function firstLine(value) {
240
+ return String(value || '').split(/\r?\n/).map((line) => line.trim()).find(Boolean) || null;
241
+ }
242
+
243
+ function commandCandidates(value) {
244
+ return String(value || '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
245
+ }
246
+
247
+ function preferWindowsExecutable(paths) {
248
+ if (process.platform !== 'win32') return paths[0] || null;
249
+ const preferredExts = ['.exe', '.cmd', '.bat', '.ps1'];
250
+ for (const ext of preferredExts) {
251
+ const match = paths.find((candidate) => candidate.toLowerCase().endsWith(ext));
252
+ if (match) return match;
253
+ }
254
+ return paths[0] || null;
255
+ }
256
+
257
+ export function getRuntimeDefinition(runtimeId) {
258
+ return RUNTIME_BY_ID.get(runtimeId) || null;
259
+ }
260
+
261
+ export function resolveRuntimeCommand(runtimeId) {
262
+ const definition = getRuntimeDefinition(runtimeId);
263
+ return definition ? definition.commands[0] : runtimeId;
264
+ }
265
+
266
+ export function resolveRuntimeLaunchArgs(runtimeId, opts = {}) {
267
+ const definition = getRuntimeDefinition(runtimeId);
268
+ const args = [];
269
+ const model = String(opts.model || '').trim();
270
+ if (definition?.modelArg && model) args.push(definition.modelArg, model);
271
+ return args;
272
+ }
273
+
274
+ export function resolveRuntimeResumeArgs(runtimeId, opts = {}) {
275
+ const definition = getRuntimeDefinition(runtimeId);
276
+ if (typeof definition?.resumeArgs !== 'function') return [];
277
+ const agentSessionId = String(opts.agentSessionId || '').trim();
278
+ return definition.resumeArgs(agentSessionId);
279
+ }
280
+
281
+ export function isRuntimeLaunchable(runtimeId) {
282
+ const definition = getRuntimeDefinition(runtimeId);
283
+ return Boolean(definition?.launchable);
284
+ }
285
+
286
+ export function resolveExecutable(command, { runner = defaultRunner, env = process.env } = {}) {
287
+ const resolver = process.platform === 'win32' ? 'where.exe' : 'which';
288
+ const result = runner(resolver, [command], { env, timeout: 1500 });
289
+ if (result?.status !== 0) return null;
290
+ return preferWindowsExecutable(commandCandidates(result.stdout));
291
+ }
292
+
293
+ export function readRuntimeVersion(command, { runner = defaultRunner, env = process.env } = {}) {
294
+ const attempts = [['--version'], ['version']];
295
+ for (const args of attempts) {
296
+ const result = runner(command, args, { env, timeout: 2000 });
297
+ if (result?.status === 0) {
298
+ const line = firstLine(result.stdout) || firstLine(result.stderr);
299
+ if (line) return line;
300
+ }
301
+ }
302
+ return null;
303
+ }
304
+
305
+ export function detectRuntime(definition, opts = {}) {
306
+ const found = definition.commands
307
+ .map((command) => ({ command, path: resolveExecutable(command, opts) }))
308
+ .find((candidate) => candidate.path);
309
+ const command = found?.command || definition.commands[0];
310
+ const executablePath = found?.path || null;
311
+ const version = executablePath && opts.readVersion ? readRuntimeVersion(command, opts) : null;
312
+
313
+ let status = 'missing';
314
+ let blockedReason = 'executable-not-found';
315
+ if (executablePath && definition.launchable) {
316
+ status = 'available';
317
+ blockedReason = null;
318
+ } else if (executablePath && !definition.launchable) {
319
+ status = 'adapter-needed';
320
+ blockedReason = 'runtime-adapter-needed';
321
+ }
322
+
323
+ return {
324
+ id: definition.id,
325
+ label: definition.label,
326
+ command,
327
+ path: executablePath,
328
+ version,
329
+ status,
330
+ launchable: Boolean(executablePath && definition.launchable),
331
+ adapterStatus: definition.adapterStatus,
332
+ resumeSupported: typeof definition.resumeArgs === 'function',
333
+ blockedReason,
334
+ capabilities: definition.capabilities,
335
+ configFiles: definition.configFiles || [],
336
+ };
337
+ }
338
+
339
+ export function detectRuntimes(opts = {}) {
340
+ const rows = RUNTIME_DEFINITIONS.map((definition) => detectRuntime(definition, opts));
341
+ return opts.includeMissing ? rows : rows.filter((runtime) => runtime.path);
342
+ }
343
+
344
+ export function detectRuntimesCached(opts = {}) {
345
+ if (opts.runner) return detectRuntimes(opts);
346
+ const includeMissing = Boolean(opts.includeMissing);
347
+ const ttlMs = Number.isFinite(opts.ttlMs) ? opts.ttlMs : DEFAULT_RUNTIME_CACHE_TTL_MS;
348
+ const cacheKey = includeMissing ? 'all' : 'detected';
349
+ const cached = runtimeCache.get(cacheKey);
350
+ const now = Date.now();
351
+ if (!opts.refresh && cached) {
352
+ if (now - cached.ts < ttlMs) return cached.rows;
353
+ warmRuntimeCache({ includeMissing });
354
+ return cached.rows;
355
+ }
356
+ const rows = detectRuntimes({ includeMissing });
357
+ runtimeCache.set(cacheKey, { ts: now, rows });
358
+ return rows;
359
+ }
360
+
361
+ export function warmRuntimeCache(opts = {}) {
362
+ const includeMissing = Boolean(opts.includeMissing);
363
+ const cacheKey = includeMissing ? 'all' : 'detected';
364
+ if (runtimeRefreshInFlight.has(cacheKey)) return;
365
+ runtimeRefreshInFlight.add(cacheKey);
366
+ const timer = setTimeout(() => {
367
+ try {
368
+ runtimeCache.set(cacheKey, { ts: Date.now(), rows: detectRuntimes({ includeMissing }) });
369
+ } finally {
370
+ runtimeRefreshInFlight.delete(cacheKey);
371
+ }
372
+ }, 0);
373
+ timer.unref?.();
374
+ }
@@ -0,0 +1,67 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Resolve an arbitrary path string to an absolute filesystem path.
5
+ *
6
+ * @param {string} rawPath - Path to canonicalize.
7
+ * @returns {string} Absolute path.
8
+ * @throws {TypeError} If rawPath is null or undefined.
9
+ * @throws {Error} If rawPath is an empty string.
10
+ */
11
+ export function canonicalizeProjectPath(rawPath) {
12
+ if (rawPath === null || rawPath === undefined) {
13
+ throw new TypeError('Path must be a non-null string');
14
+ }
15
+ if (rawPath === '') {
16
+ throw new Error('Path must not be empty');
17
+ }
18
+ return path.resolve(rawPath);
19
+ }
20
+
21
+ /**
22
+ * Resolve a relative path against a base directory, ensuring the result
23
+ * stays within the base directory (no path traversal escapes).
24
+ *
25
+ * @param {string} basePath - The base directory (must be absolute).
26
+ * @param {string} relativePath - The relative path to resolve.
27
+ * @returns {string} The resolved absolute path within basePath.
28
+ * @throws {Error} If the resolved path escapes basePath, if the relativePath
29
+ * contains null bytes, or if the relativePath is absolute.
30
+ */
31
+ export function safeResolve(basePath, relativePath) {
32
+ if (relativePath.includes('\0')) {
33
+ throw new Error('Path must not contain null bytes');
34
+ }
35
+
36
+ const target = path.resolve(basePath, relativePath);
37
+
38
+ // Reject if the resolved path is not within the base directory
39
+ if (!target.startsWith(basePath + path.sep) && target !== basePath) {
40
+ throw new Error('Path traversal detected: resolved path escapes base');
41
+ }
42
+
43
+ return target;
44
+ }
45
+
46
+ /**
47
+ * Validate a task ID string.
48
+ *
49
+ * Allowed characters: alphanumeric, dash, underscore.
50
+ * Rejects empty strings, path traversal sequences (.., /, \), and null bytes.
51
+ *
52
+ * @param {string} taskId - The task ID to validate.
53
+ * @returns {boolean} True if the task ID is valid; false otherwise.
54
+ */
55
+ export function validateTaskId(taskId) {
56
+ if (typeof taskId !== 'string' || taskId === '') {
57
+ return false;
58
+ }
59
+
60
+ // Reject path traversal sequences and null bytes
61
+ if (taskId.includes('..') || taskId.includes('/') || taskId.includes('\\') || taskId.includes('\0')) {
62
+ return false;
63
+ }
64
+
65
+ // Must match: alphanumeric, dash, underscore only
66
+ return /^[a-zA-Z0-9_-]+$/.test(taskId);
67
+ }