@crouton-kit/crouter 0.2.6 → 0.3.2
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/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +9 -9
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +19 -19
- package/dist/cli.js +42 -37
- package/dist/commands/__tests__/human.test.d.ts +1 -0
- package/dist/commands/__tests__/human.test.js +214 -0
- package/dist/commands/__tests__/skill.test.d.ts +1 -0
- package/dist/commands/__tests__/skill.test.js +294 -0
- package/dist/commands/debug.d.ts +3 -0
- package/dist/commands/debug.js +179 -0
- package/dist/commands/flow.d.ts +2 -0
- package/dist/commands/flow.js +24 -0
- package/dist/commands/human.d.ts +2 -0
- package/dist/commands/human.js +480 -0
- package/dist/commands/job.d.ts +2 -0
- package/dist/commands/job.js +669 -0
- package/dist/commands/pkg.d.ts +2 -0
- package/dist/commands/pkg.js +1021 -0
- package/dist/commands/plan.d.ts +4 -2
- package/dist/commands/plan.js +306 -22
- package/dist/commands/skill.d.ts +2 -2
- package/dist/commands/skill.js +613 -456
- package/dist/commands/spec.d.ts +3 -2
- package/dist/commands/spec.js +283 -10
- package/dist/commands/sys.d.ts +2 -0
- package/dist/commands/sys.js +712 -0
- package/dist/core/__tests__/argv-parser.test.d.ts +1 -0
- package/dist/core/__tests__/argv-parser.test.js +199 -0
- package/dist/core/__tests__/flow-leaves.test.d.ts +1 -0
- package/dist/core/__tests__/flow-leaves.test.js +248 -0
- package/dist/core/__tests__/job.test.d.ts +1 -0
- package/dist/core/__tests__/job.test.js +346 -0
- package/dist/core/__tests__/pkg.test.d.ts +1 -0
- package/dist/core/__tests__/pkg.test.js +218 -0
- package/dist/core/__tests__/sys.test.d.ts +1 -0
- package/dist/core/__tests__/sys.test.js +208 -0
- package/dist/core/artifact.d.ts +29 -18
- package/dist/core/artifact.js +78 -221
- package/dist/core/auto-update.js +11 -4
- package/dist/core/command.d.ts +36 -0
- package/dist/core/command.js +287 -0
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.js +5 -0
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +4 -0
- package/dist/core/help.d.ts +98 -0
- package/dist/core/help.js +163 -0
- package/dist/core/io.d.ts +29 -0
- package/dist/core/io.js +83 -0
- package/dist/core/jobs.d.ts +87 -0
- package/dist/core/jobs.js +353 -0
- package/dist/core/pagination.d.ts +33 -0
- package/dist/core/pagination.js +89 -0
- package/dist/core/self-update.d.ts +21 -0
- package/dist/core/self-update.js +105 -0
- package/dist/core/spawn.d.ts +47 -65
- package/dist/core/spawn.js +78 -228
- package/dist/prompts/agent.d.ts +10 -5
- package/dist/prompts/agent.js +51 -74
- package/dist/prompts/debug.d.ts +8 -0
- package/dist/prompts/debug.js +37 -0
- package/dist/prompts/review.js +4 -11
- package/dist/prompts/skill.d.ts +0 -1
- package/dist/prompts/skill.js +95 -149
- package/package.json +4 -2
- package/dist/commands/agent.d.ts +0 -2
- package/dist/commands/agent.js +0 -265
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.js +0 -146
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js +0 -268
- package/dist/commands/marketplace.d.ts +0 -2
- package/dist/commands/marketplace.js +0 -365
- package/dist/commands/plugin.d.ts +0 -2
- package/dist/commands/plugin.js +0 -367
- package/dist/commands/update.d.ts +0 -4
- package/dist/commands/update.js +0 -140
- package/dist/prompts/plan.d.ts +0 -1
- package/dist/prompts/plan.js +0 -175
- package/dist/prompts/spec.d.ts +0 -1
- package/dist/prompts/spec.js +0 -153
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Self-update and content-check primitives extracted from commands/update.ts.
|
|
2
|
+
// Moved here to break the core→commands import inversion: auto-update.ts
|
|
3
|
+
// (core) previously imported from commands/update.ts (commands layer).
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { join, dirname } from 'node:path';
|
|
7
|
+
import { readFileSync } from 'node:fs';
|
|
8
|
+
import { listAllPlugins, listAllMarketplaces } from './resolver.js';
|
|
9
|
+
import { pull, fetch, currentSha, remoteSha } from './git.js';
|
|
10
|
+
import { updateState } from './config.js';
|
|
11
|
+
import { nowIso } from './fs-utils.js';
|
|
12
|
+
import { general, network } from './errors.js';
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = dirname(__filename);
|
|
15
|
+
// src/core/self-update.ts → up to src/ → up to pkg root
|
|
16
|
+
const PKG_ROOT = join(__dirname, '..', '..');
|
|
17
|
+
const PACKAGE_JSON_PATH = join(PKG_ROOT, 'package.json');
|
|
18
|
+
export function currentVersion() {
|
|
19
|
+
const raw = readFileSync(PACKAGE_JSON_PATH, 'utf8');
|
|
20
|
+
const parsed = JSON.parse(raw);
|
|
21
|
+
return parsed.version;
|
|
22
|
+
}
|
|
23
|
+
export function selfUpdate() {
|
|
24
|
+
const res = spawnSync('npm', ['i', '-g', '@crouton-kit/crouter@latest'], { stdio: 'inherit' });
|
|
25
|
+
if (res.status !== 0) {
|
|
26
|
+
throw general('npm install failed');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/** Check whether a newer crtr version is available on npm.
|
|
30
|
+
* Warns to stderr if network unavailable; returns {current, latest} or null if unreachable. */
|
|
31
|
+
export function selfCheck() {
|
|
32
|
+
const res = spawnSync('npm', ['view', '@crouton-kit/crouter', 'version'], { encoding: 'utf8' });
|
|
33
|
+
if (res.status !== 0) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const latest = res.stdout.trim();
|
|
37
|
+
const current = currentVersion();
|
|
38
|
+
return { current, latest };
|
|
39
|
+
}
|
|
40
|
+
/** Pull updates for all installed marketplaces and standalone plugins. */
|
|
41
|
+
export function contentUpdate() {
|
|
42
|
+
const marketplaces = listAllMarketplaces();
|
|
43
|
+
for (const mkt of marketplaces) {
|
|
44
|
+
const res = pull(mkt.root);
|
|
45
|
+
if (res.status !== 0) {
|
|
46
|
+
throw network(`git pull failed for marketplace ${mkt.name}: ${res.stderr.trim()}`);
|
|
47
|
+
}
|
|
48
|
+
updateState(mkt.scope, (s) => {
|
|
49
|
+
if (!s.marketplaces[mkt.name])
|
|
50
|
+
s.marketplaces[mkt.name] = {};
|
|
51
|
+
s.marketplaces[mkt.name].last_updated = nowIso();
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const plugins = listAllPlugins();
|
|
55
|
+
for (const plugin of plugins) {
|
|
56
|
+
if (!plugin.enabled)
|
|
57
|
+
continue;
|
|
58
|
+
if (plugin.sourceMarketplace)
|
|
59
|
+
continue;
|
|
60
|
+
const res = pull(plugin.root);
|
|
61
|
+
if (res.status !== 0) {
|
|
62
|
+
throw network(`git pull failed for plugin ${plugin.name}: ${res.stderr.trim()}`);
|
|
63
|
+
}
|
|
64
|
+
updateState(plugin.scope, (s) => {
|
|
65
|
+
if (!s.plugins[plugin.name])
|
|
66
|
+
s.plugins[plugin.name] = {};
|
|
67
|
+
s.plugins[plugin.name].last_updated = nowIso();
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Check whether any marketplace/plugin has updates available.
|
|
72
|
+
* Returns per-item status without applying anything. */
|
|
73
|
+
export function contentCheck() {
|
|
74
|
+
const results = [];
|
|
75
|
+
const marketplaces = listAllMarketplaces();
|
|
76
|
+
for (const mkt of marketplaces) {
|
|
77
|
+
const fetchRes = fetch(mkt.root, mkt.ref);
|
|
78
|
+
if (fetchRes.status !== 0) {
|
|
79
|
+
results.push({ name: mkt.name, kind: 'marketplace', current: null, latest: null, up_to_date: true, unreachable: true });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const head = currentSha(mkt.root);
|
|
83
|
+
const remote = remoteSha(mkt.root, mkt.ref);
|
|
84
|
+
const up_to_date = head !== null && remote !== null ? head === remote : true;
|
|
85
|
+
results.push({ name: mkt.name, kind: 'marketplace', current: head, latest: remote, up_to_date, unreachable: false });
|
|
86
|
+
}
|
|
87
|
+
const plugins = listAllPlugins();
|
|
88
|
+
for (const plugin of plugins) {
|
|
89
|
+
if (!plugin.enabled)
|
|
90
|
+
continue;
|
|
91
|
+
if (plugin.sourceMarketplace)
|
|
92
|
+
continue;
|
|
93
|
+
const ref = 'main';
|
|
94
|
+
const fetchRes = fetch(plugin.root, ref);
|
|
95
|
+
if (fetchRes.status !== 0) {
|
|
96
|
+
results.push({ name: plugin.name, kind: 'plugin', current: null, latest: null, up_to_date: true, unreachable: true });
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
const head = currentSha(plugin.root);
|
|
100
|
+
const remote = remoteSha(plugin.root, ref);
|
|
101
|
+
const up_to_date = head !== null && remote !== null ? head === remote : true;
|
|
102
|
+
results.push({ name: plugin.name, kind: 'plugin', current: head, latest: remote, up_to_date, unreachable: false });
|
|
103
|
+
}
|
|
104
|
+
return results;
|
|
105
|
+
}
|
package/dist/core/spawn.d.ts
CHANGED
|
@@ -1,95 +1,77 @@
|
|
|
1
|
-
export interface
|
|
2
|
-
/**
|
|
1
|
+
export interface SpawnAgentOptions {
|
|
2
|
+
/** First user message for the new claude session. */
|
|
3
3
|
prompt: string;
|
|
4
4
|
cwd: string;
|
|
5
|
-
|
|
5
|
+
/** crtr job_id injected as CRTR_JOB_ID env var in the pane. */
|
|
6
|
+
jobId: string;
|
|
7
|
+
/** If set, resume this Claude Code session with --fork-session (new session id). */
|
|
8
|
+
fork?: {
|
|
9
|
+
sessionId: string;
|
|
10
|
+
};
|
|
11
|
+
/** Max panes per tmux window before overflowing to a new window. */
|
|
12
|
+
maxPanesPerWindow: number;
|
|
6
13
|
}
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export type SidePaneStatus = 'submitted' | 'timeout' | 'pane-closed' | 'spawn-failed';
|
|
11
|
-
export interface SidePaneResult {
|
|
12
|
-
status: SidePaneStatus;
|
|
13
|
-
content: string;
|
|
14
|
+
export interface SpawnAgentResult {
|
|
15
|
+
status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
|
|
16
|
+
/** tmux pane id of the spawned pane. */
|
|
14
17
|
paneId?: string;
|
|
15
|
-
|
|
18
|
+
/** How the pane was placed. */
|
|
19
|
+
placement?: 'split-window' | 'new-window';
|
|
20
|
+
message: string;
|
|
16
21
|
}
|
|
17
|
-
export declare function createSession(): {
|
|
18
|
-
id: string;
|
|
19
|
-
dir: string;
|
|
20
|
-
};
|
|
21
|
-
export declare function submitToSession(sessionDir: string, content: string): void;
|
|
22
22
|
export interface DetachOptions {
|
|
23
|
-
/**
|
|
24
|
-
|
|
23
|
+
/** Inner command to run in the pane. If omitted, build `claude … <prompt>`. */
|
|
24
|
+
command?: string;
|
|
25
|
+
/** Full first user message for the new claude session (claude mode only;
|
|
26
|
+
* ignored when `command` is set). No custom system prompt. */
|
|
27
|
+
prompt?: string;
|
|
25
28
|
cwd: string;
|
|
29
|
+
/** crtr job_id injected as CRTR_JOB_ID env var in the pane and used by the
|
|
30
|
+
* `_fail` guard. Optional only when `failGuard` is false. */
|
|
31
|
+
jobId?: string;
|
|
26
32
|
/** Where to open the new pane. */
|
|
27
33
|
placement: 'split-h' | 'split-v' | 'new-window';
|
|
28
34
|
/** Seconds to wait before killing the originating pane so the caller can finish. */
|
|
29
35
|
killAfterSeconds: number;
|
|
36
|
+
/** Append `; crtr job _fail <jobId>` and inject CRTR_JOB_ID. Default true. */
|
|
37
|
+
failGuard?: boolean;
|
|
38
|
+
/** Pin the new pane to this tmux pane: split-window splits it; new-window is
|
|
39
|
+
* inserted immediately after its window (-a -t <pane>). Without this, tmux
|
|
40
|
+
* uses the attached client's currently-focused pane — which drifts if the
|
|
41
|
+
* user switches windows between kickoff and spawn. */
|
|
42
|
+
targetPane?: string;
|
|
30
43
|
}
|
|
31
44
|
export interface DetachResult {
|
|
32
45
|
status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
|
|
33
46
|
paneId?: string;
|
|
34
47
|
message: string;
|
|
35
48
|
}
|
|
49
|
+
export declare function isInTmux(): boolean;
|
|
50
|
+
export declare function shellQuote(s: string): string;
|
|
51
|
+
export declare function countPanesInCurrentWindow(): number;
|
|
52
|
+
/**
|
|
53
|
+
* Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
|
|
54
|
+
* so the caller can return normally before the pane dies. No-op outside tmux
|
|
55
|
+
* or when TMUX_PANE is unset.
|
|
56
|
+
*
|
|
57
|
+
* Used by `crtr job submit` (kill_pane=true) so a reviewer agent can self-close
|
|
58
|
+
* its pane after delivering its verdict, and by `spawnAndDetach` for handoff
|
|
59
|
+
* self-kill.
|
|
60
|
+
*/
|
|
61
|
+
export declare function scheduleKillCurrentPane(delaySeconds: number): boolean;
|
|
36
62
|
/**
|
|
37
63
|
* Fire-and-forget: launch an interactive `claude` in a new pane (or window),
|
|
38
64
|
* then schedule the originating pane to be killed after `killAfterSeconds`.
|
|
39
65
|
*
|
|
40
|
-
* No custom system prompt — the task is delivered as the first user message
|
|
41
|
-
* so the user can `/clear` to fall back to a normal default Claude session.
|
|
42
|
-
*
|
|
66
|
+
* No custom system prompt — the task is delivered as the first user message.
|
|
43
67
|
* Returns as soon as the new pane is up; does NOT wait for claude to finish.
|
|
44
68
|
*/
|
|
45
69
|
export declare function spawnAndDetach(opts: DetachOptions): DetachResult;
|
|
46
|
-
/**
|
|
47
|
-
* Spawn a side-pane `claude` reviewer. Blocks until the reviewer calls
|
|
48
|
-
* `crtr agent submit <content>`, the 10-min budget elapses, or the pane is closed.
|
|
49
|
-
*
|
|
50
|
-
* No custom system prompt — the task is delivered as the first user message
|
|
51
|
-
* so the reviewer is a normal Claude session running a single task.
|
|
52
|
-
*/
|
|
53
|
-
export declare function spawnSidePaneReview(opts: SidePaneOptions): Promise<SidePaneResult>;
|
|
54
|
-
export interface SpawnAgentOptions {
|
|
55
|
-
/** First user message for the new claude session. */
|
|
56
|
-
prompt: string;
|
|
57
|
-
cwd: string;
|
|
58
|
-
/** If set, resume this Claude Code session with --fork-session (new session id). */
|
|
59
|
-
fork?: {
|
|
60
|
-
sessionId: string;
|
|
61
|
-
};
|
|
62
|
-
/** Max panes per tmux window before overflowing to a new window. */
|
|
63
|
-
maxPanesPerWindow: number;
|
|
64
|
-
}
|
|
65
|
-
export interface SpawnAgentResult {
|
|
66
|
-
status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
|
|
67
|
-
/** crtr session UUID — pass to `crtr agent await` to receive the result. */
|
|
68
|
-
sessionId?: string;
|
|
69
|
-
/** tmux pane id of the spawned pane. */
|
|
70
|
-
paneId?: string;
|
|
71
|
-
/** How the pane was placed. */
|
|
72
|
-
placement?: 'split-window' | 'new-window';
|
|
73
|
-
message: string;
|
|
74
|
-
}
|
|
75
|
-
export declare function sessionDirForId(sessionId: string): string;
|
|
76
|
-
export declare function countPanesInCurrentWindow(): number;
|
|
77
70
|
/**
|
|
78
71
|
* Async sibling spawn. Launches a claude session in a new tmux pane or window
|
|
79
72
|
* (depending on current pane count vs maxPanesPerWindow). Returns immediately
|
|
80
|
-
* with the
|
|
73
|
+
* with the pane id; the parent stays alive.
|
|
81
74
|
*
|
|
82
|
-
* If `fork` is set, uses `claude --resume <id> --fork-session
|
|
83
|
-
* gets a fresh session id and does not contend with the parent's JSONL.
|
|
75
|
+
* If `fork` is set, uses `claude --resume <id> --fork-session`.
|
|
84
76
|
*/
|
|
85
77
|
export declare function spawnAgent(opts: SpawnAgentOptions): SpawnAgentResult;
|
|
86
|
-
export interface AwaitOptions {
|
|
87
|
-
timeoutMs: number;
|
|
88
|
-
/** Kill the child pane after content is received. Default true. */
|
|
89
|
-
killPane: boolean;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Block until the agent identified by `sessionId` calls `crtr agent submit`.
|
|
93
|
-
* Returns content + status. Cleans up the session dir on completion.
|
|
94
|
-
*/
|
|
95
|
-
export declare function awaitSession(sessionId: string, opts: AwaitOptions): Promise<SidePaneResult>;
|
package/dist/core/spawn.js
CHANGED
|
@@ -1,55 +1,71 @@
|
|
|
1
|
+
// Tmux pane spawning machinery for crtr job subtree.
|
|
2
|
+
//
|
|
3
|
+
// Kept: spawnAgent (fire-and-forget new pane), spawnAndDetach (detach + kill originating pane),
|
|
4
|
+
// shellQuote, isInTmux, countPanesInCurrentWindow.
|
|
5
|
+
//
|
|
6
|
+
// Removed: createSession, submitToSession, awaitSession, waitForResult,
|
|
7
|
+
// sessionDirForId, writeSessionMeta, readSessionMeta — all superseded
|
|
8
|
+
// by the jobs.ts sidecar model (result.json + log.jsonl).
|
|
9
|
+
//
|
|
10
|
+
// Crash detection: the wrapper shell command is:
|
|
11
|
+
// `claude --dangerously-skip-permissions <prompt>; crtr job _fail <job_id>`
|
|
12
|
+
// If the worker calls `crtr job submit` before claude exits, result.json is
|
|
13
|
+
// written and `_fail` is a no-op (writeResult is idempotent for done status).
|
|
14
|
+
// If claude dies without a submit, `_fail` writes status 'failed'. Either way
|
|
15
|
+
// `job read result` sees a terminal result.json.
|
|
1
16
|
import { spawnSync } from 'node:child_process';
|
|
2
|
-
|
|
3
|
-
import { tmpdir } from 'node:os';
|
|
4
|
-
import { join } from 'node:path';
|
|
5
|
-
import { randomUUID } from 'node:crypto';
|
|
6
|
-
import { general, notFound } from './errors.js';
|
|
7
|
-
const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
|
|
8
|
-
const PANE_POLL_MS = 2000;
|
|
9
|
-
export const DEFAULT_PANE_OPTS = {
|
|
10
|
-
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
11
|
-
};
|
|
12
|
-
function isInTmux() {
|
|
17
|
+
export function isInTmux() {
|
|
13
18
|
return Boolean(process.env.TMUX);
|
|
14
19
|
}
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
mkdirSync(root, { recursive: true });
|
|
18
|
-
return root;
|
|
19
|
-
}
|
|
20
|
-
export function createSession() {
|
|
21
|
-
const id = randomUUID();
|
|
22
|
-
const dir = join(sessionRoot(), id);
|
|
23
|
-
mkdirSync(dir, { recursive: true });
|
|
24
|
-
return { id, dir };
|
|
25
|
-
}
|
|
26
|
-
export function submitToSession(sessionDir, content) {
|
|
27
|
-
const tmp = join(sessionDir, '.content.tmp');
|
|
28
|
-
const final = join(sessionDir, 'content');
|
|
29
|
-
writeFileSync(tmp, content, 'utf8');
|
|
30
|
-
renameSync(tmp, final);
|
|
20
|
+
export function shellQuote(s) {
|
|
21
|
+
return `'${s.replace(/'/g, "'\\''")}'`;
|
|
31
22
|
}
|
|
32
|
-
function
|
|
33
|
-
const result = spawnSync('tmux', ['list-panes', '-
|
|
23
|
+
export function countPanesInCurrentWindow() {
|
|
24
|
+
const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
|
|
34
25
|
encoding: 'utf8',
|
|
35
26
|
});
|
|
36
27
|
if (result.status !== 0)
|
|
37
|
-
return
|
|
38
|
-
return result.stdout.split('\n').
|
|
28
|
+
return 0;
|
|
29
|
+
return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
|
|
39
30
|
}
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
/**
|
|
32
|
+
* Schedule a kill-pane on the *current* tmux pane after `delaySeconds`, detached
|
|
33
|
+
* so the caller can return normally before the pane dies. No-op outside tmux
|
|
34
|
+
* or when TMUX_PANE is unset.
|
|
35
|
+
*
|
|
36
|
+
* Used by `crtr job submit` (kill_pane=true) so a reviewer agent can self-close
|
|
37
|
+
* its pane after delivering its verdict, and by `spawnAndDetach` for handoff
|
|
38
|
+
* self-kill.
|
|
39
|
+
*/
|
|
40
|
+
export function scheduleKillCurrentPane(delaySeconds) {
|
|
41
|
+
const currentPane = process.env.TMUX_PANE;
|
|
42
|
+
if (currentPane === undefined || currentPane === '' || delaySeconds <= 0) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
const killCmd = `sleep ${delaySeconds}; tmux kill-pane -t ${currentPane}`;
|
|
46
|
+
spawnSync('sh', ['-c', `nohup sh -c ${shellQuote(killCmd)} </dev/null >/dev/null 2>&1 &`], {
|
|
47
|
+
stdio: 'ignore',
|
|
48
|
+
});
|
|
49
|
+
return true;
|
|
42
50
|
}
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
/**
|
|
52
|
+
* Build the wrapper shell command passed to the tmux pane.
|
|
53
|
+
*
|
|
54
|
+
* Pattern: `claude <args>; crtr job _fail <job_id>`
|
|
55
|
+
*
|
|
56
|
+
* If the worker submits via `crtr job submit` before claude exits,
|
|
57
|
+
* result.json is already written (`done`); `_fail` sees it and is a no-op.
|
|
58
|
+
* If claude crashes/exits without submitting, `_fail` writes status `failed`
|
|
59
|
+
* so `job read result` can distinguish completion from crash.
|
|
60
|
+
*/
|
|
61
|
+
function wrapperCmd(claudeCmd, jobId) {
|
|
62
|
+
return `${claudeCmd}; crtr job _fail ${shellQuote(jobId)}`;
|
|
45
63
|
}
|
|
46
64
|
/**
|
|
47
65
|
* Fire-and-forget: launch an interactive `claude` in a new pane (or window),
|
|
48
66
|
* then schedule the originating pane to be killed after `killAfterSeconds`.
|
|
49
67
|
*
|
|
50
|
-
* No custom system prompt — the task is delivered as the first user message
|
|
51
|
-
* so the user can `/clear` to fall back to a normal default Claude session.
|
|
52
|
-
*
|
|
68
|
+
* No custom system prompt — the task is delivered as the first user message.
|
|
53
69
|
* Returns as soon as the new pane is up; does NOT wait for claude to finish.
|
|
54
70
|
*/
|
|
55
71
|
export function spawnAndDetach(opts) {
|
|
@@ -59,22 +75,32 @@ export function spawnAndDetach(opts) {
|
|
|
59
75
|
message: 'handoff requires tmux (TMUX env var not set)',
|
|
60
76
|
};
|
|
61
77
|
}
|
|
62
|
-
const
|
|
63
|
-
|
|
64
|
-
'--dangerously-skip-permissions',
|
|
65
|
-
|
|
66
|
-
|
|
78
|
+
const inner = opts.command !== undefined
|
|
79
|
+
? opts.command
|
|
80
|
+
: ['claude', '--dangerously-skip-permissions', shellQuote(opts.prompt)].join(' ');
|
|
81
|
+
const useFailGuard = opts.failGuard !== false;
|
|
82
|
+
const fullCmd = useFailGuard ? wrapperCmd(inner, opts.jobId) : inner;
|
|
67
83
|
const splitArgs = [];
|
|
68
84
|
if (opts.placement === 'new-window') {
|
|
69
85
|
splitArgs.push('new-window');
|
|
86
|
+
if (opts.targetPane !== undefined && opts.targetPane !== '') {
|
|
87
|
+
// -a = insert after target window; -t <pane> resolves to that pane's window.
|
|
88
|
+
splitArgs.push('-a', '-t', opts.targetPane);
|
|
89
|
+
}
|
|
70
90
|
}
|
|
71
91
|
else {
|
|
72
92
|
splitArgs.push('split-window');
|
|
73
93
|
splitArgs.push(opts.placement === 'split-h' ? '-h' : '-v');
|
|
94
|
+
if (opts.targetPane !== undefined && opts.targetPane !== '') {
|
|
95
|
+
splitArgs.push('-t', opts.targetPane);
|
|
96
|
+
}
|
|
74
97
|
}
|
|
75
98
|
splitArgs.push('-P', '-F', '#{pane_id}');
|
|
76
99
|
splitArgs.push('-c', opts.cwd);
|
|
77
|
-
|
|
100
|
+
if (opts.jobId !== undefined) {
|
|
101
|
+
splitArgs.push('-e', `CRTR_JOB_ID=${opts.jobId}`);
|
|
102
|
+
}
|
|
103
|
+
splitArgs.push(fullCmd);
|
|
78
104
|
const split = spawnSync('tmux', splitArgs, { encoding: 'utf8' });
|
|
79
105
|
if (split.status !== 0) {
|
|
80
106
|
const stderrText = split.stderr.trim();
|
|
@@ -82,228 +108,52 @@ export function spawnAndDetach(opts) {
|
|
|
82
108
|
return { status: 'spawn-failed', message: msg };
|
|
83
109
|
}
|
|
84
110
|
const paneId = split.stdout.trim();
|
|
85
|
-
// Schedule self-kill of the originating pane.
|
|
86
|
-
|
|
87
|
-
const currentPane = process.env.TMUX_PANE;
|
|
88
|
-
if (currentPane !== undefined && currentPane !== '' && opts.killAfterSeconds > 0) {
|
|
89
|
-
const killCmd = `sleep ${opts.killAfterSeconds}; tmux kill-pane -t ${currentPane}`;
|
|
90
|
-
spawnSync('sh', ['-c', `nohup sh -c ${shellQuote(killCmd)} </dev/null >/dev/null 2>&1 &`], {
|
|
91
|
-
stdio: 'ignore',
|
|
92
|
-
});
|
|
93
|
-
}
|
|
111
|
+
// Schedule self-kill of the originating pane.
|
|
112
|
+
scheduleKillCurrentPane(opts.killAfterSeconds);
|
|
94
113
|
return {
|
|
95
114
|
status: 'spawned',
|
|
96
115
|
paneId,
|
|
97
116
|
message: `handed off to pane ${paneId}; this pane will close in ${opts.killAfterSeconds}s`,
|
|
98
117
|
};
|
|
99
118
|
}
|
|
100
|
-
/**
|
|
101
|
-
* Spawn a side-pane `claude` reviewer. Blocks until the reviewer calls
|
|
102
|
-
* `crtr agent submit <content>`, the 10-min budget elapses, or the pane is closed.
|
|
103
|
-
*
|
|
104
|
-
* No custom system prompt — the task is delivered as the first user message
|
|
105
|
-
* so the reviewer is a normal Claude session running a single task.
|
|
106
|
-
*/
|
|
107
|
-
export async function spawnSidePaneReview(opts) {
|
|
108
|
-
if (!isInTmux()) {
|
|
109
|
-
throw general('side-pane review requires tmux (TMUX env var not set)');
|
|
110
|
-
}
|
|
111
|
-
const session = createSession();
|
|
112
|
-
const timeoutMs = opts.timeoutMs;
|
|
113
|
-
const cwd = opts.cwd;
|
|
114
|
-
const claudeCmd = [
|
|
115
|
-
'claude',
|
|
116
|
-
'-p',
|
|
117
|
-
'--dangerously-skip-permissions',
|
|
118
|
-
shellQuote(opts.prompt),
|
|
119
|
-
].join(' ');
|
|
120
|
-
// After claude exits, sleep briefly so the watcher can confirm submission.
|
|
121
|
-
// The watcher kills the pane anyway once content arrives.
|
|
122
|
-
const fullCmd = `cd ${shellQuote(cwd)} && ${claudeCmd}; sleep 2`;
|
|
123
|
-
const splitArgs = [
|
|
124
|
-
'split-window',
|
|
125
|
-
'-h',
|
|
126
|
-
'-P',
|
|
127
|
-
'-F',
|
|
128
|
-
'#{pane_id}',
|
|
129
|
-
'-e',
|
|
130
|
-
`CRTR_SESSION=${session.id}`,
|
|
131
|
-
'-e',
|
|
132
|
-
`CRTR_PIPE=${session.dir}`,
|
|
133
|
-
fullCmd,
|
|
134
|
-
];
|
|
135
|
-
const split = spawnSync('tmux', splitArgs, { encoding: 'utf8' });
|
|
136
|
-
if (split.status !== 0) {
|
|
137
|
-
rmSync(session.dir, { recursive: true, force: true });
|
|
138
|
-
const stderrText = split.stderr.trim();
|
|
139
|
-
const msg = stderrText === '' ? 'tmux split-window failed' : stderrText;
|
|
140
|
-
return {
|
|
141
|
-
status: 'spawn-failed',
|
|
142
|
-
content: msg,
|
|
143
|
-
sessionDir: session.dir,
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
const paneId = split.stdout.trim();
|
|
147
|
-
const contentPath = join(session.dir, 'content');
|
|
148
|
-
const result = await waitForResult(session.dir, contentPath, paneId, timeoutMs);
|
|
149
|
-
if (paneAlive(paneId))
|
|
150
|
-
killPane(paneId);
|
|
151
|
-
try {
|
|
152
|
-
rmSync(session.dir, { recursive: true, force: true });
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
/* noop */
|
|
156
|
-
}
|
|
157
|
-
return { ...result, paneId, sessionDir: session.dir };
|
|
158
|
-
}
|
|
159
|
-
function metaPath(sessionDir) {
|
|
160
|
-
return join(sessionDir, 'meta.json');
|
|
161
|
-
}
|
|
162
|
-
function writeSessionMeta(sessionDir, meta) {
|
|
163
|
-
writeFileSync(metaPath(sessionDir), JSON.stringify(meta), 'utf8');
|
|
164
|
-
}
|
|
165
|
-
function readSessionMeta(sessionDir) {
|
|
166
|
-
const p = metaPath(sessionDir);
|
|
167
|
-
if (!existsSync(p))
|
|
168
|
-
return undefined;
|
|
169
|
-
try {
|
|
170
|
-
return JSON.parse(readFileSync(p, 'utf8'));
|
|
171
|
-
}
|
|
172
|
-
catch {
|
|
173
|
-
return undefined;
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
export function sessionDirForId(sessionId) {
|
|
177
|
-
return join(sessionRoot(), sessionId);
|
|
178
|
-
}
|
|
179
|
-
export function countPanesInCurrentWindow() {
|
|
180
|
-
// -t '' targets the current window of the current session.
|
|
181
|
-
const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
|
|
182
|
-
encoding: 'utf8',
|
|
183
|
-
});
|
|
184
|
-
if (result.status !== 0)
|
|
185
|
-
return 0;
|
|
186
|
-
return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
|
|
187
|
-
}
|
|
188
119
|
/**
|
|
189
120
|
* Async sibling spawn. Launches a claude session in a new tmux pane or window
|
|
190
121
|
* (depending on current pane count vs maxPanesPerWindow). Returns immediately
|
|
191
|
-
* with the
|
|
122
|
+
* with the pane id; the parent stays alive.
|
|
192
123
|
*
|
|
193
|
-
* If `fork` is set, uses `claude --resume <id> --fork-session
|
|
194
|
-
* gets a fresh session id and does not contend with the parent's JSONL.
|
|
124
|
+
* If `fork` is set, uses `claude --resume <id> --fork-session`.
|
|
195
125
|
*/
|
|
196
126
|
export function spawnAgent(opts) {
|
|
197
127
|
if (!isInTmux()) {
|
|
198
128
|
return {
|
|
199
129
|
status: 'not-in-tmux',
|
|
200
|
-
message: 'crtr
|
|
130
|
+
message: 'crtr job requires tmux (TMUX env var not set)',
|
|
201
131
|
};
|
|
202
132
|
}
|
|
203
|
-
const session = createSession();
|
|
204
133
|
const claudeParts = ['claude'];
|
|
205
134
|
if (opts.fork !== undefined) {
|
|
206
135
|
claudeParts.push('--resume', opts.fork.sessionId, '--fork-session');
|
|
207
136
|
}
|
|
208
137
|
claudeParts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
|
|
209
138
|
const claudeCmd = claudeParts.join(' ');
|
|
139
|
+
const fullCmd = wrapperCmd(claudeCmd, opts.jobId);
|
|
210
140
|
const useNewWindow = countPanesInCurrentWindow() >= opts.maxPanesPerWindow;
|
|
211
141
|
const placement = useNewWindow ? 'new-window' : 'split-window';
|
|
212
142
|
const tmuxArgs = [placement];
|
|
213
143
|
if (!useNewWindow)
|
|
214
144
|
tmuxArgs.push('-h');
|
|
215
|
-
tmuxArgs.push('-P', '-F', '#{pane_id}', '-c', opts.cwd, '-e', `
|
|
145
|
+
tmuxArgs.push('-P', '-F', '#{pane_id}', '-c', opts.cwd, '-e', `CRTR_JOB_ID=${opts.jobId}`, fullCmd);
|
|
216
146
|
const split = spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
|
|
217
147
|
if (split.status !== 0) {
|
|
218
|
-
rmSync(session.dir, { recursive: true, force: true });
|
|
219
148
|
const stderrText = split.stderr.trim();
|
|
220
149
|
const msg = stderrText === '' ? `tmux ${placement} failed` : stderrText;
|
|
221
150
|
return { status: 'spawn-failed', message: msg };
|
|
222
151
|
}
|
|
223
152
|
const paneId = split.stdout.trim();
|
|
224
|
-
writeSessionMeta(session.dir, {
|
|
225
|
-
paneId,
|
|
226
|
-
createdAt: Date.now(),
|
|
227
|
-
kind: opts.fork !== undefined ? 'fork' : 'new',
|
|
228
|
-
});
|
|
229
153
|
return {
|
|
230
154
|
status: 'spawned',
|
|
231
|
-
sessionId: session.id,
|
|
232
155
|
paneId,
|
|
233
156
|
placement,
|
|
234
|
-
message: `agent
|
|
157
|
+
message: `agent spawned in pane ${paneId} (${placement})`,
|
|
235
158
|
};
|
|
236
159
|
}
|
|
237
|
-
/**
|
|
238
|
-
* Block until the agent identified by `sessionId` calls `crtr agent submit`.
|
|
239
|
-
* Returns content + status. Cleans up the session dir on completion.
|
|
240
|
-
*/
|
|
241
|
-
export async function awaitSession(sessionId, opts) {
|
|
242
|
-
const sessionDir = sessionDirForId(sessionId);
|
|
243
|
-
if (!existsSync(sessionDir)) {
|
|
244
|
-
throw notFound(`agent session not found: ${sessionId} (looked at ${sessionDir})`);
|
|
245
|
-
}
|
|
246
|
-
const meta = readSessionMeta(sessionDir);
|
|
247
|
-
let paneId;
|
|
248
|
-
if (meta !== undefined && meta.paneId !== '') {
|
|
249
|
-
paneId = meta.paneId;
|
|
250
|
-
}
|
|
251
|
-
const contentPath = join(sessionDir, 'content');
|
|
252
|
-
const result = await waitForResult(sessionDir, contentPath, paneId, opts.timeoutMs);
|
|
253
|
-
if (opts.killPane && paneId !== undefined && paneAlive(paneId))
|
|
254
|
-
killPane(paneId);
|
|
255
|
-
try {
|
|
256
|
-
rmSync(sessionDir, { recursive: true, force: true });
|
|
257
|
-
}
|
|
258
|
-
catch {
|
|
259
|
-
/* noop */
|
|
260
|
-
}
|
|
261
|
-
return { ...result, paneId, sessionDir };
|
|
262
|
-
}
|
|
263
|
-
function waitForResult(sessionDir, contentPath, paneId, timeoutMs) {
|
|
264
|
-
return new Promise((resolve) => {
|
|
265
|
-
let settled = false;
|
|
266
|
-
const finish = (status, content) => {
|
|
267
|
-
if (settled)
|
|
268
|
-
return;
|
|
269
|
-
settled = true;
|
|
270
|
-
clearTimeout(timeoutTimer);
|
|
271
|
-
if (paneTimer !== undefined)
|
|
272
|
-
clearInterval(paneTimer);
|
|
273
|
-
try {
|
|
274
|
-
watcher.close();
|
|
275
|
-
}
|
|
276
|
-
catch {
|
|
277
|
-
/* noop */
|
|
278
|
-
}
|
|
279
|
-
resolve({ status, content });
|
|
280
|
-
};
|
|
281
|
-
const watcher = watch(sessionDir, (_event, name) => {
|
|
282
|
-
if (name === 'content' && existsSync(contentPath)) {
|
|
283
|
-
const content = readFileSync(contentPath, 'utf8');
|
|
284
|
-
finish('submitted', content);
|
|
285
|
-
}
|
|
286
|
-
});
|
|
287
|
-
if (existsSync(contentPath)) {
|
|
288
|
-
finish('submitted', readFileSync(contentPath, 'utf8'));
|
|
289
|
-
return;
|
|
290
|
-
}
|
|
291
|
-
const timeoutTimer = setTimeout(() => {
|
|
292
|
-
finish('timeout', '');
|
|
293
|
-
}, timeoutMs);
|
|
294
|
-
let paneTimer;
|
|
295
|
-
if (paneId !== undefined) {
|
|
296
|
-
const watchedPaneId = paneId;
|
|
297
|
-
paneTimer = setInterval(() => {
|
|
298
|
-
if (!paneAlive(watchedPaneId)) {
|
|
299
|
-
if (existsSync(contentPath)) {
|
|
300
|
-
finish('submitted', readFileSync(contentPath, 'utf8'));
|
|
301
|
-
}
|
|
302
|
-
else {
|
|
303
|
-
finish('pane-closed', '');
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
}, PANE_POLL_MS);
|
|
307
|
-
}
|
|
308
|
-
});
|
|
309
|
-
}
|
package/dist/prompts/agent.d.ts
CHANGED
|
@@ -1,13 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* First user message for a spec → plan handoff.
|
|
3
|
-
*
|
|
3
|
+
*
|
|
4
|
+
* Thin prompt: the worker discovers the full planning workflow by running
|
|
5
|
+
* `crtr flow plan new -h`, then saves the plan via `crtr flow plan new`. This avoids
|
|
6
|
+
* embedding the planPrompt() blob here and keeps the prompt in sync with the
|
|
7
|
+
* live CLI without any coupling.
|
|
4
8
|
*/
|
|
5
|
-
export declare function planHandoffPrompt(specPath: string,
|
|
9
|
+
export declare function planHandoffPrompt(specPath: string, jobId: string): string;
|
|
6
10
|
/**
|
|
7
11
|
* First user message for a plan → implementation handoff.
|
|
8
12
|
*/
|
|
9
|
-
export declare function implementHandoffPrompt(planPath: string): string;
|
|
13
|
+
export declare function implementHandoffPrompt(planPath: string, jobId: string): string;
|
|
10
14
|
/**
|
|
11
|
-
* First user message for a
|
|
15
|
+
* First user message for a reviewer agent.
|
|
16
|
+
* The reviewer submits via `crtr job submit` rather than `crtr agent submit`.
|
|
12
17
|
*/
|
|
13
|
-
export declare function
|
|
18
|
+
export declare function reviewerHandoffPrompt(artifactPath: string, artifactKind: 'plan' | 'spec', specPath: string | null, jobId: string): string;
|