devlyn-cli 4.3.28 → 4.3.29
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 +10 -10
- package/bin/devlyn.js +92 -58
- package/bin/engines.js +5 -3
- package/bin/harness-activation.js +208 -11
- package/bin/skill-history.json +9 -0
- package/config/skills/_shared/engine-doctor.sh +2 -2
- package/config/skills/_shared/engine-preflight.md +2 -2
- package/config/skills/_shared/managed-storage.md +80 -0
- package/config/skills/_shared/platform-support.py +67 -28
- package/config/skills/_shared/role-config.py +16 -7
- package/config/skills/_shared/storage-client.py +31 -7
- package/config/skills/_shared/task-complete.py +1 -1
- package/config/skills/_shared/task-completion.md +2 -76
- package/config/skills/devlyn-engines/SKILL.md +1 -1
- package/config/skills/devlyn-ideate/SKILL.md +2 -2
- package/config/skills/devlyn-ideate/references/executor.md +62 -0
- package/config/skills/devlyn-ideate/references/loop.md +2 -59
- package/config/skills/devlyn-ideate/references/package-format.md +1 -1
- package/config/skills/devlyn-ideate/scripts/acceptance.py +442 -21
- package/config/skills/devlyn-ideate/scripts/queue.py +58 -10
- package/native/claude/.claude-plugin/plugin.json +1 -1
- package/native/claude/skills/_shared/engine-doctor.sh +2 -2
- package/native/claude/skills/_shared/engine-preflight.md +2 -2
- package/native/claude/skills/_shared/managed-storage.md +80 -0
- package/native/claude/skills/_shared/platform-support.py +67 -28
- package/native/claude/skills/_shared/role-config.py +16 -7
- package/native/claude/skills/_shared/storage-client.py +31 -7
- package/native/claude/skills/_shared/task-complete.py +1 -1
- package/native/claude/skills/_shared/task-completion.md +2 -76
- package/native/claude/skills/devlyn-engines/SKILL.md +1 -1
- package/native/claude/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/claude/skills/devlyn-ideate/references/executor.md +62 -0
- package/native/claude/skills/devlyn-ideate/references/loop.md +2 -59
- package/native/claude/skills/devlyn-ideate/references/package-format.md +1 -1
- package/native/claude/skills/devlyn-ideate/scripts/acceptance.py +442 -21
- package/native/claude/skills/devlyn-ideate/scripts/queue.py +58 -10
- package/native/codex/.agents/plugins/marketplace.json +1 -1
- package/native/codex/plugins/devlyn/.codex-plugin/plugin.json +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/engine-doctor.sh +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/engine-preflight.md +2 -2
- package/native/codex/plugins/devlyn/skills/_shared/managed-storage.md +80 -0
- package/native/codex/plugins/devlyn/skills/_shared/platform-support.py +67 -28
- package/native/codex/plugins/devlyn/skills/_shared/role-config.py +16 -7
- package/native/codex/plugins/devlyn/skills/_shared/storage-client.py +31 -7
- package/native/codex/plugins/devlyn/skills/_shared/task-complete.py +1 -1
- package/native/codex/plugins/devlyn/skills/_shared/task-completion.md +2 -76
- package/native/codex/plugins/devlyn/skills/devlyn-engines/SKILL.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/SKILL.md +2 -2
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/executor.md +62 -0
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/loop.md +2 -59
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/references/package-format.md +1 -1
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/acceptance.py +442 -21
- package/native/codex/plugins/devlyn/skills/devlyn-ideate/scripts/queue.py +58 -10
- package/package.json +1 -11
- package/release.json +89 -59
- /package/config/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
- /package/config/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
- /package/config/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
- /package/native/claude/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
- /package/native/claude/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
- /package/native/claude/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
- /package/native/codex/plugins/devlyn/skills/_shared/adapters/{claude.md → claude.adapter.md} +0 -0
- /package/native/codex/plugins/devlyn/skills/_shared/adapters/{codex.md → codex.adapter.md} +0 -0
- /package/native/codex/plugins/devlyn/skills/_shared/adapters/{omp.md → omp.adapter.md} +0 -0
|
@@ -11,6 +11,7 @@ const SKILLS = ['devlyn-ideate', 'devlyn-engines'];
|
|
|
11
11
|
const object = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
12
12
|
const textValue = (value) => typeof value === 'string' && !value.includes('\0');
|
|
13
13
|
const strings = (value) => Array.isArray(value) && value.every(textValue);
|
|
14
|
+
const isFile = (file) => fs.statSync(file, { throwIfNoEntry: false })?.isFile() === true;
|
|
14
15
|
const reason = (code, params = {}) => ({ code, params });
|
|
15
16
|
const channel = (state = 'disabled', mechanism = null, reasons = []) => ({ state, mechanism, reasons });
|
|
16
17
|
const joinText = (...parts) => parts.filter((part) => part !== null && part !== undefined && part !== '').join('\n\n');
|
|
@@ -90,6 +91,7 @@ function effectiveConfig(context) {
|
|
|
90
91
|
claude: ['appendSystemPrompt', 'pluginDirs'], codex: ['developer_instructions', 'skills', 'marketplaces', 'plugins'],
|
|
91
92
|
grok: ['rules'], omp: ['appendSystemPrompt', 'skills'], antigravity: [], cursor: [],
|
|
92
93
|
opencode: ['inlineConfig', 'skillsPaths'], kimi: ['ownerAgent'], copilot: ['skillsDirs', 'instructionsDirs'], qwen: ['appendSystemPrompt'],
|
|
94
|
+
pi: ['appendSystemPrompt'],
|
|
93
95
|
}[context.engine];
|
|
94
96
|
if (!object(config) || Object.keys(config).some((key) => !keys.includes(key))) return null;
|
|
95
97
|
for (const field of ['appendSystemPrompt', 'developer_instructions', 'rules', 'inlineConfig', 'skillsDirs', 'instructionsDirs']) {
|
|
@@ -120,8 +122,20 @@ function effectiveConfig(context) {
|
|
|
120
122
|
return config;
|
|
121
123
|
}
|
|
122
124
|
|
|
125
|
+
// pi's agent dir is PI_CODING_AGENT_DIR, else ~/.pi/agent. That ~, and a leading ~ pi expands in the
|
|
126
|
+
// variable, is os.homedir(): USERPROFILE on Windows, HOME elsewhere (config.js getAgentDir). The home of
|
|
127
|
+
// its user .agents/skills takes HOME first on every platform (package-manager.js getHomeDir).
|
|
128
|
+
function piFolders(cwd, env, platform = process.platform) {
|
|
129
|
+
const paths = platform === 'win32' ? path.win32 : path.posix;
|
|
130
|
+
const home = (platform === 'win32' ? env.USERPROFILE : env.HOME) || os.homedir();
|
|
131
|
+
const dir = env.PI_CODING_AGENT_DIR;
|
|
132
|
+
const agentDir = dir ? dir.replace(platform === 'win32' ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, () => home) : paths.join(home, '.pi', 'agent');
|
|
133
|
+
return { agentDir: paths.resolve(cwd, agentDir), agentsHome: env.HOME || home };
|
|
134
|
+
}
|
|
135
|
+
|
|
123
136
|
function discoveryRoots(context, env, config) {
|
|
124
137
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
138
|
+
const pi = piFolders(context.cwd, env);
|
|
125
139
|
const roots = [...(context.skillRoots || [])];
|
|
126
140
|
const add = (base, ...parts) => { if (base) roots.push(path.resolve(base, ...parts)); };
|
|
127
141
|
const engine = context.engine;
|
|
@@ -135,10 +149,11 @@ function discoveryRoots(context, env, config) {
|
|
|
135
149
|
kimi: [env.KIMI_CODE_HOME || path.join(home, '.kimi-code'), '.kimi-code'],
|
|
136
150
|
copilot: [env.COPILOT_HOME || path.join(home, '.copilot'), '.github'],
|
|
137
151
|
qwen: [env.QWEN_HOME || path.join(home, '.qwen'), '.qwen'],
|
|
152
|
+
pi: [pi.agentDir, null],
|
|
138
153
|
}[engine];
|
|
139
154
|
if (engine !== 'codex') add(native[0], 'skills');
|
|
140
155
|
const copilotDefaultHome = !env.COPILOT_HOME || path.resolve(env.COPILOT_HOME) === path.join(home, '.copilot');
|
|
141
|
-
if (!['claude', 'cursor'].includes(engine) && (engine !== 'copilot' || copilotDefaultHome)) add(home, '.agents/skills');
|
|
156
|
+
if (!['claude', 'cursor'].includes(engine) && (engine !== 'copilot' || copilotDefaultHome)) add(engine === 'pi' ? pi.agentsHome : home, '.agents/skills');
|
|
142
157
|
if (['omp', 'opencode', 'grok'].includes(engine)) add(home, '.claude/skills');
|
|
143
158
|
if (engine === 'omp') {
|
|
144
159
|
add(home, '.codex/skills'); add(home, '.agent/skills');
|
|
@@ -150,6 +165,7 @@ function discoveryRoots(context, env, config) {
|
|
|
150
165
|
for (const dir of config?.skillsPaths || []) add(context.cwd, dir);
|
|
151
166
|
}
|
|
152
167
|
if (engine === 'copilot') for (const dir of (config?.skillsDirs ?? env.COPILOT_SKILLS_DIRS ?? '').split(',').filter(Boolean)) add(context.cwd, dir);
|
|
168
|
+
if (engine === 'pi') add(context.cwd, '.pi/skills');
|
|
153
169
|
for (let dir = path.resolve(context.cwd); ; dir = path.dirname(dir)) {
|
|
154
170
|
if (native[1]) add(dir, native[1], 'skills');
|
|
155
171
|
if (!['claude', 'cursor'].includes(engine)) add(dir, '.agents/skills');
|
|
@@ -187,6 +203,153 @@ function kimiSkillRoots(context, env) {
|
|
|
187
203
|
return { roots: [...roots], errors };
|
|
188
204
|
}
|
|
189
205
|
|
|
206
|
+
// omp and pi read their own APPEND_SYSTEM.md only when no append text is passed, and the plan passes
|
|
207
|
+
// some, so the owner's text leads it. Text the caller supplies wins, an explicit empty string included.
|
|
208
|
+
// Only when it supplies none (the key absent, or null for unset) is the first existing file `lookup()`
|
|
209
|
+
// names read, as `devlyn run` reads omp's (harness-context.js ompOwnerAppend). Returns {text}, or
|
|
210
|
+
// {reason} when the owner's text cannot be known.
|
|
211
|
+
function ownerAppend(config, lookup) {
|
|
212
|
+
if (config.appendSystemPrompt != null) return { text: config.appendSystemPrompt };
|
|
213
|
+
try {
|
|
214
|
+
const found = lookup();
|
|
215
|
+
if (found.reason) return found;
|
|
216
|
+
const file = found.files.find(isFile);
|
|
217
|
+
return { text: file === undefined ? null : fs.readFileSync(file, 'utf8') };
|
|
218
|
+
} catch (error) {
|
|
219
|
+
return { reason: reason('config-unreadable', { path: error.path, detail: error.code || error.message }) };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// pi's user and project settings.json (settings-manager.js). Entries they name resolve through pi's own
|
|
224
|
+
// package manager, which the planner does not repeat. A file that cannot be read is null and reported.
|
|
225
|
+
function piSettings(cwd, agentDir) {
|
|
226
|
+
const settings = { errors: [] };
|
|
227
|
+
for (const [scope, file] of [['user', path.join(agentDir, 'settings.json')], ['project', path.join(cwd, '.pi/settings.json')]]) {
|
|
228
|
+
try {
|
|
229
|
+
settings[scope] = fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '') || '{}') : {};
|
|
230
|
+
} catch (error) {
|
|
231
|
+
settings[scope] = null;
|
|
232
|
+
settings.errors.push(reason('config-unreadable', { path: file, detail: error.code || error.message }));
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return settings;
|
|
236
|
+
}
|
|
237
|
+
const listed = (settings, ...keys) => keys.some((key) => settings?.[key] != null && settings[key].length !== 0);
|
|
238
|
+
|
|
239
|
+
// Whether the agent dir's extensions folder holds anything pi could load; pi skips dot entries and
|
|
240
|
+
// node_modules (package-manager.js collectAutoExtensionEntries).
|
|
241
|
+
function piExtensionEntries(agentDir) {
|
|
242
|
+
try {
|
|
243
|
+
return fs.readdirSync(path.join(agentDir, 'extensions')).some((name) => !name.startsWith('.') && name !== 'node_modules');
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (error.code === 'ENOENT') return false;
|
|
246
|
+
throw error;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// pi reads a project's .pi/APPEND_SYSTEM.md only when it trusts the project, else its agent dir's
|
|
251
|
+
// (resource-loader discoverAppendSystemPromptFile). pi first asks the extensions it loads before trust,
|
|
252
|
+
// and the first yes or no wins (project-trust.js resolveProjectTrusted). With project settings held
|
|
253
|
+
// untrusted, those come only from the agent dir's extensions folder and the extensions or packages its
|
|
254
|
+
// user settings name (resource-loader loadProjectTrustExtensions, package-manager resolve): the app
|
|
255
|
+
// passes no -e, and pi's built-in extensions answer no trust question. With none, trust is pi's saved
|
|
256
|
+
// decision for the folder or its nearest decided parent (trust.json). Otherwise pi settles trust at
|
|
257
|
+
// launch, so which file it reads cannot be known here. The planner never writes trust.
|
|
258
|
+
function piAppendFiles(cwd, agentDir, userSettings) {
|
|
259
|
+
const project = path.join(cwd, '.pi/APPEND_SYSTEM.md');
|
|
260
|
+
const user = path.join(agentDir, 'APPEND_SYSTEM.md');
|
|
261
|
+
if (!isFile(project)) return { files: [user] };
|
|
262
|
+
const undecided = { reason: reason('principles-unavailable', { detail: 'project-trust-undecided', path: project }) };
|
|
263
|
+
if (userSettings === null || listed(userSettings, 'extensions', 'packages') || piExtensionEntries(agentDir)) return undecided;
|
|
264
|
+
const store = path.join(agentDir, 'trust.json');
|
|
265
|
+
let trusted = null;
|
|
266
|
+
try {
|
|
267
|
+
const saved = fs.existsSync(store) ? JSON.parse(fs.readFileSync(store, 'utf8').replace(/^\uFEFF/, '')) : {};
|
|
268
|
+
if (!object(saved) || Object.values(saved).some((value) => ![true, false, null].includes(value))) throw new Error('not a trust record');
|
|
269
|
+
for (let dir = fs.realpathSync(cwd); trusted === null; dir = path.dirname(dir)) {
|
|
270
|
+
if (typeof saved[dir] === 'boolean') trusted = saved[dir];
|
|
271
|
+
else if (dir === path.dirname(dir)) break;
|
|
272
|
+
}
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return { reason: reason('config-unreadable', { path: store, detail: error.code || error.message }) };
|
|
275
|
+
}
|
|
276
|
+
if (trusted === null) return undecided;
|
|
277
|
+
return { files: [trusted ? project : user] };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// The files pi may load as skills below a root (package-manager.js collectSkillEntries). pi stops at a
|
|
281
|
+
// folder's SKILL.md unless an ignore file hides it, and takes other .md files at the root of its own
|
|
282
|
+
// folders and below it in .agents/skills. Ignore files are not read here, so every .md file is taken and
|
|
283
|
+
// every folder searched: at least what pi finds is found. Dot entries and node_modules are skipped as pi
|
|
284
|
+
// skips them.
|
|
285
|
+
function piSkillFiles(root) {
|
|
286
|
+
const files = [], seen = new Set();
|
|
287
|
+
const visit = (dir) => {
|
|
288
|
+
let entries;
|
|
289
|
+
try {
|
|
290
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (dir === root && error.code === 'ENOENT') return;
|
|
293
|
+
throw error;
|
|
294
|
+
}
|
|
295
|
+
seen.add(fs.realpathSync(dir));
|
|
296
|
+
for (const entry of entries) {
|
|
297
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
298
|
+
const full = path.join(dir, entry.name);
|
|
299
|
+
let kind = entry;
|
|
300
|
+
// pi follows links and skips one it cannot follow.
|
|
301
|
+
if (entry.isSymbolicLink()) { try { kind = fs.statSync(full); } catch { continue; } }
|
|
302
|
+
if (kind.isDirectory()) { if (!seen.has(fs.realpathSync(full))) visit(full); }
|
|
303
|
+
else if (kind.isFile() && entry.name.endsWith('.md')) files.push(full);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
visit(root);
|
|
307
|
+
return files;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// The names pi may give a skill file (skills.js loadSkillFromFile): its frontmatter's string `name`, else,
|
|
311
|
+
// also for an empty one, its folder's name. A plain scalar may be no string (null, a number), so the
|
|
312
|
+
// folder counts then too.
|
|
313
|
+
// null when the name is not one simple line, which counts as a collision.
|
|
314
|
+
function piSkillNames(file) {
|
|
315
|
+
const text = fs.readFileSync(file, 'utf8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
|
316
|
+
const folder = path.basename(path.dirname(file));
|
|
317
|
+
const end = text.startsWith('---') ? text.indexOf('\n---', 3) : -1;
|
|
318
|
+
const lines = end < 0 ? [] : text.slice(4, end).split('\n');
|
|
319
|
+
if (/^\s/.test(lines.find((line) => line.trim() && !line.trim().startsWith('#')) ?? '')) return null;
|
|
320
|
+
// Lines that are or may be a top-level name key: quoted or complex keys, merges, anchors, flow mappings.
|
|
321
|
+
const keys = lines.flatMap((line, index) => (/^(?:name[ \t]*:|["'?{[&*!]|<<)/.test(line) ? [index] : []));
|
|
322
|
+
if (!keys.length) return [folder];
|
|
323
|
+
const match = keys.length === 1 && /^name:(.*)$/.exec(lines[keys[0]]);
|
|
324
|
+
if (!match) return null;
|
|
325
|
+
const value = match[1].replace(/\s#.*$/, '').trim();
|
|
326
|
+
const quoted = /^"([^"\\]*)"$|^'([^']*)'$/.exec(value);
|
|
327
|
+
if (quoted) return [(quoted[1] ?? quoted[2]) || folder];
|
|
328
|
+
const next = lines.slice(keys[0] + 1).find((line) => line.trim()) ?? '';
|
|
329
|
+
if (!value || /^[-?:,[\]{}#&*!|>'"%@`]/.test(value) || /^\s/.test(next)) return null;
|
|
330
|
+
return [value, folder];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// pi names a skill by its frontmatter wherever it sits below a root, and a found name wins over --skill.
|
|
334
|
+
// Files `found` already reports are skipped.
|
|
335
|
+
function piNameCollisions(roots, found) {
|
|
336
|
+
const collisions = [], errors = [], seen = new Set(found.map((entry) => entry.path));
|
|
337
|
+
for (const root of roots) {
|
|
338
|
+
try {
|
|
339
|
+
for (const file of piSkillFiles(root)) {
|
|
340
|
+
if (seen.has(file)) continue;
|
|
341
|
+
seen.add(file);
|
|
342
|
+
const names = piSkillNames(file);
|
|
343
|
+
if (names === null) collisions.push(reason('name-collision', { path: file }));
|
|
344
|
+
else for (const name of new Set(names)) if (SKILLS.includes(name)) collisions.push(reason('name-collision', { name, path: file }));
|
|
345
|
+
}
|
|
346
|
+
} catch (error) {
|
|
347
|
+
errors.push(reason('config-unreadable', { path: error.path || root, detail: error.code || error.message }));
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { collisions, errors };
|
|
351
|
+
}
|
|
352
|
+
|
|
190
353
|
function inspectSkills(context, release, env, config, active) {
|
|
191
354
|
const found = [], errors = [], hashes = new Map();
|
|
192
355
|
for (const root of discoveryRoots(context, env, config)) {
|
|
@@ -327,8 +490,10 @@ function planActivation(releaseRoot, input, env = process.env) {
|
|
|
327
490
|
return path.join(context.runDir, relativePath);
|
|
328
491
|
};
|
|
329
492
|
const principles = (mechanism) => { status.principles = channel('configured', mechanism); };
|
|
330
|
-
|
|
331
|
-
|
|
493
|
+
// The catalog travels in the principles text: without that text only the absolute invocation is left.
|
|
494
|
+
const fallback = (reasons = [], withCatalog = true) => {
|
|
495
|
+
status.skills = withCatalog ? channel('partial', 'absolute-catalog', [...reasons, reason('skills-catalog')])
|
|
496
|
+
: channel('partial', null, reasons);
|
|
332
497
|
status.invocation = { kind: 'read-file', text: `Read ${path.join(release.root, 'config/skills/devlyn-ideate/SKILL.md')}` };
|
|
333
498
|
};
|
|
334
499
|
const native = (mechanism, kind, text) => { status.skills = channel('configured', mechanism); status.invocation = { kind, text }; };
|
|
@@ -381,11 +546,39 @@ function planActivation(releaseRoot, input, env = process.env) {
|
|
|
381
546
|
case 'omp': {
|
|
382
547
|
const skills = { ...config.skills,
|
|
383
548
|
customDirectories: [...new Set([...(config.skills?.customDirectories || []), path.join(release.root, 'config/skills')])] };
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
549
|
+
// The files in the order `devlyn run` reads them (harness-context.js ompOwnerAppend).
|
|
550
|
+
const owner = ownerAppend(config, () => ({ files: [path.join(context.cwd, '.omp/APPEND_SYSTEM.md'),
|
|
551
|
+
path.join(env.HOME || env.USERPROFILE || os.homedir(), '.omp/agent/APPEND_SYSTEM.md')] }));
|
|
552
|
+
plan.engineArgs.push('--config', file('devlyn-omp.json', JSON.stringify({ skills }, null, 2) + '\n'));
|
|
553
|
+
// Owner text that cannot be known leaves the flag off, so the engine's own lookup stays intact.
|
|
554
|
+
if (owner.reason) status.principles = channel('partial', null, [owner.reason]);
|
|
555
|
+
else {
|
|
556
|
+
plan.engineArgs.push('--append-system-prompt', joinText(owner.text, instructions));
|
|
557
|
+
principles('append-system-prompt');
|
|
558
|
+
}
|
|
559
|
+
if (collisions.length || errors.length || skills.enableSkillCommands === false || skills.ignoredSkills?.length) {
|
|
560
|
+
fallback([...collisions, ...errors], !owner.reason);
|
|
561
|
+
} else native('skills.customDirectories', 'slash', '/skill:devlyn-ideate');
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
case 'pi': {
|
|
565
|
+
const { agentDir } = piFolders(context.cwd, env);
|
|
566
|
+
const settings = piSettings(context.cwd, agentDir);
|
|
567
|
+
const owner = ownerAppend(config, () => piAppendFiles(context.cwd, agentDir, settings.user));
|
|
568
|
+
if (owner.reason) status.principles = channel('partial', null, [owner.reason]);
|
|
569
|
+
else {
|
|
570
|
+
plan.engineArgs.push('--append-system-prompt', file('devlyn-instructions.md', joinText(owner.text, instructions)));
|
|
571
|
+
principles('append-system-prompt');
|
|
572
|
+
}
|
|
573
|
+
// A discovered or settings-named skill wins over a --skill of the same name.
|
|
574
|
+
const named = piNameCollisions(discoveryRoots(context, env, config), found);
|
|
575
|
+
const reasons = [...collisions, ...named.collisions, ...errors, ...named.errors, ...settings.errors];
|
|
576
|
+
if (reasons.length || [settings.user, settings.project].some((scope) => listed(scope, 'skills', 'packages'))) {
|
|
577
|
+
fallback(reasons, !owner.reason);
|
|
578
|
+
} else {
|
|
579
|
+
for (const name of SKILLS) plan.engineArgs.push('--skill', path.join(release.root, 'config/skills', name));
|
|
580
|
+
native('--skill', 'slash', '/skill:devlyn-ideate');
|
|
581
|
+
}
|
|
389
582
|
break;
|
|
390
583
|
}
|
|
391
584
|
case 'grok':
|
|
@@ -454,11 +647,15 @@ function planActivation(releaseRoot, input, env = process.env) {
|
|
|
454
647
|
break;
|
|
455
648
|
case 'antigravity':
|
|
456
649
|
case 'cursor':
|
|
457
|
-
|
|
650
|
+
// Only a first task carries the principles and the catalog: without one nothing reaches the session.
|
|
458
651
|
if (context.initialPrompt) {
|
|
652
|
+
status.principles = channel('partial', 'initial-task', [reason('principles-task-only')]);
|
|
459
653
|
plan.prompt = { ...context.initialPrompt, text: joinText(instructions, context.initialPrompt.text) };
|
|
460
654
|
fallback();
|
|
461
|
-
} else
|
|
655
|
+
} else {
|
|
656
|
+
status.principles = channel('unavailable', null, [reason('principles-unavailable', { detail: 'no-initial-task' })]);
|
|
657
|
+
status.skills = channel('unavailable', null, [reason('skills-unavailable', { detail: 'no-initial-task' })]);
|
|
658
|
+
}
|
|
462
659
|
break;
|
|
463
660
|
}
|
|
464
661
|
if (plan.engineArgs.length || Object.keys(plan.envPatch).length || plan.prompt) plan.envPatch.DEVLYN_HARNESS_ROOT = release.root;
|
|
@@ -473,4 +670,4 @@ function activationCommand(args) {
|
|
|
473
670
|
return planActivation(opts['release-root'], readContext(opts['context-file']));
|
|
474
671
|
}
|
|
475
672
|
|
|
476
|
-
module.exports = { planActivation, activationCommand, readContext, toml, skillCatalog };
|
|
673
|
+
module.exports = { planActivation, activationCommand, readContext, toml, skillCatalog, piFolders };
|
package/bin/skill-history.json
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
"_shared": [
|
|
5
5
|
"015eea12cc07cfb9eb318c30855f6deacb5bf43bd508a511dffd2e48c9d82964",
|
|
6
6
|
"0544f4e90951233c6cbe99755fb5c95728d7429b8b4ce5dd6e2829dd0816524c",
|
|
7
|
+
"0575d3635d8ec6f72120309a93af17e1801c636d3cf2b426d2feec064230c500",
|
|
7
8
|
"05935881380d65ba34a964592e4587534fcea034b43b13673eff4108ed9389fd",
|
|
8
9
|
"06ab3a4b9e1c97d9b3dcd238148d775f1165f3fc08898af2f3b76149162b78d3",
|
|
9
10
|
"082124278a538e4ea8b6622d767f996e3d818ec85d6e0d8ddddafe9bb03118b2",
|
|
@@ -17,6 +18,7 @@
|
|
|
17
18
|
"135f09d5382b4d375204bba3ff6fb3f6fdc34a1ec89fe88542da02d12dda0f0d",
|
|
18
19
|
"1504c7a41e5f4a3206288d05e66fb48847222864f7825f05633883e2c562ec77",
|
|
19
20
|
"161cb4f1b27a9f81ae1e96af6ff596399c8936578a11d601108a89cbdeea241d",
|
|
21
|
+
"16811f6cf716d3931562be0da4dae13b6aa5a9a126029a8ccddb7c797d0cb652",
|
|
20
22
|
"196afa14871099a9afc4e484b00e8d8ccd928dba79db95a6a8776f58c8e78c39",
|
|
21
23
|
"1c1e5e44ff704b1b8791fabcad79a4158d8cd2f024569c1685ec3b185a98fbcc",
|
|
22
24
|
"20ac702b463f3eaa07fd6686d5209aed7993773504e35896fc732f7e653ec6cd",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"59f0ecc2c1fd9620e4cac06be370e0bf9965a35c20616047445dbc42da6a1677",
|
|
73
75
|
"5b8541a98c06de50443c2c3905c1b92ba143a972dfe618c4b296b799e0e1673c",
|
|
74
76
|
"5be2e643f96b6ce503ae8d631c67729f0c24b6c5d2b431c087161b520ccbe417",
|
|
77
|
+
"5d3d614f8a75ae7cf1b5e29036e963ce76cf817fe9bd86457a4f8ff026b0036f",
|
|
75
78
|
"60cdd1a8b220e629bcba3a24652323d16e3e6a15ba5b07c69ef2785a9c2eca70",
|
|
76
79
|
"61ec4c0435a2f0b307df8e88d81d9862c3abc1bf758648f2c18039581596659b",
|
|
77
80
|
"628a40e873d156e9179fd5a6bb3da9f910b6dde0886055804a506cdf8a631aaa",
|
|
@@ -175,6 +178,7 @@
|
|
|
175
178
|
"e0a305cf3bf47c8bc357195d4d50b0f6177069e870c33040c258c93edae68646",
|
|
176
179
|
"e115205a744fef918a59d1b3a601df09a8b6df1d3e60613b9e8b87240929f8d8",
|
|
177
180
|
"e19204055bb16fff4263f95fac092ee8f971ee9055b7a969497e5714782a454c",
|
|
181
|
+
"e2a8157e0467cbe0c7cff583e34629bbd69c906830a1530a0e69a4cf4c448f2e",
|
|
178
182
|
"e41013abf5e17445ef83a86974ac1de77beaa61e484e1916577ecc64bb34c220",
|
|
179
183
|
"e725ec4eca42f737ee4bdaceb10c11db289414a1d4bb8be8fe103f89c89f07d5",
|
|
180
184
|
"e812d02ebdbdb8d82eb85842d676e21bedddb0d00154eed99b9d29b43cb8fc02",
|
|
@@ -193,6 +197,7 @@
|
|
|
193
197
|
"f9617e1034543f31ae2a3820301fbac6e4674e5d6fbc0d4b76751fe87b8e013e",
|
|
194
198
|
"fd68c7a74272929a14e685e447a51058ffcdd4c1d6bef83cbba18045c07aee55",
|
|
195
199
|
"fdb608b34a8600b4ce209d7f645ec5ad4475948be6ad95c941cc7ef02b5959df",
|
|
200
|
+
"fe357defe8ac6efd6bbd15b3bf35f79d79761c0f56d507488d9428536d4fb5f7",
|
|
196
201
|
"fe386122e502410394ab60643e79aa736ddb5030257c863f4a198e23b56b4cef",
|
|
197
202
|
"feaa255b2b6a7b0dc79877c4eb620c0f3cac88b50d2d1407a0a8a02ebc08ed49"
|
|
198
203
|
],
|
|
@@ -321,6 +326,7 @@
|
|
|
321
326
|
"89f0efaa2efdfddc887b494138247980d97856f6cabe6a8dcc585b3a28e5fa88",
|
|
322
327
|
"9bc8113592de34a7d559d8a5db6253e472357e947010ea2304701f1f7de3643d",
|
|
323
328
|
"ee723ff072721cae3f5e130d72515d44414337cffadb9615902b0d578912bda2",
|
|
329
|
+
"fc2710a0ed3eb0aeb7d095378bcbd1c77a5c46d0973bda7a49f6ec0a173adb2c",
|
|
324
330
|
"fed622aa1bcf9a3b437d42c39f6b35403c8cde657be0685b93bbe864e82ca8c4"
|
|
325
331
|
],
|
|
326
332
|
"devlyn-evaluate": [
|
|
@@ -348,6 +354,7 @@
|
|
|
348
354
|
"15f3eca3fad92b199814a00d42e4005a2838a40d800b9f755f7165d31773e7c9",
|
|
349
355
|
"19207315f23c9a12bf2ced0e4e79bb7dfdc7cc5d0c9dd94095d9c6dfe1e8fea8",
|
|
350
356
|
"1d8c3bd31c8b755acd0c3c9f416acb5024dfa99fbf5e2c14d8d6df00a24fe9b4",
|
|
357
|
+
"2e5b05df1af5891761a6460328d3a1b95aa6ead1b373b5147de6388f7cb98df2",
|
|
351
358
|
"300d1af60cc53285935dc4b5b370e157707218d844a4303b96d7826eef36ed43",
|
|
352
359
|
"302b1a6c109087dce02ed6af184d6aeeca6cecfd40ebe638beecf8bdf21cf59c",
|
|
353
360
|
"338a2b5ddde0f09444040c45922716663b1df76ec1a6c97ebdeb770b9e3ede49",
|
|
@@ -364,6 +371,7 @@
|
|
|
364
371
|
"6374290c74a8c17979e8c128905e106d38bd810c0451f0e9240c30e0f7ecabb0",
|
|
365
372
|
"65770e3f20c3a3afd2822e9acdcfc80b827f84a7737010b79332da70d431b05d",
|
|
366
373
|
"65c781764633b912e5947362eba0837c8027461a904975401d79ad4fe8714084",
|
|
374
|
+
"65ec310be43384375852e31ba3458313b01052668543849186a70715bfa81dcc",
|
|
367
375
|
"694c8920e50d92cba46dbb27eb44dd88035a89ed625d3cbf9f2bce9366d44c96",
|
|
368
376
|
"6b3143bcfe9c60a76f142d533177d85d36f856a163a226351317ed688738c05a",
|
|
369
377
|
"6b403afbf5a117cc08603d3648b9dded4f9ac4ca02fc1d8e1f0035615148bb5c",
|
|
@@ -812,6 +820,7 @@
|
|
|
812
820
|
"4.3.26",
|
|
813
821
|
"4.3.27",
|
|
814
822
|
"4.3.28",
|
|
823
|
+
"4.3.29",
|
|
815
824
|
"4.3.3",
|
|
816
825
|
"4.3.4",
|
|
817
826
|
"4.3.5",
|
|
@@ -29,7 +29,7 @@ check_binary() {
|
|
|
29
29
|
|
|
30
30
|
check_adapter() {
|
|
31
31
|
# $1 = target name
|
|
32
|
-
if [ -f "$ADAPTERS_DIR/$1.md" ]; then printf 'yes'; else printf 'no'; fi
|
|
32
|
+
if [ -f "$ADAPTERS_DIR/$1.adapter.md" ]; then printf 'yes'; else printf 'no'; fi
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
check_executor() {
|
|
@@ -47,7 +47,7 @@ for i in "${!TARGETS[@]}"; do
|
|
|
47
47
|
kind="${KINDS[$i]}"
|
|
48
48
|
binary="$(check_binary "${BINARIES[$i]}")"
|
|
49
49
|
adapter="$(check_adapter "$target")"
|
|
50
|
-
executor="$(check_executor "$ADAPTERS_DIR/$target.md")"
|
|
50
|
+
executor="$(check_executor "$ADAPTERS_DIR/$target.adapter.md")"
|
|
51
51
|
|
|
52
52
|
note='-'
|
|
53
53
|
case "$kind" in
|
|
@@ -4,7 +4,7 @@ Used by `/devlyn-ideate` and `/devlyn-engines`: one rule for which engine execut
|
|
|
4
4
|
|
|
5
5
|
## Selection
|
|
6
6
|
|
|
7
|
-
The executor is the `executor` pin in `.devlyn/engines.json` at the checkout root (set with `/devlyn-engines executor <name>`), else the CLI the user opened. A linked worktree or subdirectory has no pin unless one is set there.
|
|
7
|
+
The executor is the `executor` pin in `.devlyn/engines.json` at the checkout root (set with `/devlyn-engines executor <name>`), else the CLI the user opened. A linked worktree or subdirectory has no pin unless one is set there. Only Claude and Codex can execute, so a drain from any other CLI needs a claude or codex pin. Keys earlier releases wrote for retired pipeline roles (`pair_judge_priority`, `roles`) stay as written and select nothing: a worker profile never becomes the executor.
|
|
8
8
|
|
|
9
9
|
Before dispatching to the executor, run:
|
|
10
10
|
|
|
@@ -16,7 +16,7 @@ A pin is a promise. When the pinned engine's CLI is unavailable, the command exi
|
|
|
16
16
|
|
|
17
17
|
## Engines
|
|
18
18
|
|
|
19
|
-
An engine is pinnable when `_shared/adapters/<name>.md` exists and does not declare `executor: no
|
|
19
|
+
An engine is pinnable when `_shared/adapters/<name>.adapter.md` exists and does not declare `executor: no`. Adapters ship with devlyn-cli releases, and a reinstall replaces `_shared`. An adapter's `## Invocation` says how to start it. On native Windows, use native Node/npm and Python (`python3` on PATH), with Git for Windows Bash for the shipped shell wrapper.
|
|
20
20
|
|
|
21
21
|
## Reporting a blocked engine
|
|
22
22
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# Managed storage
|
|
2
|
+
|
|
3
|
+
New allocation and warming modes come from enrollment state alone:
|
|
4
|
+
|
|
5
|
+
| `~/.devlyn/storage-client.json` | recovery marker | unenroll journal | Mode |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| absent | absent | not read | standalone (never enrolled) |
|
|
8
|
+
| valid | any | not read | managed; a fenced authority refuses |
|
|
9
|
+
| invalid or unreadable | any | not read | refuse; repair registration |
|
|
10
|
+
| absent | present | verified `retired: true` | standalone (unenrollment complete) |
|
|
11
|
+
| absent | present | absent, unreadable or incomplete | refuse; Open Devlyn to repair its storage registration |
|
|
12
|
+
|
|
13
|
+
An unfinished retirement takes precedence over enrollment; follow its
|
|
14
|
+
operation-specific retained-helper resume to reconcile or cancel as named.
|
|
15
|
+
The journal `storage-client-unenroll.json` is read no-follow for retirement
|
|
16
|
+
recovery and the last two rows. Schema 1, canonical UUIDv7 operation, permanent `storage-client-fence.json`,
|
|
17
|
+
matching operation/root/lock identities and Rust's archive ordering/path
|
|
18
|
+
validation are required. If native root/lock identities or the home path changed,
|
|
19
|
+
completed retirement permits standalone only when `storage`, `control`,
|
|
20
|
+
`harness/state.json` and `storage-client.json` are all absent under the current
|
|
21
|
+
lifecycle root (checked without following links). Live inventory requires repair.
|
|
22
|
+
No repository-wide receipt scan selects mode. Other keys can allocate after
|
|
23
|
+
completed unenrollment; a leftover managed key retains its own recovery command.
|
|
24
|
+
|
|
25
|
+
Existing operations keep their managed identity. Receipts pin the control
|
|
26
|
+
identity; current enrollment verifies the helper before allocation. Recovery uses
|
|
27
|
+
the current verified `storage-client-recovery/pins.json`, including after uninstall
|
|
28
|
+
or unenrollment, and requires its authority binding to match the receipt, except
|
|
29
|
+
for a proven moved task: use its unflagged, operation-specific `recover-storage`
|
|
30
|
+
resume to release it, retain its files, and archive its receipt under `moved/`. Missing
|
|
31
|
+
or invalid pins retain resources and require repair; saved artifacts are never a
|
|
32
|
+
fallback. Enrolled checkout preserves
|
|
33
|
+
`managed_pending`/`managed_owned` and Rust's `storage-allocation.json` subreceipt.
|
|
34
|
+
After an interrupted reply, use the returned `recover-storage` resume. Never
|
|
35
|
+
delete managed receipts or replace an unfinished operation ID.
|
|
36
|
+
|
|
37
|
+
Owner `recover-storage --receipt <receipt.json> --operation-id <saved ID>
|
|
38
|
+
--for-removal` fences allocation and reconciles closure and the exact cache set.
|
|
39
|
+
Never-published allocations are abandoned. Accepted LOCAL_ONLY, FAILED, and PRs
|
|
40
|
+
observed CLOSED without merge share custody/recovery-ref verification and fenced
|
|
41
|
+
removal, retaining task branches and a durable terminal delivery. Release writers
|
|
42
|
+
with `complete --writers-stopped` first. A queue-produced FAILED checkout is
|
|
43
|
+
advanced by `read-tree -m -u <source_sha> <publish_sha>` only when HEAD is at the
|
|
44
|
+
attached publish commit and the index and worktree are exactly at source; other
|
|
45
|
+
differences refuse as dirty.
|
|
46
|
+
|
|
47
|
+
Removal-finished receipts with final COMPLETE, LOCAL_ONLY, FAILED or CLOSED
|
|
48
|
+
delivery move under `devlyn-completion/retired/`, regardless of scratch status.
|
|
49
|
+
Retirement moves scratch and its saved cleanup record with the audit and deletes
|
|
50
|
+
nothing. `complete --writers-stopped` saves the writer release there and retries
|
|
51
|
+
pending scratch, including an interrupted cleanup with no saved result; the next
|
|
52
|
+
allocation retries completed deliveries with released writers. `clean-scratch`
|
|
53
|
+
remains available there and never downgrades CLEAN.
|
|
54
|
+
Saved managed `complete`, `recover-storage` and `clean-scratch` resumes carry an
|
|
55
|
+
operation ID validated under the receipt lock. Without an ID, a locator selects
|
|
56
|
+
its active receipt. An ID selects only that operation, including its verified
|
|
57
|
+
retired receipt; a mismatched ID refuses. Retired paths also remain usable.
|
|
58
|
+
A new allocation may reuse a retired key without disturbing its audit.
|
|
59
|
+
|
|
60
|
+
Retained submodule repositories in the admin directory block removal even when
|
|
61
|
+
the checkout is absent; preserve/publish their source and explicitly resolve
|
|
62
|
+
them. Manual checkout removal is advised only after portable source checks and
|
|
63
|
+
record preservation succeed; changed or new source requires retention. If native
|
|
64
|
+
Windows or unobservable writers then require retention, stop all
|
|
65
|
+
writers, remove the task checkout by hand, keep its branch and receipt, then run
|
|
66
|
+
the returned owner-removal resume. The absent-tree path completes the fenced
|
|
67
|
+
registration removal. Scratch cleanup requires positive allocation closure,
|
|
68
|
+
without a cache-removal fence; actual checkout removal owns that fence, including
|
|
69
|
+
empty cache sets. Missing helpers or uncertain closure retain resources with a
|
|
70
|
+
repair/resume action. Compatible recovery artifacts and Rust's LaunchGate remain
|
|
71
|
+
required while managed obligations exist.
|
|
72
|
+
|
|
73
|
+
Managed allocations currently start cold; `build_dirs` retains each returned cache
|
|
74
|
+
and publication lease ID. Managed warming is deferred. Standalone allocations keep
|
|
75
|
+
the warming behavior in [task-completion.md](task-completion.md).
|
|
76
|
+
|
|
77
|
+
For managed receipts, use the saved `clean-scratch` resume with `--operation-id <saved ID>`.
|
|
78
|
+
|
|
79
|
+
New managed allocations use the recovery ref `refs/devlyn/completed/<id>.<operation>` so branch reuse
|
|
80
|
+
preserves earlier recovery refs.
|
|
@@ -327,7 +327,9 @@ if os.name == "nt":
|
|
|
327
327
|
self.creating = False
|
|
328
328
|
self.interrupted_signal = None
|
|
329
329
|
|
|
330
|
-
def start(self, argv, stdin):
|
|
330
|
+
def start(self, argv, stdin, **popen):
|
|
331
|
+
"""Launch argv, a list or a command line passed verbatim, inside this job; `popen` passes cwd, env and
|
|
332
|
+
streams to the enclosed bootstrap, whose target inherits them."""
|
|
331
333
|
self.handle = _kernel.CreateJobObjectW(None, None)
|
|
332
334
|
limits = _ExtendedLimits()
|
|
333
335
|
limits.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE; no breakaway.
|
|
@@ -346,13 +348,15 @@ if os.name == "nt":
|
|
|
346
348
|
os.set_handle_inheritable(handle, True)
|
|
347
349
|
startup = subprocess.STARTUPINFO()
|
|
348
350
|
startup.lpAttributeList = {"handle_list": inherited}
|
|
351
|
+
# A list travels element by element, a command line verbatim as one element; a mode word tells which.
|
|
349
352
|
command = [sys.executable, "-I", "-S", str(Path(__file__).resolve()), "--job-bootstrap",
|
|
350
|
-
str(inherited[0]), str(inherited[2]), str(inherited[1]),
|
|
353
|
+
str(inherited[0]), str(inherited[2]), str(inherited[1]),
|
|
354
|
+
*(("line", argv) if isinstance(argv, str) else ("argv", *argv))]
|
|
351
355
|
# Retain the Popen object even if __init__ raises after native creation.
|
|
352
356
|
self.child = subprocess.Popen.__new__(subprocess.Popen)
|
|
353
357
|
self.creating = True
|
|
354
358
|
try:
|
|
355
|
-
self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True)
|
|
359
|
+
self.child.__init__(command, stdin=stdin, startupinfo=startup, close_fds=True, **popen)
|
|
356
360
|
finally:
|
|
357
361
|
self.creating = False
|
|
358
362
|
if self.interrupted_signal is not None:
|
|
@@ -424,6 +428,11 @@ if os.name == "nt":
|
|
|
424
428
|
|
|
425
429
|
def _job_bootstrap(argv):
|
|
426
430
|
job, allow, ready = map(int, argv[:3])
|
|
431
|
+
mode, command = argv[3], argv[4:]
|
|
432
|
+
if mode == "line":
|
|
433
|
+
command, = command
|
|
434
|
+
elif mode != "argv":
|
|
435
|
+
raise ValueError(f"unknown job target mode {mode!r}")
|
|
427
436
|
with contextlib.ExitStack() as handles:
|
|
428
437
|
allow_fd = msvcrt.open_osfhandle(allow, os.O_RDONLY | os.O_BINARY)
|
|
429
438
|
handles.callback(os.close, allow_fd)
|
|
@@ -436,50 +445,77 @@ if os.name == "nt":
|
|
|
436
445
|
os.write(ready_fd, b"R")
|
|
437
446
|
if _read_control(allow_fd, 1) != b"G":
|
|
438
447
|
raise OSError("Windows job launch was not authorized")
|
|
439
|
-
with subprocess.Popen(
|
|
448
|
+
with subprocess.Popen(command, stdin=sys.stdin.buffer, close_fds=True) as target:
|
|
440
449
|
os.write(ready_fd, target.pid.to_bytes(4, "little"))
|
|
441
450
|
return target.wait()
|
|
442
451
|
|
|
443
452
|
|
|
453
|
+
def _signal_group(child, sig):
|
|
454
|
+
"""Send `sig` to the child's process group; False once it has no member this process may signal. An exited leader
|
|
455
|
+
is reaped first: until then it still counts as a member."""
|
|
456
|
+
child.poll()
|
|
457
|
+
try:
|
|
458
|
+
os.killpg(child.pid, sig)
|
|
459
|
+
except ProcessLookupError:
|
|
460
|
+
return False
|
|
461
|
+
except PermissionError: # EPERM: no member this process may signal; with the leader alive the run cannot be ended.
|
|
462
|
+
if child.poll() is None:
|
|
463
|
+
raise
|
|
464
|
+
return False
|
|
465
|
+
return True
|
|
466
|
+
|
|
467
|
+
|
|
444
468
|
def terminate_tree(child, job=None):
|
|
445
469
|
if job is not None:
|
|
446
470
|
job.terminate()
|
|
447
471
|
return
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
time.sleep(max(0, deadline - time.monotonic()))
|
|
460
|
-
try:
|
|
461
|
-
os.killpg(child.pid, signal.SIGKILL)
|
|
462
|
-
except ProcessLookupError:
|
|
463
|
-
pass # Every member already exited during the grace period.
|
|
464
|
-
child.wait()
|
|
472
|
+
# SIGTERM and up to 5 s for the group to leave, then SIGKILL and about 1 s more; what still answers then is dead but
|
|
473
|
+
# unreaped or may not be signalled, which cannot be told apart. A member this process cannot signal, or one that left
|
|
474
|
+
# the session, is not owned; acceptance.py's evidence is safe from it because no check process holds an evidence file.
|
|
475
|
+
for sig, seconds in ((signal.SIGTERM, 5), (signal.SIGKILL, 1)):
|
|
476
|
+
deadline = time.monotonic() + seconds
|
|
477
|
+
alive = _signal_group(child, sig)
|
|
478
|
+
while alive and time.monotonic() < deadline:
|
|
479
|
+
time.sleep(0.01)
|
|
480
|
+
alive = _signal_group(child, 0)
|
|
481
|
+
if not alive:
|
|
482
|
+
return
|
|
465
483
|
|
|
466
484
|
|
|
467
|
-
def run_process(argv, stdin, seconds, *, heartbeat=0):
|
|
485
|
+
def run_process(argv, stdin, seconds, *, heartbeat=0, on_timeout=124, **popen):
|
|
486
|
+
"""Run argv as an owned tree (its own POSIX session or Windows job), ended whole however the run ends; `popen`
|
|
487
|
+
passes cwd, env and streams. Returns the exit status, or `on_timeout` once `seconds` pass. On Windows argv may be a
|
|
488
|
+
command line passed verbatim."""
|
|
468
489
|
child = None
|
|
469
490
|
job = _WindowsJob() if os.name == "nt" else None
|
|
470
491
|
previous = {}
|
|
492
|
+
received = [] # Every handled signal; the first ends the run with 128 + it once the tree has ended.
|
|
493
|
+
deferring = False # While set, a signal only waits: a POSIX child's creation and the teardown are not interrupted.
|
|
471
494
|
|
|
472
495
|
def interrupted(signum, _frame):
|
|
496
|
+
received.append(signum)
|
|
473
497
|
# CPython stores the native handle only after CreateProcess's finally block.
|
|
474
498
|
if job is not None and job.creating:
|
|
475
499
|
job.interrupted_signal = signum
|
|
476
|
-
|
|
477
|
-
|
|
500
|
+
elif not deferring:
|
|
501
|
+
raise SystemExit(128 + signum)
|
|
478
502
|
|
|
479
503
|
try:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
504
|
+
handled = [signal.SIGTERM, signal.SIGINT]
|
|
505
|
+
if os.name != "nt" and signal.getsignal(signal.SIGHUP) is not signal.SIG_IGN:
|
|
506
|
+
# A closed terminal hangs up the runner, not the child's own session; an inherited ignore (nohup) stays.
|
|
507
|
+
handled.append(signal.SIGHUP)
|
|
508
|
+
for sig in handled:
|
|
509
|
+
previous[sig] = signal.getsignal(sig) # Saved first: a signal right after installation finds it to restore.
|
|
510
|
+
signal.signal(sig, interrupted)
|
|
511
|
+
if job is not None:
|
|
512
|
+
child = job.start(argv, stdin, **popen)
|
|
513
|
+
else:
|
|
514
|
+
deferring = True # Raised inside Popen, a signal would leave the new session unowned.
|
|
515
|
+
child = subprocess.Popen(argv, stdin=stdin, start_new_session=True, **popen)
|
|
516
|
+
deferring = False
|
|
517
|
+
if received:
|
|
518
|
+
raise SystemExit(128 + received[0])
|
|
483
519
|
started = time.monotonic()
|
|
484
520
|
next_heartbeat = started + heartbeat
|
|
485
521
|
if heartbeat:
|
|
@@ -492,17 +528,20 @@ def run_process(argv, stdin, seconds, *, heartbeat=0):
|
|
|
492
528
|
if seconds and now - started >= seconds:
|
|
493
529
|
if heartbeat:
|
|
494
530
|
print(f"[codex-monitored] timeout: elapsed={int(now-started)}s limit={seconds}s", file=sys.stderr, flush=True)
|
|
495
|
-
return
|
|
531
|
+
return on_timeout
|
|
496
532
|
if heartbeat and now >= next_heartbeat:
|
|
497
533
|
print(f"[codex-monitored] heartbeat: elapsed={int(now-started)}s", file=sys.stderr, flush=True)
|
|
498
534
|
next_heartbeat = now + heartbeat
|
|
499
535
|
finally:
|
|
536
|
+
deferring = True # Teardown runs to its end.
|
|
500
537
|
try:
|
|
501
538
|
if job is not None or child is not None:
|
|
502
539
|
terminate_tree(child, job)
|
|
503
540
|
finally:
|
|
504
541
|
for sig, handler in previous.items():
|
|
505
542
|
signal.signal(sig, handler)
|
|
543
|
+
if received: # Whenever it came and whatever teardown raised, a signal ends the run.
|
|
544
|
+
raise SystemExit(128 + received[0])
|
|
506
545
|
|
|
507
546
|
|
|
508
547
|
if __name__ == "__main__" and os.name == "nt":
|