devlyn-cli 4.3.24 → 4.3.27

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 (34) hide show
  1. package/README.md +27 -27
  2. package/bin/devlyn.js +42 -94
  3. package/bin/engines.js +14 -12
  4. package/bin/harness-activation.js +4 -2
  5. package/bin/harness-context.js +395 -0
  6. package/bin/harness-run.js +148 -0
  7. package/bin/instructions.js +71 -159
  8. package/bin/skill-history.json +7 -0
  9. package/config/skills/_shared/storage-checkout.py +1 -0
  10. package/config/skills/_shared/storage-client.py +349 -14
  11. package/config/skills/_shared/task-complete.py +99 -24
  12. package/config/skills/_shared/task-completion.md +7 -3
  13. package/config/skills/devlyn-ideate/SKILL.md +2 -2
  14. package/config/skills/devlyn-ideate/references/loop.md +2 -2
  15. package/config/skills/devlyn-ideate/scripts/queue.py +10 -3
  16. package/native/claude/.claude-plugin/plugin.json +1 -1
  17. package/native/claude/skills/_shared/storage-checkout.py +1 -0
  18. package/native/claude/skills/_shared/storage-client.py +349 -14
  19. package/native/claude/skills/_shared/task-complete.py +99 -24
  20. package/native/claude/skills/_shared/task-completion.md +7 -3
  21. package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
  22. package/native/claude/skills/devlyn-ideate/references/loop.md +2 -2
  23. package/native/claude/skills/devlyn-ideate/scripts/queue.py +10 -3
  24. package/native/codex/.agents/plugins/marketplace.json +1 -1
  25. package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
  26. package/native/codex/plugins/devlyn/skills/_shared/storage-checkout.py +1 -0
  27. package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +349 -14
  28. package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +99 -24
  29. package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +7 -3
  30. package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
  31. package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -2
  32. package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +10 -3
  33. package/package.json +2 -2
  34. package/release.json +43 -33
