@crouton-kit/crouter 0.3.3 → 0.3.8
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 +2 -2
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
- package/dist/cli.js +6 -6
- package/dist/commands/__tests__/skill.test.js +24 -28
- package/dist/commands/{flow.d.ts → agent.d.ts} +1 -1
- package/dist/commands/agent.js +384 -0
- package/dist/commands/debug.d.ts +1 -1
- package/dist/commands/debug.js +7 -7
- package/dist/commands/job.js +54 -379
- package/dist/commands/plan.d.ts +1 -1
- package/dist/commands/plan.js +11 -11
- package/dist/commands/skill.js +114 -107
- package/dist/commands/spec.d.ts +1 -1
- package/dist/commands/spec.js +11 -11
- package/dist/core/__tests__/job.test.js +38 -74
- package/dist/core/__tests__/jobs.test.d.ts +1 -0
- package/dist/core/__tests__/jobs.test.js +66 -0
- package/dist/core/__tests__/resolver.test.d.ts +1 -0
- package/dist/core/__tests__/resolver.test.js +113 -0
- package/dist/core/config.js +20 -2
- package/dist/core/jobs.d.ts +26 -12
- package/dist/core/jobs.js +151 -42
- package/dist/core/resolver.d.ts +1 -2
- package/dist/core/resolver.js +60 -46
- package/dist/core/spawn.d.ts +26 -3
- package/dist/core/spawn.js +144 -11
- package/dist/prompts/agent.d.ts +3 -3
- package/dist/prompts/agent.js +20 -18
- package/dist/prompts/debug.js +14 -7
- package/dist/prompts/skill.js +16 -16
- package/dist/types.d.ts +1 -1
- package/dist/types.js +2 -2
- package/package.json +2 -2
- package/dist/commands/flow.js +0 -24
package/dist/core/jobs.js
CHANGED
|
@@ -6,7 +6,9 @@
|
|
|
6
6
|
// Layout: ${XDG_STATE_HOME or ~/.local/state}/crtr/jobs/<job_id>/
|
|
7
7
|
// meta.json — written atomically on create; updated atomically on terminal transition.
|
|
8
8
|
// log.jsonl — append-only event log.
|
|
9
|
-
// result.
|
|
9
|
+
// result.md — agent submissions (markdown body + YAML frontmatter). Written atomically.
|
|
10
|
+
// result.json — programmatic submissions (structured object). Written atomically.
|
|
11
|
+
// Either result file's APPEARANCE is the completion signal. Exactly one is written per job.
|
|
10
12
|
import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, } from 'node:fs';
|
|
11
13
|
import { watch } from 'node:fs';
|
|
12
14
|
import { spawnSync } from 'node:child_process';
|
|
@@ -31,9 +33,22 @@ function metaPath(jobId) {
|
|
|
31
33
|
function logPath(jobId) {
|
|
32
34
|
return join(jobDir(jobId), 'log.jsonl');
|
|
33
35
|
}
|
|
34
|
-
function
|
|
36
|
+
function resultJsonPath(jobId) {
|
|
35
37
|
return join(jobDir(jobId), 'result.json');
|
|
36
38
|
}
|
|
39
|
+
function resultMdPath(jobId) {
|
|
40
|
+
return join(jobDir(jobId), 'result.md');
|
|
41
|
+
}
|
|
42
|
+
/** Path of whichever result file currently exists, or null if neither does. */
|
|
43
|
+
function existingResultPath(jobId) {
|
|
44
|
+
const md = resultMdPath(jobId);
|
|
45
|
+
if (existsSync(md))
|
|
46
|
+
return md;
|
|
47
|
+
const js = resultJsonPath(jobId);
|
|
48
|
+
if (existsSync(js))
|
|
49
|
+
return js;
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
37
52
|
// ---------------------------------------------------------------------------
|
|
38
53
|
// Internal helpers
|
|
39
54
|
// ---------------------------------------------------------------------------
|
|
@@ -129,9 +144,9 @@ export function appendEvent(jobId, event) {
|
|
|
129
144
|
appendFileSync(p, JSON.stringify(line) + '\n', 'utf8');
|
|
130
145
|
}
|
|
131
146
|
/**
|
|
132
|
-
* Atomically write result.json and update meta.json status.
|
|
133
|
-
*
|
|
134
|
-
* log content.
|
|
147
|
+
* Atomically write result.json (structured object) and update meta.json status.
|
|
148
|
+
* Used by programmatic callers (human, sys) that produce object results.
|
|
149
|
+
* The result file's appearance is the completion signal — never inferred from log content.
|
|
135
150
|
*/
|
|
136
151
|
export function writeResult(jobId, result, terminalStatus) {
|
|
137
152
|
const dir = jobDir(jobId);
|
|
@@ -143,44 +158,124 @@ export function writeResult(jobId, result, terminalStatus) {
|
|
|
143
158
|
result,
|
|
144
159
|
written_at: new Date().toISOString(),
|
|
145
160
|
};
|
|
146
|
-
// Atomic write: tmp + rename within same directory (same fs, rename is atomic).
|
|
147
161
|
const tmp = join(dir, '.result.tmp');
|
|
148
162
|
writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
|
|
149
|
-
renameSync(tmp,
|
|
150
|
-
|
|
163
|
+
renameSync(tmp, resultJsonPath(jobId));
|
|
164
|
+
const meta = readMeta(jobId);
|
|
165
|
+
meta.status = terminalStatus;
|
|
166
|
+
writeMeta(jobId, meta);
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
|
|
170
|
+
* Used by `crtr job submit` for agent-driven markdown results.
|
|
171
|
+
*/
|
|
172
|
+
export function writeMarkdownResult(jobId, body, terminalStatus, reason) {
|
|
173
|
+
const dir = jobDir(jobId);
|
|
174
|
+
if (!existsSync(dir)) {
|
|
175
|
+
throw notFound(`job not found: ${jobId}`, { job_id: jobId });
|
|
176
|
+
}
|
|
177
|
+
const fm = {
|
|
178
|
+
status: terminalStatus,
|
|
179
|
+
written_at: new Date().toISOString(),
|
|
180
|
+
};
|
|
181
|
+
if (reason !== undefined && reason !== '') {
|
|
182
|
+
fm.reason = reason;
|
|
183
|
+
}
|
|
184
|
+
const content = `${renderFrontmatter(fm)}${body}`;
|
|
185
|
+
const tmp = join(dir, '.result.tmp');
|
|
186
|
+
writeFileSync(tmp, content, 'utf8');
|
|
187
|
+
renameSync(tmp, resultMdPath(jobId));
|
|
151
188
|
const meta = readMeta(jobId);
|
|
152
189
|
meta.status = terminalStatus;
|
|
153
190
|
writeMeta(jobId, meta);
|
|
154
191
|
}
|
|
155
192
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* Race safety: registers the watcher THEN re-stats. If result.json appeared
|
|
160
|
-
* between the first stat and the watch registration, the re-stat catches it
|
|
161
|
-
* before the watcher has a chance to miss it.
|
|
193
|
+
* Render a small fixed-shape frontmatter block. We control writer and reader,
|
|
194
|
+
* so a 3-key hand-rolled emitter is plenty — no YAML dep, no escaping surprises.
|
|
195
|
+
* Values are plain strings; we double-quote `reason` to survive newlines/colons.
|
|
162
196
|
*/
|
|
197
|
+
function renderFrontmatter(fm) {
|
|
198
|
+
const lines = ['---', `status: ${fm.status}`, `written_at: ${fm.written_at}`];
|
|
199
|
+
if (fm.reason !== undefined) {
|
|
200
|
+
const escaped = fm.reason.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
|
|
201
|
+
lines.push(`reason: "${escaped}"`);
|
|
202
|
+
}
|
|
203
|
+
lines.push('---', '');
|
|
204
|
+
return lines.join('\n');
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Parse the small fixed-shape frontmatter we emit. Tolerant of trailing
|
|
208
|
+
* whitespace; returns `{ frontmatter, body }`. Throws if the document does not
|
|
209
|
+
* start with `---\n` or no closing `---` is found.
|
|
210
|
+
*/
|
|
211
|
+
function parseMarkdownResult(raw) {
|
|
212
|
+
if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
|
|
213
|
+
throw new Error('result.md missing opening --- delimiter');
|
|
214
|
+
}
|
|
215
|
+
const afterOpen = raw.indexOf('\n') + 1;
|
|
216
|
+
const closeIdx = raw.indexOf('\n---', afterOpen);
|
|
217
|
+
if (closeIdx === -1) {
|
|
218
|
+
throw new Error('result.md missing closing --- delimiter');
|
|
219
|
+
}
|
|
220
|
+
const fmBlock = raw.slice(afterOpen, closeIdx);
|
|
221
|
+
// Body starts after the closing `---` line.
|
|
222
|
+
const afterCloseLine = raw.indexOf('\n', closeIdx + 1);
|
|
223
|
+
const body = afterCloseLine === -1 ? '' : raw.slice(afterCloseLine + 1);
|
|
224
|
+
const fm = {};
|
|
225
|
+
for (const line of fmBlock.split('\n')) {
|
|
226
|
+
const m = line.match(/^([a-z_]+):\s*(.*)$/);
|
|
227
|
+
if (m === null)
|
|
228
|
+
continue;
|
|
229
|
+
const key = m[1];
|
|
230
|
+
if (m[2] === undefined)
|
|
231
|
+
continue;
|
|
232
|
+
let val = m[2];
|
|
233
|
+
if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
|
|
234
|
+
val = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
|
|
235
|
+
}
|
|
236
|
+
if (key === 'status') {
|
|
237
|
+
fm.status = val;
|
|
238
|
+
}
|
|
239
|
+
else if (key === 'written_at') {
|
|
240
|
+
fm.written_at = val;
|
|
241
|
+
}
|
|
242
|
+
else if (key === 'reason') {
|
|
243
|
+
fm.reason = val;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (fm.status === undefined || fm.written_at === undefined) {
|
|
247
|
+
throw new Error('result.md frontmatter missing status or written_at');
|
|
248
|
+
}
|
|
249
|
+
return { frontmatter: fm, body };
|
|
250
|
+
}
|
|
163
251
|
export function readResult(jobId, opts = {}) {
|
|
164
252
|
const dir = jobDir(jobId);
|
|
165
253
|
if (!existsSync(dir)) {
|
|
166
254
|
throw notFound(`job not found: ${jobId}`, { job_id: jobId });
|
|
167
255
|
}
|
|
168
|
-
function
|
|
169
|
-
const raw = readFileSync(
|
|
256
|
+
function parseAt(path) {
|
|
257
|
+
const raw = readFileSync(path, 'utf8');
|
|
258
|
+
if (path.endsWith('.md')) {
|
|
259
|
+
const { frontmatter, body } = parseMarkdownResult(raw);
|
|
260
|
+
const out = { status: frontmatter.status, result_md: body };
|
|
261
|
+
if (frontmatter.reason !== undefined) {
|
|
262
|
+
out.reason = frontmatter.reason;
|
|
263
|
+
}
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
170
266
|
const parsed = JSON.parse(raw);
|
|
171
267
|
return { status: parsed.status, result: parsed.result };
|
|
172
268
|
}
|
|
173
|
-
|
|
174
|
-
if (
|
|
175
|
-
|
|
176
|
-
return Promise.resolve({ status: r.status, result: r.result });
|
|
269
|
+
const existing = existingResultPath(jobId);
|
|
270
|
+
if (existing !== null) {
|
|
271
|
+
return Promise.resolve(parseAt(existing));
|
|
177
272
|
}
|
|
178
273
|
if (opts.waitMs === undefined || opts.waitMs <= 0) {
|
|
179
274
|
return Promise.resolve({ status: 'timeout' });
|
|
180
275
|
}
|
|
181
276
|
return new Promise((resolve) => {
|
|
182
277
|
let settled = false;
|
|
183
|
-
const finish = (
|
|
278
|
+
const finish = (response) => {
|
|
184
279
|
if (settled)
|
|
185
280
|
return;
|
|
186
281
|
settled = true;
|
|
@@ -189,41 +284,47 @@ export function readResult(jobId, opts = {}) {
|
|
|
189
284
|
watcher.close();
|
|
190
285
|
}
|
|
191
286
|
catch { /* noop */ }
|
|
192
|
-
resolve(
|
|
287
|
+
resolve(response);
|
|
193
288
|
};
|
|
194
|
-
// Register watcher first, then re-stat (race safety).
|
|
195
289
|
const watcher = watch(dir, (_event, name) => {
|
|
196
|
-
if (name
|
|
197
|
-
|
|
198
|
-
|
|
290
|
+
if (name !== 'result.md' && name !== 'result.json')
|
|
291
|
+
return;
|
|
292
|
+
const path = existingResultPath(jobId);
|
|
293
|
+
if (path !== null) {
|
|
294
|
+
finish(parseAt(path));
|
|
199
295
|
}
|
|
200
296
|
});
|
|
201
|
-
|
|
202
|
-
if (
|
|
203
|
-
|
|
204
|
-
finish(r.status, r.result);
|
|
297
|
+
const path = existingResultPath(jobId);
|
|
298
|
+
if (path !== null) {
|
|
299
|
+
finish(parseAt(path));
|
|
205
300
|
return;
|
|
206
301
|
}
|
|
207
302
|
const timer = setTimeout(() => {
|
|
208
|
-
finish('timeout');
|
|
303
|
+
finish({ status: 'timeout' });
|
|
209
304
|
}, opts.waitMs);
|
|
210
305
|
});
|
|
211
306
|
}
|
|
212
307
|
/**
|
|
213
|
-
* Derive job state from meta.json, result
|
|
214
|
-
* If a pid is recorded, is not alive, and no result
|
|
308
|
+
* Derive job state from meta.json, the result file, and the tail of log.jsonl.
|
|
309
|
+
* If a pid is recorded, is not alive, and no result file exists → 'failed'.
|
|
215
310
|
*/
|
|
216
311
|
export function jobStatus(jobId) {
|
|
217
312
|
const meta = readMeta(jobId);
|
|
218
313
|
const age_s = (Date.now() - new Date(meta.created_at).getTime()) / 1000;
|
|
219
|
-
// Derive effective state.
|
|
220
314
|
let state = meta.status;
|
|
221
315
|
if (state === 'live') {
|
|
222
|
-
|
|
223
|
-
|
|
316
|
+
const existing = existingResultPath(jobId);
|
|
317
|
+
if (existing !== null) {
|
|
318
|
+
// Result file present but meta not yet updated (rare); trust the file.
|
|
224
319
|
try {
|
|
225
|
-
|
|
226
|
-
|
|
320
|
+
if (existing.endsWith('.md')) {
|
|
321
|
+
const { frontmatter } = parseMarkdownResult(readFileSync(existing, 'utf8'));
|
|
322
|
+
state = frontmatter.status;
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
const r = JSON.parse(readFileSync(existing, 'utf8'));
|
|
326
|
+
state = r.status;
|
|
327
|
+
}
|
|
227
328
|
}
|
|
228
329
|
catch { /* leave as live */ }
|
|
229
330
|
}
|
|
@@ -271,12 +372,20 @@ export function listJobs() {
|
|
|
271
372
|
if (!existsSync(mp))
|
|
272
373
|
continue;
|
|
273
374
|
const meta = JSON.parse(readFileSync(mp, 'utf8'));
|
|
274
|
-
// Derive effective state (result
|
|
375
|
+
// Derive effective state (result file beats meta.status for live jobs).
|
|
275
376
|
let state = meta.status;
|
|
276
|
-
if (state === 'live'
|
|
377
|
+
if (state === 'live') {
|
|
378
|
+
const mdP = join(dir, 'result.md');
|
|
379
|
+
const jsP = join(dir, 'result.json');
|
|
277
380
|
try {
|
|
278
|
-
|
|
279
|
-
|
|
381
|
+
if (existsSync(mdP)) {
|
|
382
|
+
const { frontmatter } = parseMarkdownResult(readFileSync(mdP, 'utf8'));
|
|
383
|
+
state = frontmatter.status;
|
|
384
|
+
}
|
|
385
|
+
else if (existsSync(jsP)) {
|
|
386
|
+
const r = JSON.parse(readFileSync(jsP, 'utf8'));
|
|
387
|
+
state = r.status;
|
|
388
|
+
}
|
|
280
389
|
}
|
|
281
390
|
catch { /* leave as live */ }
|
|
282
391
|
}
|
package/dist/core/resolver.d.ts
CHANGED
|
@@ -22,8 +22,7 @@ export interface SkillResolutionOpts {
|
|
|
22
22
|
export declare function resolveSkill(rawName: string, opts?: SkillResolutionOpts): Skill;
|
|
23
23
|
export interface ParsedSkillQualifier {
|
|
24
24
|
scope?: Scope;
|
|
25
|
-
|
|
26
|
-
name: string;
|
|
25
|
+
segments: string[];
|
|
27
26
|
}
|
|
28
27
|
export declare function parseSkillQualifier(raw: string): ParsedSkillQualifier;
|
|
29
28
|
export declare function listInstalledMarketplaces(scope: Scope): InstalledMarketplace[];
|
package/dist/core/resolver.js
CHANGED
|
@@ -5,6 +5,7 @@ import { listDirs, pathExists, readText, walkFiles, } from './fs-utils.js';
|
|
|
5
5
|
import { readMarketplaceManifest, readPluginManifest } from './manifest.js';
|
|
6
6
|
import { parseFrontmatter } from './frontmatter.js';
|
|
7
7
|
import { ambiguous, notFound, usage } from './errors.js';
|
|
8
|
+
import { InputError } from './io.js';
|
|
8
9
|
import { builtinSkillsRoot, marketplacesDir, pluginsDir, projectScopeRoot, scopeSkillsDir, userScopeRoot, } from './scope.js';
|
|
9
10
|
function getBuiltinPlugin() {
|
|
10
11
|
const root = builtinSkillsRoot();
|
|
@@ -202,30 +203,46 @@ export function resolveSkill(rawName, opts = {}) {
|
|
|
202
203
|
if (parsed.scope && opts.scope && parsed.scope !== opts.scope) {
|
|
203
204
|
throw usage(`scope conflict: identifier "${rawName}" uses scope "${parsed.scope}" but --scope is "${opts.scope}"`);
|
|
204
205
|
}
|
|
205
|
-
if (parsed.plugin && opts.pluginFilter && parsed.plugin !== opts.pluginFilter) {
|
|
206
|
-
throw usage(`plugin conflict: identifier "${rawName}" uses plugin "${parsed.plugin}" but --plugin is "${opts.pluginFilter}"`);
|
|
207
|
-
}
|
|
208
206
|
const effectiveScope = opts.scope ?? parsed.scope;
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
207
|
+
// Lookup-based disambiguation: if segments[0] matches an installed plugin, treat it as plugin.
|
|
208
|
+
// Otherwise the entire segments array is the skill path under the scope-direct plugin.
|
|
209
|
+
let pluginQualifier;
|
|
210
|
+
let skillName;
|
|
211
|
+
if (parsed.segments.length === 0) {
|
|
212
|
+
throw usage(`skill name required`);
|
|
213
|
+
}
|
|
214
|
+
if (opts.pluginFilter !== undefined) {
|
|
215
|
+
// Explicit plugin filter overrides disambiguation.
|
|
216
|
+
pluginQualifier = opts.pluginFilter;
|
|
217
|
+
skillName = parsed.segments.join('/');
|
|
218
|
+
}
|
|
219
|
+
else if (parsed.segments.length > 1) {
|
|
220
|
+
const maybePlugin = parsed.segments[0];
|
|
221
|
+
const pluginMatch = findPluginByName(maybePlugin, effectiveScope) ??
|
|
222
|
+
(effectiveScope === undefined ? null : findPluginByName(maybePlugin));
|
|
223
|
+
if (pluginMatch !== null) {
|
|
224
|
+
pluginQualifier = maybePlugin;
|
|
225
|
+
skillName = parsed.segments.slice(1).join('/');
|
|
226
|
+
}
|
|
227
|
+
else {
|
|
228
|
+
pluginQualifier = undefined;
|
|
229
|
+
skillName = parsed.segments.join('/');
|
|
224
230
|
}
|
|
225
231
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
232
|
+
else {
|
|
233
|
+
pluginQualifier = undefined;
|
|
234
|
+
skillName = parsed.segments[0];
|
|
235
|
+
}
|
|
236
|
+
if (pluginQualifier && opts.pluginFilter && pluginQualifier !== opts.pluginFilter) {
|
|
237
|
+
throw usage(`plugin conflict: identifier "${rawName}" uses plugin "${pluginQualifier}" but --plugin is "${opts.pluginFilter}"`);
|
|
238
|
+
}
|
|
239
|
+
const effectivePluginFilter = opts.pluginFilter ?? pluginQualifier;
|
|
240
|
+
const direct = findSkillMatches(skillName, pluginQualifier, effectiveScope, effectivePluginFilter);
|
|
241
|
+
if (direct.length > 0)
|
|
242
|
+
return pickMatch(direct, skillName, pluginQualifier);
|
|
243
|
+
throw notFound(formatNotFoundMessage(rawName, skillName, pluginQualifier), {
|
|
244
|
+
skill: skillName,
|
|
245
|
+
plugin: pluginQualifier,
|
|
229
246
|
scope: parsed.scope,
|
|
230
247
|
});
|
|
231
248
|
}
|
|
@@ -304,13 +321,13 @@ function pickMatch(matches, name, pluginQualifier) {
|
|
|
304
321
|
})),
|
|
305
322
|
});
|
|
306
323
|
}
|
|
307
|
-
function formatNotFoundMessage(rawName,
|
|
308
|
-
const suggestions = suggestSkills(
|
|
324
|
+
function formatNotFoundMessage(rawName, skillName, pluginQualifier) {
|
|
325
|
+
const suggestions = suggestSkills(skillName, pluginQualifier);
|
|
309
326
|
const lines = [`skill not found: ${rawName}`];
|
|
310
|
-
lines.push(' expected forms: <name>, <plugin
|
|
327
|
+
lines.push(' expected forms: <name>, <plugin>/<name>, <scope>/<name>, <scope>/<plugin>/<name>');
|
|
311
328
|
if (suggestions.length > 0) {
|
|
312
329
|
const formatted = suggestions
|
|
313
|
-
.map((s) => s.plugin === SCOPE_SKILL_PLUGIN ? s.name : `${s.plugin}
|
|
330
|
+
.map((s) => s.plugin === SCOPE_SKILL_PLUGIN ? s.name : `${s.plugin}/${s.name}`)
|
|
314
331
|
.slice(0, 3);
|
|
315
332
|
lines.push(` did you mean: ${formatted.join(', ')}`);
|
|
316
333
|
}
|
|
@@ -386,29 +403,26 @@ function editDistance(a, b) {
|
|
|
386
403
|
const SCOPE_QUALIFIERS = new Set(['user', 'project']);
|
|
387
404
|
// Accepted identifier forms:
|
|
388
405
|
// <name> — bare name; scope-root first, then plugins
|
|
389
|
-
// <plugin
|
|
390
|
-
// <scope
|
|
391
|
-
// <scope
|
|
392
|
-
// Bare `<plugin>/<name>` (no colon) is handled as a fallback inside resolveSkill.
|
|
406
|
+
// <plugin>/<name> — explicit plugin (plugin may contain slashes)
|
|
407
|
+
// <scope>/<name> — scope-root in a specific scope
|
|
408
|
+
// <scope>/<plugin>/<name> — fully qualified; plugin-vs-path disambiguation is lookup-based in resolveSkill
|
|
393
409
|
export function parseSkillQualifier(raw) {
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
}
|
|
409
|
-
return { scope, name: after };
|
|
410
|
+
if (raw.includes(':')) {
|
|
411
|
+
const suggested = raw.replace(/:/g, '/');
|
|
412
|
+
throw new InputError({
|
|
413
|
+
error: 'invalid_qualifier',
|
|
414
|
+
message: "mixed separators ':' and '/' no longer supported; use slashes throughout",
|
|
415
|
+
received: raw,
|
|
416
|
+
field: 'name',
|
|
417
|
+
next: `Use ${suggested}.`,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
const segments = raw.split('/');
|
|
421
|
+
if (SCOPE_QUALIFIERS.has(segments[0])) {
|
|
422
|
+
const scope = segments[0];
|
|
423
|
+
return { scope, segments: segments.slice(1) };
|
|
410
424
|
}
|
|
411
|
-
return {
|
|
425
|
+
return { segments };
|
|
412
426
|
}
|
|
413
427
|
function orderPluginsByResolution(plugins) {
|
|
414
428
|
const score = (p) => {
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface SpawnAgentOptions {
|
|
|
10
10
|
};
|
|
11
11
|
/** Max panes per tmux window before overflowing to a new window. */
|
|
12
12
|
maxPanesPerWindow: number;
|
|
13
|
+
/** Display name passed to `claude -n`; surfaces in pane title and /resume picker. */
|
|
14
|
+
name?: string;
|
|
13
15
|
}
|
|
14
16
|
export interface SpawnAgentResult {
|
|
15
17
|
status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
|
|
@@ -40,6 +42,9 @@ export interface DetachOptions {
|
|
|
40
42
|
* uses the attached client's currently-focused pane — which drifts if the
|
|
41
43
|
* user switches windows between kickoff and spawn. */
|
|
42
44
|
targetPane?: string;
|
|
45
|
+
/** Display name passed to `claude -n`; ignored when `command` is set
|
|
46
|
+
* (caller controls the full argv in that mode). */
|
|
47
|
+
name?: string;
|
|
43
48
|
}
|
|
44
49
|
export interface DetachResult {
|
|
45
50
|
status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
|
|
@@ -49,6 +54,19 @@ export interface DetachResult {
|
|
|
49
54
|
export declare function isInTmux(): boolean;
|
|
50
55
|
export declare function shellQuote(s: string): string;
|
|
51
56
|
export declare function countPanesInCurrentWindow(): number;
|
|
57
|
+
/**
|
|
58
|
+
* Find a window in the current tmux session with fewer than `maxPanesPerWindow`
|
|
59
|
+
* panes AND where every existing pane has `claude` as a foreground process.
|
|
60
|
+
* Prefers the active window so the spawned pane is visible to the user;
|
|
61
|
+
* otherwise falls back to the first other eligible window. Returns the tmux
|
|
62
|
+
* window id (e.g. `@5`) to pass via `-t`, or null if no window qualifies.
|
|
63
|
+
*
|
|
64
|
+
* Windows holding non-agent panes (dashboards, log tails, idle shells, editors,
|
|
65
|
+
* REPLs, etc.) are skipped so spawning never disrupts those workflows. A pane
|
|
66
|
+
* qualifies as long as `claude` is among its foreground commands — co-resident
|
|
67
|
+
* helpers like `caffeinate` don't disqualify it.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findWindowWithSpace(maxPanesPerWindow: number): string | null;
|
|
52
70
|
/**
|
|
53
71
|
* Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
|
|
54
72
|
* so the caller can return normally before the pane dies. No-op outside tmux
|
|
@@ -68,9 +86,14 @@ export declare function scheduleKillCurrentPane(delaySeconds: number): boolean;
|
|
|
68
86
|
*/
|
|
69
87
|
export declare function spawnAndDetach(opts: DetachOptions): DetachResult;
|
|
70
88
|
/**
|
|
71
|
-
* Async sibling spawn. Launches a claude session in a
|
|
72
|
-
*
|
|
73
|
-
* with the pane id; the parent stays alive.
|
|
89
|
+
* Async sibling spawn. Launches a claude session in a tmux pane, progressively
|
|
90
|
+
* filling existing windows up to `maxPanesPerWindow` before creating a new
|
|
91
|
+
* window. Returns immediately with the pane id; the parent stays alive.
|
|
92
|
+
*
|
|
93
|
+
* Placement order:
|
|
94
|
+
* 1. Current window, if it has space.
|
|
95
|
+
* 2. Any other window in the session with space.
|
|
96
|
+
* 3. New window (every existing window at capacity).
|
|
74
97
|
*
|
|
75
98
|
* If `fork` is set, uses `claude --resume <id> --fork-session`.
|
|
76
99
|
*/
|