@@ -0,0 +1,395 @@
1
+ 'use strict';
2
+ // What `devlyn run` hands the activation planner for one fresh terminal launch: the selected
3
+ // release and an ActivationContext built the way the Devlyn app builds it (docs/harness-run.md).
4
+ // Every read that fails stops the launch; nothing here falls back to a raw engine start.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const readline = require('readline');
10
+ const { spawn, spawnSync } = require('child_process');
11
+ const { verifyRelease, materializeRelease, fail } = require('./release');
12
+
13
+ const ENGINES = Object.freeze(['claude', 'codex', 'omp']);
14
+ const READ_MS = 6000;
15
+ const CODEX_READ_MS = 10000;
16
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
17
+
18
+ // Engine arguments `devlyn run` passes through: each is known not to change the session's freshness,
19
+ // its directory, or settings the plan merges. Anything else is refused, so a form this list has never
20
+ // seen (an attached short option, an alias, a future flag) cannot slip past as it could past a deny
21
+ // list. `flags` take no value and `values` take one; widen by reading the engine's help.
22
+ const OPTIONS = Object.freeze({
23
+ claude: {
24
+ flags: new Set(['-p', '--print', '--verbose', '--dangerously-skip-permissions', '--allow-dangerously-skip-permissions',
25
+ '--no-session-persistence', '--include-partial-messages', '--no-chrome']),
26
+ values: new Set(['--model', '--fallback-model', '--effort', '--permission-mode', '--output-format', '--input-format',
27
+ '--add-dir', '--allowedTools', '--allowed-tools', '--disallowedTools', '--disallowed-tools', '--max-budget-usd',
28
+ '--json-schema', '--mcp-config']),
29
+ },
30
+ codex: {
31
+ flags: new Set(['--oss', '--search', '--no-alt-screen', '--dangerously-bypass-approvals-and-sandbox',
32
+ '--skip-git-repo-check', '--ephemeral', '--json']),
33
+ values: new Set(['-m', '--model', '-s', '--sandbox', '-a', '--ask-for-approval', '-i', '--image', '--add-dir',
34
+ '--output-schema', '--color', '-o', '--output-last-message']),
35
+ },
36
+ omp: {
37
+ flags: new Set(['-p', '--print', '--no-session', '--no-tools', '--no-lsp', '--no-pty', '--hide-thinking',
38
+ '--auto-approve', '--print-thoughts', '--no-title']),
39
+ values: new Set(['--model', '--smol', '--slow', '--plan', '--mode', '--thinking', '--tools', '--service-tier',
40
+ '--max-time', '--warn-cost', '--max-cost', '--approval-mode', '--add-dir']),
41
+ },
42
+ });
43
+ // `exec` (alias `e`) runs one Codex task; `debug prompt-input` shows the model input without a model call.
44
+ const CODEX_RUNS = new Set(['exec', 'e']);
45
+ const CONTINUATIONS = new Set(['resume', 'fork']);
46
+ // Options the engine itself reads as a list: the words after them are their values, up to the next
47
+ // option. Per engine, because the same name takes one value elsewhere (Codex and omp `--add-dir`).
48
+ const LISTS = Object.freeze({
49
+ claude: new Set(['--add-dir', '--allowedTools', '--allowed-tools', '--disallowedTools', '--disallowed-tools', '--mcp-config']),
50
+ codex: new Set(['-i', '--image']),
51
+ omp: new Set(),
52
+ });
53
+ // Codex config keys the plan writes; an owner override of one would be overwritten unseen.
54
+ const CODEX_PLANNED_KEYS = ['developer_instructions', 'plugins', 'skills', 'marketplaces'];
55
+
56
+ // Text too long for a path, or otherwise no path at all, simply names no file.
57
+ function namesFile(candidate) {
58
+ try { return fs.statSync(candidate, { throwIfNoEntry: false })?.isFile() === true; } catch { return false; }
59
+ }
60
+
61
+ function readAppend(file, field) {
62
+ try { return fs.readFileSync(file, 'utf8'); }
63
+ catch (error) { fail('config-unreadable', { field, detail: error.code || 'unreadable' }, 2); }
64
+ }
65
+
66
+ // Read the owner's arguments in order with each option's arity, so a value is never taken for an
67
+ // option. A dash always starts an option, as it does for the engine, and a one-word positional may be
68
+ // the engine's own command (`claude attach`, `codex resume`), so only text with a space is a prompt
69
+ // here; anything literal goes after `--`. Returns what stays, what the plan merges instead, and
70
+ // whether this is a print run.
71
+ function analyzeArgs(engine, args, cwd) {
72
+ const { flags, values } = OPTIONS[engine];
73
+ const kept = [];
74
+ const positionals = [];
75
+ const appended = [];
76
+ const pluginDirs = [];
77
+ let literal = [];
78
+ let print = false;
79
+ for (let i = 0; i < args.length; i++) {
80
+ const arg = args[i];
81
+ if (arg === '--') {
82
+ kept.push(...args.slice(i));
83
+ literal = args.slice(i + 1);
84
+ break;
85
+ }
86
+ if (!arg.startsWith('-') || arg === '-') {
87
+ kept.push(arg);
88
+ positionals.push(arg);
89
+ continue;
90
+ }
91
+ const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
92
+ const name = equals < 0 ? arg : arg.slice(0, equals);
93
+ const value = () => {
94
+ if (equals >= 0) return arg.slice(equals + 1);
95
+ if (++i >= args.length) fail('invalid-arguments', { argument: name }, 2);
96
+ return args[i];
97
+ };
98
+ if (flags.has(name) && equals < 0) {
99
+ if (name === '-p' || name === '--print') print = true;
100
+ kept.push(arg);
101
+ } else if (name === '--append-system-prompt' && engine !== 'codex') {
102
+ const text = value();
103
+ // omp reads an existing file named here; merging its name would lose the owner's text.
104
+ appended.push(engine === 'omp' && namesFile(path.resolve(cwd, text)) ? readAppend(path.resolve(cwd, text), name) : text);
105
+ } else if (name === '--append-system-prompt-file' && engine === 'claude') {
106
+ appended.push(readAppend(path.resolve(cwd, value()), name));
107
+ } else if (name === '--plugin-dir' && engine === 'claude') {
108
+ const dir = value();
109
+ pluginDirs.push(path.resolve(cwd, dir));
110
+ kept.push('--plugin-dir', dir);
111
+ } else if ((name === '-c' || name === '--config') && engine === 'codex') {
112
+ const setting = value();
113
+ const key = setting.split('=', 1)[0].trim();
114
+ if (CODEX_PLANNED_KEYS.some((planned) => key === planned || key.startsWith(`${planned}.`))) {
115
+ fail('run-argument-unsupported', { engine, argument: `${name} ${key}` }, 2);
116
+ }
117
+ kept.push(name, setting);
118
+ } else if (values.has(name)) {
119
+ const given = value();
120
+ kept.push(...(equals < 0 ? [name, given] : [arg]));
121
+ if (equals < 0 && LISTS[engine].has(name)) {
122
+ while (i + 1 < args.length && !args[i + 1].startsWith('-')) kept.push(args[++i]);
123
+ }
124
+ } else {
125
+ fail('run-argument-unsupported', { engine, argument: name }, 2);
126
+ }
127
+ }
128
+ if (appended.length > 1) fail('run-argument-unsupported', { engine, argument: 'more than one appended system prompt' }, 2);
129
+ // Interactive Claude and omp route a one-word command even after `--` (`claude -- update`).
130
+ const routed = engine !== 'codex' && !print && literal.find((word) => !/\s/.test(word));
131
+ if (routed) fail('run-argument-unsupported', { engine, argument: routed, hint: 'a one-word task needs -p' }, 2);
132
+ const words = positionals.filter((word) => !/\s/.test(word));
133
+ const [first, second] = words;
134
+ const allowed = engine === 'codex'
135
+ ? (positionals[0] === first && CODEX_RUNS.has(first) ? 1 : (first === 'debug' && second === 'prompt-input' ? 2 : 0)) : 0;
136
+ const command = words[allowed];
137
+ if (command !== undefined) {
138
+ if (engine === 'codex' && CONTINUATIONS.has(command)) fail('run-continuation-unsupported', { engine, argument: command }, 2);
139
+ fail('run-argument-unsupported', { engine, argument: command, hint: 'a one-word prompt goes after --' }, 2);
140
+ }
141
+ return { kept, appendSystemPrompt: appended[0], pluginDirs, print };
142
+ }
143
+
144
+ // The executable a launch runs, resolved once to an absolute path and used for every probe and the launch.
145
+ function resolveProgram(engine, env, cwd) {
146
+ if (process.platform === 'win32') fail('run-platform-unsupported', { platform: process.platform }, 2);
147
+ for (const dir of (env.PATH || '').split(path.delimiter)) {
148
+ if (!dir) continue;
149
+ const candidate = path.resolve(cwd, dir, engine);
150
+ try {
151
+ if (fs.statSync(candidate).isFile()) { fs.accessSync(candidate, fs.constants.X_OK); return candidate; }
152
+ } catch (error) {
153
+ if (!['ENOENT', 'ENOTDIR', 'EACCES'].includes(error.code)) throw error;
154
+ }
155
+ }
156
+ fail('run-engine-missing', { engine }, 2);
157
+ }
158
+
159
+ function engineVersion(program, env) {
160
+ const result = spawnSync(program, ['--version'], { env, encoding: 'utf8', timeout: READ_MS, stdio: ['ignore', 'pipe', 'pipe'] });
161
+ if (result.error || result.status !== 0) return null;
162
+ const match = `${result.stdout}`.match(/\d+\.\d+\.\d+/);
163
+ return match ? match[0] : null;
164
+ }
165
+
166
+ const ABSENT = Symbol('absent');
167
+
168
+ // Only a path with nothing at it is absent. A dangling link or a file holding JSON null is a record
169
+ // that cannot be read, which must stop the launch rather than read as no record.
170
+ function readJsonFile(file) {
171
+ if (!fs.lstatSync(file, { throwIfNoEntry: false })) return ABSENT;
172
+ let text;
173
+ try { text = fs.readFileSync(file, 'utf8'); }
174
+ catch (error) { fail('config-unreadable', { path: file, detail: error.code || 'unreadable' }, 2); }
175
+ try { return JSON.parse(text); }
176
+ catch { fail('config-unreadable', { path: file, detail: 'not JSON' }, 2); }
177
+ }
178
+
179
+ function gitCommonDir(cwd) {
180
+ const result = spawnSync('git', ['-C', cwd, 'rev-parse', '--path-format=absolute', '--git-common-dir'],
181
+ { encoding: 'utf8', env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' }, stdio: ['ignore', 'pipe', 'ignore'] });
182
+ if (result.status !== 0) return null;
183
+ try { return fs.realpathSync.native(result.stdout.trim()); } catch { return null; }
184
+ }
185
+
186
+ // The Devlyn app's machine record (`~/.devlyn/harness/state.json`), read only: its default release and
187
+ // the machine's or this repository's choice. Absent is fine; present but unreadable stops the launch.
188
+ function appState(home, common) {
189
+ const file = path.join(home, '.devlyn/harness/state.json');
190
+ const state = readJsonFile(file);
191
+ if (state === ABSENT) return { defaultRelease: null, choice: 'default' };
192
+ const choices = ['default', 'devlyn', 'none'];
193
+ if (!object(state) || state.schemaVersion !== 1 || !(state.defaultRelease === null || state.defaultRelease === undefined
194
+ || (object(state.defaultRelease) && typeof state.defaultRelease.version === 'string'
195
+ && /^[a-f0-9]{64}$/.test(state.defaultRelease.digest || '')))
196
+ || (state.machineChoice !== undefined && !choices.includes(state.machineChoice))
197
+ || (state.projects !== undefined && !object(state.projects))) {
198
+ fail('state-unreadable', { path: file }, 3);
199
+ }
200
+ let choice = state.machineChoice === 'none' ? 'none' : 'default';
201
+ if (choice !== 'none' && common) {
202
+ const key = crypto.createHash('sha256').update(Buffer.from(common)).digest('hex');
203
+ const project = state.projects?.[key];
204
+ if (project !== undefined) {
205
+ if (!object(project) || !choices.includes(project.choice)) fail('state-unreadable', { path: file }, 3);
206
+ choice = project.choice;
207
+ }
208
+ }
209
+ return { defaultRelease: state.defaultRelease || null, choice };
210
+ }
211
+
212
+ // A release chosen by a record must verify as recorded; it never yields to another release.
213
+ function recorded(root, version, digest, source) {
214
+ let release;
215
+ try { release = verifyRelease(root).release; }
216
+ catch (error) { fail('release-unavailable', { source, version, detail: error.reason?.code || error.message }, 3); }
217
+ if (release.version !== version || release.digest !== digest) fail('release-version-conflict', { source, version }, 3);
218
+ return { release, source };
219
+ }
220
+
221
+ // The release a terminal launch plans with: an unfinished queue's bound release in this repository,
222
+ // else the app's default release, else this package itself, captured once into ~/.devlyn/cli/releases.
223
+ function selectRelease({ home, common, packageRoot, defaultRelease }) {
224
+ if (common) {
225
+ const file = path.join(common, 'devlyn-loops/harness.json');
226
+ const binding = readJsonFile(file);
227
+ if (binding !== ABSENT) {
228
+ const ref = binding?.release;
229
+ const defaults = binding?.defaults;
230
+ const digest = /^[a-f0-9]{64}$/;
231
+ if (!object(binding) || Object.keys(binding).sort().join() !== 'defaults,release,schemaVersion' || binding.schemaVersion !== 1
232
+ || !object(ref) || ref.package !== 'devlyn-cli' || typeof ref.version !== 'string' || !digest.test(ref.digest || '')
233
+ || typeof ref.root !== 'string' || !path.isAbsolute(ref.root)
234
+ || !object(defaults) || typeof defaults.path !== 'string' || !path.isAbsolute(defaults.path)
235
+ || !digest.test(defaults.sha256 || '')) {
236
+ fail('state-unreadable', { path: file }, 3);
237
+ }
238
+ return recorded(ref.root, ref.version, ref.digest, 'queue');
239
+ }
240
+ }
241
+ if (defaultRelease) {
242
+ return recorded(path.join(home, '.devlyn/harness/devlyn-cli', defaultRelease.version), defaultRelease.version,
243
+ defaultRelease.digest, 'app-default');
244
+ }
245
+ try {
246
+ return { release: materializeRelease(packageRoot, path.join(home, '.devlyn/cli/releases')).release, source: 'package' };
247
+ } catch (error) {
248
+ // A source checkout or a damaged install is not a release; say so instead of planning without one.
249
+ fail('release-unavailable', { source: 'package', detail: error.reason?.code || error.code || error.message }, 3);
250
+ }
251
+ }
252
+
253
+ // Claude plugins named devlyn that Claude itself loads here (crates/devlynd harness.rs claude_devlyn_plugins).
254
+ function claudeDevlynPlugins(root, cwd) {
255
+ const registry = readJsonFile(path.join(root, 'plugins/installed_plugins.json'));
256
+ if (registry === ABSENT) return [];
257
+ if (!object(registry) || registry.version !== 2 || !object(registry.plugins)) {
258
+ fail('config-unreadable', { field: 'claude plugin registry', detail: 'not version 2' }, 2);
259
+ }
260
+ const settings = [path.join(cwd, '.claude/settings.local.json'), path.join(cwd, '.claude/settings.json'),
261
+ path.join(root, 'settings.json')].map(readJsonFile);
262
+ const enabled = (id) => settings.find((value) => object(value) && object(value.enabledPlugins)
263
+ && typeof value.enabledPlugins[id] === 'boolean')?.enabledPlugins[id] === true;
264
+ return Object.entries(registry.plugins)
265
+ .filter(([id]) => id.split('@')[0] === 'devlyn' && enabled(id))
266
+ .flatMap(([, installs]) => (Array.isArray(installs) ? installs : []))
267
+ .filter((install) => install?.scope === 'user'
268
+ || (typeof install?.projectPath === 'string' && (cwd === install.projectPath || cwd.startsWith(install.projectPath + path.sep))))
269
+ .map((install) => install?.installPath)
270
+ .filter((dir) => typeof dir === 'string' && fs.statSync(dir, { throwIfNoEntry: false })?.isDirectory());
271
+ }
272
+
273
+ function withoutNulls(value) {
274
+ if (value === null) return undefined;
275
+ if (Array.isArray(value)) return value.map(withoutNulls).filter((item) => item !== undefined);
276
+ if (object(value)) {
277
+ return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, withoutNulls(item)]).filter(([, item]) => item !== undefined));
278
+ }
279
+ return value;
280
+ }
281
+
282
+ // Codex's merged layers as its own app-server answers `config/read` (crates/devlynd codex.rs read_config).
283
+ function codexConfig(program, accountHome, cwd, env) {
284
+ return new Promise((resolve, reject) => {
285
+ const child = spawn(program, ['app-server'], { env: { ...env, CODEX_HOME: accountHome }, stdio: ['pipe', 'pipe', 'ignore'] });
286
+ const lines = readline.createInterface({ input: child.stdout });
287
+ const pending = new Map();
288
+ let settled = false;
289
+ const finish = (error, value) => {
290
+ if (settled) return;
291
+ settled = true;
292
+ clearTimeout(timer);
293
+ lines.close();
294
+ // A read-only helper: nothing to save, and one that ignores SIGTERM must not hold this launch.
295
+ child.kill('SIGKILL');
296
+ child.stdin.destroy();
297
+ child.stdout.destroy();
298
+ child.unref();
299
+ if (error) reject(error); else resolve(value);
300
+ };
301
+ const refuse = (detail) => finish(Object.assign(new Error(detail), { reason: { code: 'config-unreadable', params: { field: 'codex config/read', detail } } }));
302
+ const timer = setTimeout(() => refuse('timed out'), CODEX_READ_MS);
303
+ child.on('error', (error) => refuse(error.code || 'did not start'));
304
+ child.on('exit', () => refuse('app-server exited'));
305
+ lines.on('line', (line) => {
306
+ let message;
307
+ try { message = JSON.parse(line); } catch { refuse('unreadable line'); return; }
308
+ if (message.id === undefined || !pending.has(message.id)) return;
309
+ const handle = pending.get(message.id);
310
+ pending.delete(message.id);
311
+ // Its message can quote configuration, so only the failed method is said.
312
+ if (message.error) refuse(`${handle.method} refused`);
313
+ else handle.next(message.result);
314
+ });
315
+ let id = 0;
316
+ const call = (method, params, next) => {
317
+ id += 1;
318
+ pending.set(id, { method, next });
319
+ child.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
320
+ };
321
+ call('initialize', { clientInfo: { name: 'devlyn-cli', version: require('../package.json').version } }, () => {
322
+ child.stdin.write(`${JSON.stringify({ method: 'initialized' })}\n`);
323
+ call('config/read', { cwd }, (result) => {
324
+ if (!object(result?.config)) { refuse('no config'); return; }
325
+ const read = result.config;
326
+ const config = {};
327
+ for (const key of ['developer_instructions', 'marketplaces', 'plugins']) {
328
+ const value = withoutNulls(read[key]);
329
+ if (value !== undefined) config[key] = value;
330
+ }
331
+ const entries = withoutNulls(read.skills?.config);
332
+ if (entries !== undefined) config.skills = { config: entries };
333
+ finish(null, config);
334
+ });
335
+ });
336
+ }).catch((error) => fail(error.reason?.code || 'config-unreadable', error.reason?.params || { detail: error.message }, 2));
337
+ }
338
+
339
+ // omp's effective settings in this folder (crates/devlynd harness.rs omp_skills), plus autoResume.
340
+ function ompConfig(program, cwd, env) {
341
+ const result = spawnSync(program, ['config', 'list', '--json'], { cwd, env, encoding: 'utf8', timeout: READ_MS, stdio: ['ignore', 'pipe', 'pipe'] });
342
+ if (result.error || result.status !== 0) fail('config-unreadable', { field: 'omp config list', detail: result.error?.code || `exit ${result.status}` }, 2);
343
+ let listed;
344
+ try { listed = JSON.parse(result.stdout); } catch { fail('config-unreadable', { field: 'omp config list', detail: 'not JSON' }, 2); }
345
+ if (!object(listed)) fail('config-unreadable', { field: 'omp config list', detail: 'not an object' }, 2);
346
+ const skills = {};
347
+ for (const [key, setting] of Object.entries(listed)) {
348
+ if (key.startsWith('skills.') && object(setting) && 'value' in setting) skills[key.slice('skills.'.length)] = setting.value;
349
+ }
350
+ return { skills, autoResume: listed.autoResume?.value === true };
351
+ }
352
+
353
+ // omp loads APPEND_SYSTEM.md only when no append text is given, and the plan always gives some, so
354
+ // the owner's file is carried over: the project's first, then the user's, as omp looks them up.
355
+ function ompOwnerAppend(cwd, home) {
356
+ for (const file of [path.join(cwd, '.omp/APPEND_SYSTEM.md'), path.join(home, '.omp/agent/APPEND_SYSTEM.md')]) {
357
+ if (fs.statSync(file, { throwIfNoEntry: false })?.isFile()) return readAppend(file, 'APPEND_SYSTEM.md');
358
+ }
359
+ return undefined;
360
+ }
361
+
362
+ // Everything one fresh launch of `engine` with the owner's `args` needs before planning.
363
+ async function launchInputs({ engine, args, cwd, env, home, packageRoot }) {
364
+ if (!ENGINES.includes(engine)) fail('run-engine-unsupported', { engine, supported: ENGINES.join(',') }, 2);
365
+ const common = gitCommonDir(cwd);
366
+ const state = appState(home, common);
367
+ // A project turned off gets no plan to protect, so the owner's arguments go through untouched.
368
+ const off = state.choice === 'none';
369
+ const analyzed = off ? { kept: args, pluginDirs: [], print: false } : analyzeArgs(engine, args, cwd);
370
+ const program = resolveProgram(engine, env, cwd);
371
+ const version = engineVersion(program, env);
372
+ const { release, source } = selectRelease({ home, common, packageRoot, defaultRelease: state.defaultRelease });
373
+ let effectiveConfig;
374
+ let accountHome;
375
+ if (engine === 'claude') {
376
+ const root = env.CLAUDE_CONFIG_DIR || path.join(home, '.claude');
377
+ effectiveConfig = { pluginDirs: [...analyzed.pluginDirs, ...claudeDevlynPlugins(root, cwd)] };
378
+ if (analyzed.appendSystemPrompt !== undefined) effectiveConfig.appendSystemPrompt = analyzed.appendSystemPrompt;
379
+ } else if (engine === 'codex') {
380
+ accountHome = env.CODEX_HOME || path.join(home, '.codex');
381
+ if (!path.isAbsolute(accountHome) || !fs.statSync(accountHome, { throwIfNoEntry: false })?.isDirectory()) {
382
+ fail('config-unreadable', { field: 'CODEX_HOME', detail: 'not an existing absolute folder' }, 2);
383
+ }
384
+ effectiveConfig = await codexConfig(program, accountHome, cwd, env);
385
+ } else {
386
+ const omp = ompConfig(program, cwd, env);
387
+ if (omp.autoResume && !analyzed.print && !off) fail('run-continuation-unsupported', { engine, argument: 'autoResume' }, 2);
388
+ effectiveConfig = { skills: omp.skills };
389
+ const appended = analyzed.appendSystemPrompt ?? (off ? undefined : ompOwnerAppend(cwd, home));
390
+ if (appended !== undefined) effectiveConfig.appendSystemPrompt = appended;
391
+ }
392
+ return { program, version, release, source, args: analyzed.kept, cwd, choice: state.choice, accountHome, effectiveConfig };
393
+ }
394
+
395
+ module.exports = { ENGINES, analyzeArgs, launchInputs };
@@ -0,0 +1,148 @@
1
+ 'use strict';
2
+ // `devlyn run <engine> [engine args...]`: start claude, codex or omp from a terminal with the same
3
+ // activation plan the Devlyn app applies (docs/harness-run.md). Preparation either succeeds
4
+ // completely or no engine starts; the engine's exit code or signal is the command's own.
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const crypto = require('crypto');
9
+ const { spawn, spawnSync } = require('child_process');
10
+ const { HarnessError, fail } = require('./release');
11
+ const { launchInputs } = require('./harness-context');
12
+
13
+ const sha256 = (text) => crypto.createHash('sha256').update(text, 'utf8').digest('hex');
14
+ const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
15
+
16
+ // Run one of the selected release's own commands (never this package's) and return its data.
17
+ function releaseCommand(release, argv, input) {
18
+ const result = spawnSync(process.execPath, [path.join(release.root, 'bin/devlyn.js'), ...argv],
19
+ { input, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
20
+ let envelope;
21
+ try { envelope = JSON.parse(result.stdout); } catch { envelope = null; }
22
+ if (!object(envelope) || envelope.schemaVersion !== 1) {
23
+ fail('cli-failed', { command: argv.slice(0, 2).join(' '), exit: result.status }, 3);
24
+ }
25
+ if (!envelope.ok) {
26
+ // A `detail` can quote what it failed on, so only the structured reason is carried.
27
+ const { detail, ...params } = envelope.error?.params || {};
28
+ fail(envelope.error?.code || 'cli-failed', params, result.status || 3);
29
+ }
30
+ return envelope.data;
31
+ }
32
+
33
+ function validatePlan(plan, release) {
34
+ const strings = (value) => Array.isArray(value) && value.every((item) => typeof item === 'string');
35
+ if (!object(plan) || plan.schemaVersion !== 1 || !strings(plan.engineArgs) || !object(plan.envPatch)
36
+ || !Object.values(plan.envPatch).every((value) => value === null || typeof value === 'string')
37
+ || !Array.isArray(plan.files) || !object(plan.channels)) {
38
+ fail('plan-invalid', { detail: 'shape' }, 3);
39
+ }
40
+ if (plan.release && (plan.release.version !== release.version || plan.release.digest !== release.digest)) {
41
+ fail('plan-invalid', { detail: 'release changed' }, 3);
42
+ }
43
+ // Terminal launches carry no initial task, so no plan may rewrite one.
44
+ if (plan.prompt !== null) fail('plan-invalid', { detail: 'unexpected prompt' }, 3);
45
+ if (plan.machineSync !== null && !(object(plan.machineSync) && plan.machineSync.engine === 'codex')) {
46
+ fail('plan-invalid', { detail: 'machineSync' }, 3);
47
+ }
48
+ const seen = new Set();
49
+ for (const file of plan.files) {
50
+ const relative = file?.relativePath;
51
+ if (typeof relative !== 'string' || typeof file.utf8 !== 'string' || !/^[a-f0-9]{64}$/.test(file.sha256 || '')
52
+ || path.isAbsolute(relative) || path.normalize(relative) !== relative || relative.split(/[\\/]/).includes('..')
53
+ || seen.has(relative) || sha256(file.utf8) !== file.sha256) {
54
+ fail('plan-invalid', { detail: `file ${relative}` }, 3);
55
+ }
56
+ seen.add(relative);
57
+ }
58
+ }
59
+
60
+ // Write the plan's files under this run's own folder, exclusively, and read each back.
61
+ function writeFiles(runDir, files) {
62
+ for (const file of files) {
63
+ const target = path.join(runDir, file.relativePath);
64
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
65
+ fs.writeFileSync(target, file.utf8, { flag: 'wx', mode: 0o600 });
66
+ if (sha256(fs.readFileSync(target, 'utf8')) !== file.sha256) fail('plan-invalid', { detail: `wrote ${file.relativePath}` }, 3);
67
+ }
68
+ }
69
+
70
+ function statusLine(inputs, plan, engine) {
71
+ const { release, source, version, choice } = inputs;
72
+ const head = `devlyn: ${engine} ${version} | release ${release.version}@${release.digest.slice(0, 8)} (${source})`;
73
+ if (choice === 'none') return `${head} | harness off for this project`;
74
+ const said = (name) => {
75
+ const channel = plan.channels[name];
76
+ const reasons = (channel.reasons || []).map((reason) => reason.code).join(',');
77
+ return `${name}=${channel.state}${channel.mechanism ? `:${channel.mechanism}` : ''}${reasons ? ` (${reasons})` : ''}`;
78
+ };
79
+ return `${head} | ${said('principles')} | ${said('skills')}`;
80
+ }
81
+
82
+ async function runCommand(args) {
83
+ const engine = args[0];
84
+ if (!engine || engine.startsWith('-')) fail('invalid-arguments', { argument: 'usage: devlyn run <claude|codex|omp> [engine args...]' }, 2);
85
+ const env = { ...process.env };
86
+ const home = env.HOME || os.homedir();
87
+ const inputs = await launchInputs({ engine, args: args.slice(1), cwd: process.cwd(), env, home,
88
+ packageRoot: path.resolve(__dirname, '..') });
89
+ const runDir = fs.mkdtempSync(path.join(os.tmpdir(), 'devlyn-run-'));
90
+ fs.chmodSync(runDir, 0o700);
91
+ const cleanup = () => fs.rmSync(runDir, { recursive: true, force: true });
92
+ let child;
93
+ try {
94
+ const context = { schemaVersion: 1, engine, version: inputs.version, launchMode: 'fresh', cwd: inputs.cwd,
95
+ runDir, projectChoice: inputs.choice, committedBlockApplies: false, initialPrompt: null, accountRef: null,
96
+ ...(inputs.accountHome ? { accountHome: inputs.accountHome } : {}), effectiveConfig: inputs.effectiveConfig };
97
+ const plan = releaseCommand(inputs.release, ['activation', '--plan', '--release-root', inputs.release.root,
98
+ '--context-file', '-', '--json'], JSON.stringify(context));
99
+ validatePlan(plan, inputs.release);
100
+ if (inputs.choice !== 'none' && plan.channels.principles.state !== 'configured') {
101
+ fail('principles-unavailable', { reasons: (plan.channels.principles.reasons || []).map((reason) => reason.code).join(','),
102
+ release: inputs.release.version, source: inputs.source, engineVersion: inputs.version }, 3);
103
+ }
104
+ writeFiles(runDir, plan.files);
105
+ if (plan.machineSync) {
106
+ releaseCommand(inputs.release, ['machine', 'sync', '--release-root', inputs.release.root, '--engine', 'codex',
107
+ '--account-home', plan.machineSync.accountHome, '--json']);
108
+ }
109
+ const childEnv = { ...env, DEVLYN_NODE: process.execPath };
110
+ for (const [key, value] of Object.entries(plan.envPatch)) {
111
+ if (value === null) delete childEnv[key]; else childEnv[key] = value;
112
+ }
113
+ process.stderr.write(`${statusLine(inputs, plan, engine)}\n`);
114
+ child = spawn(inputs.program, [...plan.engineArgs, ...inputs.args], { cwd: inputs.cwd, env: childEnv, stdio: 'inherit' });
115
+ } catch (error) {
116
+ cleanup();
117
+ throw error;
118
+ }
119
+ // The terminal already delivers Ctrl-C and Ctrl-\ to the engine; forwarding them would double them.
120
+ // Signals sent to this process alone are passed on.
121
+ const ignore = () => {};
122
+ const forward = (signal) => () => { if (child.exitCode === null && child.signalCode === null) child.kill(signal); };
123
+ const handlers = { SIGINT: ignore, SIGQUIT: ignore, SIGTERM: forward('SIGTERM'), SIGHUP: forward('SIGHUP') };
124
+ for (const [signal, handler] of Object.entries(handlers)) process.on(signal, handler);
125
+ const outcome = await new Promise((resolve) => {
126
+ child.on('error', (error) => resolve({ error }));
127
+ child.on('exit', (code, signal) => resolve({ code, signal }));
128
+ });
129
+ cleanup();
130
+ for (const [signal, handler] of Object.entries(handlers)) process.removeListener(signal, handler);
131
+ if (outcome.error) fail('run-engine-failed', { engine, detail: outcome.error.code || outcome.error.message }, 3);
132
+ if (outcome.signal) { process.kill(process.pid, outcome.signal); return; }
133
+ process.exitCode = outcome.code;
134
+ }
135
+
136
+ // Errors say what stopped the launch on one line, without prompts, credentials or configuration.
137
+ async function runMain(args) {
138
+ try { await runCommand(args); }
139
+ catch (error) {
140
+ // Any other error's message can quote a prompt, so only its system code is said.
141
+ const known = error instanceof HarnessError;
142
+ const reason = known ? error.reason : { code: 'operation-failed', params: error.code ? { systemCode: error.code } : {} };
143
+ process.stderr.write(`devlyn run: ${reason.code} ${JSON.stringify(reason.params)}\n`);
144
+ process.exitCode = known ? error.exitCode : 1;
145
+ }
146
+ }
147
+
148
+ module.exports = { runMain };