@crouton-kit/crouter 0.1.4 → 0.1.6

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.
@@ -1,6 +1,8 @@
1
+ import { writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
1
3
  import { join } from 'node:path';
2
4
  import { userScopeRoot } from './scope.js';
3
- import { ensureDir, pathExists, removePath, nowIso } from './fs-utils.js';
5
+ import { ensureDir, pathExists, readText, removePath, nowIso } from './fs-utils.js';
4
6
  import { readConfig, readState, updateConfig, updateState, ensureScopeInitialized } from './config.js';
5
7
  import { clone } from './git.js';
6
8
  import { readMarketplaceManifest } from './manifest.js';
@@ -20,6 +22,83 @@ function shouldSkipForArgv(argv) {
20
22
  return true;
21
23
  return SKIP_SUBCOMMANDS.has(sub);
22
24
  }
25
+ const BOOT_SKILL_NAME = 'crtr-skills';
26
+ const BOOT_SKILL_MARKER = '<!-- crtr-boot-skill v1 -->';
27
+ function bootSkillBody() {
28
+ return `---
29
+ name: crtr-skills
30
+ description: Capture, list, search, and load skills via the crtr CLI. Use when the user wants to remember something, save knowledge, build a context primer, or recall a previously saved skill. Triggers: "save", "remember", "build context for", "what skills do we have", "skill for X".
31
+ argument-hint: [topic or verb]
32
+ ---
33
+
34
+ ${BOOT_SKILL_MARKER}
35
+
36
+ # /crtr:skills — the skill router
37
+
38
+ \`crtr\` is the source of truth for skills on this machine. Every skill the
39
+ agent should know about is discoverable via \`crtr skill\`. This file is a
40
+ thin router; the CLI is the index.
41
+
42
+ ## What the user is asking for
43
+
44
+ - **Capture new knowledge** ("save this", "remember", "build context for X",
45
+ "make a skill that…"): run \`crtr skill create $ARGUMENTS\` and follow the
46
+ walkthrough it prints. It picks a template (primer/preference/runbook/
47
+ glossary/decision/freeform) and walks you through scoping, researching,
48
+ and scaffolding.
49
+ - **Find a relevant skill** ("what do we have on X"): run
50
+ \`crtr skill search "$ARGUMENTS"\` and load the best hit with
51
+ \`crtr skill show <name>\`.
52
+ - **Load a known skill by name**: run \`crtr skill show <name>\`.
53
+ - **List everything**: run \`crtr skill list\`.
54
+ - **Anything else skill-related**: run \`crtr skill\` (no args) — it prints
55
+ the full skill workflow guide. Follow it.
56
+
57
+ \`$ARGUMENTS\` is the user's request as a string. Use it to seed the topic for
58
+ \`create\` or the query for \`search\`. If it's empty, ask the user what they
59
+ want before running anything.
60
+
61
+ ## Output rules
62
+
63
+ The CLI's stdout is the prompt. Read it, then act on it. Don't paraphrase the
64
+ guidance back at the user — just do the work it describes.
65
+
66
+ If \`crtr\` isn't on PATH, tell the user and stop. This skill assumes
67
+ \`@crouton-kit/crouter\` is installed globally.
68
+ `;
69
+ }
70
+ export function ensureBootSkill(argv) {
71
+ try {
72
+ if (process.env.CRTR_NO_BOOT_SKILL === '1')
73
+ return;
74
+ if (shouldSkipForArgv(argv))
75
+ return;
76
+ const claudeSkillsRoot = join(homedir(), '.claude', 'skills');
77
+ // Only install if the user actually uses Claude Code (the dir exists or is
78
+ // creatable). We won't create ~/.claude itself; that's not our directory.
79
+ if (!pathExists(join(homedir(), '.claude')))
80
+ return;
81
+ const skillDir = join(claudeSkillsRoot, BOOT_SKILL_NAME);
82
+ const skillFile = join(skillDir, 'SKILL.md');
83
+ if (pathExists(skillFile)) {
84
+ // Idempotent: only rewrite if it's still our marker version.
85
+ const existing = readText(skillFile);
86
+ if (!existing.includes(BOOT_SKILL_MARKER))
87
+ return;
88
+ // Same marker — check if body needs update, otherwise skip.
89
+ if (existing === bootSkillBody())
90
+ return;
91
+ }
92
+ ensureDir(skillDir);
93
+ writeFileSync(skillFile, bootSkillBody(), 'utf8');
94
+ }
95
+ catch (e) {
96
+ if (process.env.CRTR_DEBUG === '1') {
97
+ const msg = e instanceof Error ? e.message : String(e);
98
+ process.stderr.write(`crtr: boot-skill error: ${msg}\n`);
99
+ }
100
+ }
101
+ }
23
102
  export function ensureOfficialMarketplace(argv) {
24
103
  try {
25
104
  if (process.env.CRTR_NO_BOOTSTRAP === '1')
@@ -81,7 +81,11 @@ function mergeConfig(partial) {
81
81
  content: normalizeMode(au?.content, defaults.auto_update.content),
82
82
  interval_hours,
83
83
  };
84
- return { schema_version, marketplaces, plugins, skills, auto_update };
84
+ const rawMaxPanes = partial.max_panes_per_window;
85
+ const max_panes_per_window = typeof rawMaxPanes === 'number' && Number.isFinite(rawMaxPanes) && rawMaxPanes >= 1
86
+ ? Math.floor(rawMaxPanes)
87
+ : defaults.max_panes_per_window;
88
+ return { schema_version, marketplaces, plugins, skills, auto_update, max_panes_per_window };
85
89
  }
86
90
  export function updateConfig(scope, mutate) {
87
91
  const cfg = readConfig(scope);
@@ -11,6 +11,7 @@ export declare function effectiveSkillEnabled(pluginName: string, skillName: str
11
11
  disabledIn?: Scope;
12
12
  };
13
13
  export declare function listSkillsInPlugin(plugin: InstalledPlugin, cfgs?: ScopeConfigs): Skill[];
14
+ export declare function listScopeRootSkills(scope: Scope, cfgs?: ScopeConfigs): Skill[];
14
15
  export declare function listAllSkills(scopeFilter?: Scope): Skill[];
15
16
  export interface SkillResolutionOpts {
16
17
  scope?: Scope;
@@ -1,11 +1,11 @@
1
1
  import { join, relative, sep, dirname } from 'node:path';
2
- import { SKILL_ENTRY_FILE, SKILLS_DIR, skillConfigKey, } from '../types.js';
2
+ import { SCOPE_SKILL_PLUGIN, SKILL_ENTRY_FILE, SKILLS_DIR, skillConfigKey, } from '../types.js';
3
3
  import { readConfig } from './config.js';
4
4
  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 } from './errors.js';
8
- import { marketplacesDir, pluginsDir, projectScopeRoot, userScopeRoot } from './scope.js';
8
+ import { marketplacesDir, pluginsDir, projectScopeRoot, scopeSkillsDir, userScopeRoot, } from './scope.js';
9
9
  export function listInstalledPlugins(scope) {
10
10
  const dir = pluginsDir(scope);
11
11
  if (!dir || !pathExists(dir))
@@ -99,20 +99,80 @@ export function listSkillsInPlugin(plugin, cfgs) {
99
99
  }
100
100
  return skills.sort((a, b) => a.name.localeCompare(b.name));
101
101
  }
102
+ export function listScopeRootSkills(scope, cfgs) {
103
+ const skillsRoot = scopeSkillsDir(scope);
104
+ if (!skillsRoot || !pathExists(skillsRoot))
105
+ return [];
106
+ const configs = cfgs === undefined ? loadScopeConfigs() : cfgs;
107
+ const skills = [];
108
+ const skillFiles = walkFiles(skillsRoot, (n) => n === SKILL_ENTRY_FILE);
109
+ for (const file of skillFiles) {
110
+ const rel = relative(skillsRoot, dirname(file));
111
+ const name = rel.split(sep).join('/');
112
+ if (!name)
113
+ continue;
114
+ const source = readText(file);
115
+ const { data } = parseFrontmatter(source);
116
+ const { enabled, disabledIn } = effectiveSkillEnabled(SCOPE_SKILL_PLUGIN, name, configs);
117
+ skills.push({
118
+ name,
119
+ plugin: SCOPE_SKILL_PLUGIN,
120
+ scope,
121
+ path: file,
122
+ pluginRoot: skillsRoot,
123
+ frontmatter: data === null ? { name } : data,
124
+ enabled,
125
+ disabledIn,
126
+ });
127
+ }
128
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
129
+ }
102
130
  export function listAllSkills(scopeFilter) {
103
131
  const plugins = scopeFilter ? listInstalledPlugins(scopeFilter) : listAllPlugins();
104
132
  const cfgs = loadScopeConfigs();
105
- return plugins
106
- .filter((p) => p.enabled)
107
- .flatMap((p) => listSkillsInPlugin(p, cfgs));
133
+ const scopes = scopeFilter
134
+ ? [scopeFilter]
135
+ : [projectScopeRoot() ? 'project' : null, 'user'].filter(Boolean);
136
+ return [
137
+ ...scopes.flatMap((s) => listScopeRootSkills(s, cfgs)),
138
+ ...plugins.filter((p) => p.enabled).flatMap((p) => listSkillsInPlugin(p, cfgs)),
139
+ ];
108
140
  }
109
141
  export function resolveSkill(rawName, opts = {}) {
110
142
  const { plugin: pluginQualifier, name } = parseSkillQualifier(rawName);
111
143
  const plugins = opts.scope ? listInstalledPlugins(opts.scope) : listAllPlugins();
112
144
  const enabledPlugins = plugins.filter((p) => p.enabled);
113
145
  const cfgs = loadScopeConfigs();
114
- const ordered = orderPluginsByResolution(enabledPlugins);
115
146
  const matches = [];
147
+ // Scope-root skills first — they're the user's own captured knowledge.
148
+ if (!opts.pluginFilter &&
149
+ (pluginQualifier === undefined || pluginQualifier === SCOPE_SKILL_PLUGIN)) {
150
+ const scopes = opts.scope
151
+ ? [opts.scope]
152
+ : [projectScopeRoot() ? 'project' : null, 'user'].filter(Boolean);
153
+ for (const s of scopes) {
154
+ const skillsRoot = scopeSkillsDir(s);
155
+ if (!skillsRoot)
156
+ continue;
157
+ const skillPath = join(skillsRoot, ...name.split('/'), SKILL_ENTRY_FILE);
158
+ if (!pathExists(skillPath))
159
+ continue;
160
+ const source = readText(skillPath);
161
+ const { data } = parseFrontmatter(source);
162
+ const { enabled, disabledIn } = effectiveSkillEnabled(SCOPE_SKILL_PLUGIN, name, cfgs);
163
+ matches.push({
164
+ name,
165
+ plugin: SCOPE_SKILL_PLUGIN,
166
+ scope: s,
167
+ path: skillPath,
168
+ pluginRoot: skillsRoot,
169
+ frontmatter: data === null ? { name } : data,
170
+ enabled,
171
+ disabledIn,
172
+ });
173
+ }
174
+ }
175
+ const ordered = orderPluginsByResolution(enabledPlugins);
116
176
  for (const plugin of ordered) {
117
177
  if (pluginQualifier && plugin.name !== pluginQualifier)
118
178
  continue;
@@ -7,6 +7,7 @@ export declare function requireScopeRoot(scope: Scope): string;
7
7
  export declare function ensureProjectScopeRoot(startDir?: string): string;
8
8
  export declare function pluginsDir(scope: Scope): string | null;
9
9
  export declare function marketplacesDir(scope: Scope): string | null;
10
+ export declare function scopeSkillsDir(scope: Scope): string | null;
10
11
  export declare function resolveScopeArg(scopeArg: string | undefined): Scope | 'all';
11
12
  export declare function listScopes(scopeArg: string | undefined): Scope[];
12
13
  export declare function resetScopeCache(): void;
@@ -63,6 +63,10 @@ export function marketplacesDir(scope) {
63
63
  const root = scopeRoot(scope);
64
64
  return root ? join(root, 'marketplaces') : null;
65
65
  }
66
+ export function scopeSkillsDir(scope) {
67
+ const root = scopeRoot(scope);
68
+ return root ? join(root, 'skills') : null;
69
+ }
66
70
  export function resolveScopeArg(scopeArg) {
67
71
  if (scopeArg === undefined)
68
72
  return 'all';
@@ -0,0 +1,95 @@
1
+ export interface SidePaneOptions {
2
+ /** Full first user message — task + checklist + submit instructions all in one. */
3
+ prompt: string;
4
+ cwd: string;
5
+ timeoutMs: number;
6
+ }
7
+ export declare const DEFAULT_PANE_OPTS: {
8
+ timeoutMs: number;
9
+ };
10
+ export type SidePaneStatus = 'submitted' | 'timeout' | 'pane-closed' | 'spawn-failed';
11
+ export interface SidePaneResult {
12
+ status: SidePaneStatus;
13
+ content: string;
14
+ paneId?: string;
15
+ sessionDir: string;
16
+ }
17
+ export declare function createSession(): {
18
+ id: string;
19
+ dir: string;
20
+ };
21
+ export declare function submitToSession(sessionDir: string, content: string): void;
22
+ export interface DetachOptions {
23
+ /** Full first user message for the new claude session. No custom system prompt. */
24
+ prompt: string;
25
+ cwd: string;
26
+ /** Where to open the new pane. */
27
+ placement: 'split-h' | 'split-v' | 'new-window';
28
+ /** Seconds to wait before killing the originating pane so the caller can finish. */
29
+ killAfterSeconds: number;
30
+ }
31
+ export interface DetachResult {
32
+ status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
33
+ paneId?: string;
34
+ message: string;
35
+ }
36
+ /**
37
+ * Fire-and-forget: launch an interactive `claude` in a new pane (or window),
38
+ * then schedule the originating pane to be killed after `killAfterSeconds`.
39
+ *
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
+ *
43
+ * Returns as soon as the new pane is up; does NOT wait for claude to finish.
44
+ */
45
+ 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
+ /**
78
+ * Async sibling spawn. Launches a claude session in a new tmux pane or window
79
+ * (depending on current pane count vs maxPanesPerWindow). Returns immediately
80
+ * with the crtr session id; the parent stays alive.
81
+ *
82
+ * If `fork` is set, uses `claude --resume <id> --fork-session` so the child
83
+ * gets a fresh session id and does not contend with the parent's JSONL.
84
+ */
85
+ 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>;
@@ -0,0 +1,309 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { mkdirSync, watch, readFileSync, existsSync, writeFileSync, renameSync, rmSync } from 'node:fs';
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() {
13
+ return Boolean(process.env.TMUX);
14
+ }
15
+ function sessionRoot() {
16
+ const root = join(tmpdir(), 'crtr-sessions');
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);
31
+ }
32
+ function paneAlive(paneId) {
33
+ const result = spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
34
+ encoding: 'utf8',
35
+ });
36
+ if (result.status !== 0)
37
+ return false;
38
+ return result.stdout.split('\n').some((line) => line.trim() === paneId);
39
+ }
40
+ function killPane(paneId) {
41
+ spawnSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
42
+ }
43
+ function shellQuote(s) {
44
+ return `'${s.replace(/'/g, "'\\''")}'`;
45
+ }
46
+ /**
47
+ * Fire-and-forget: launch an interactive `claude` in a new pane (or window),
48
+ * then schedule the originating pane to be killed after `killAfterSeconds`.
49
+ *
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
+ *
53
+ * Returns as soon as the new pane is up; does NOT wait for claude to finish.
54
+ */
55
+ export function spawnAndDetach(opts) {
56
+ if (!isInTmux()) {
57
+ return {
58
+ status: 'not-in-tmux',
59
+ message: 'handoff requires tmux (TMUX env var not set)',
60
+ };
61
+ }
62
+ const claudeCmd = [
63
+ 'claude',
64
+ '--dangerously-skip-permissions',
65
+ shellQuote(opts.prompt),
66
+ ].join(' ');
67
+ const splitArgs = [];
68
+ if (opts.placement === 'new-window') {
69
+ splitArgs.push('new-window');
70
+ }
71
+ else {
72
+ splitArgs.push('split-window');
73
+ splitArgs.push(opts.placement === 'split-h' ? '-h' : '-v');
74
+ }
75
+ splitArgs.push('-P', '-F', '#{pane_id}');
76
+ splitArgs.push('-c', opts.cwd);
77
+ splitArgs.push(claudeCmd);
78
+ const split = spawnSync('tmux', splitArgs, { encoding: 'utf8' });
79
+ if (split.status !== 0) {
80
+ const stderrText = split.stderr.trim();
81
+ const msg = stderrText === '' ? 'tmux split-window/new-window failed' : stderrText;
82
+ return { status: 'spawn-failed', message: msg };
83
+ }
84
+ const paneId = split.stdout.trim();
85
+ // Schedule self-kill of the originating pane. We detach this so it survives
86
+ // crtr's exit.
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
+ }
94
+ return {
95
+ status: 'spawned',
96
+ paneId,
97
+ message: `handed off to pane ${paneId}; this pane will close in ${opts.killAfterSeconds}s`,
98
+ };
99
+ }
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
+ /**
189
+ * Async sibling spawn. Launches a claude session in a new tmux pane or window
190
+ * (depending on current pane count vs maxPanesPerWindow). Returns immediately
191
+ * with the crtr session id; the parent stays alive.
192
+ *
193
+ * If `fork` is set, uses `claude --resume <id> --fork-session` so the child
194
+ * gets a fresh session id and does not contend with the parent's JSONL.
195
+ */
196
+ export function spawnAgent(opts) {
197
+ if (!isInTmux()) {
198
+ return {
199
+ status: 'not-in-tmux',
200
+ message: 'crtr agent requires tmux (TMUX env var not set)',
201
+ };
202
+ }
203
+ const session = createSession();
204
+ const claudeParts = ['claude'];
205
+ if (opts.fork !== undefined) {
206
+ claudeParts.push('--resume', opts.fork.sessionId, '--fork-session');
207
+ }
208
+ claudeParts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
209
+ const claudeCmd = claudeParts.join(' ');
210
+ const useNewWindow = countPanesInCurrentWindow() >= opts.maxPanesPerWindow;
211
+ const placement = useNewWindow ? 'new-window' : 'split-window';
212
+ const tmuxArgs = [placement];
213
+ if (!useNewWindow)
214
+ tmuxArgs.push('-h');
215
+ tmuxArgs.push('-P', '-F', '#{pane_id}', '-c', opts.cwd, '-e', `CRTR_SESSION=${session.id}`, '-e', `CRTR_PIPE=${session.dir}`, claudeCmd);
216
+ const split = spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
217
+ if (split.status !== 0) {
218
+ rmSync(session.dir, { recursive: true, force: true });
219
+ const stderrText = split.stderr.trim();
220
+ const msg = stderrText === '' ? `tmux ${placement} failed` : stderrText;
221
+ return { status: 'spawn-failed', message: msg };
222
+ }
223
+ const paneId = split.stdout.trim();
224
+ writeSessionMeta(session.dir, {
225
+ paneId,
226
+ createdAt: Date.now(),
227
+ kind: opts.fork !== undefined ? 'fork' : 'new',
228
+ });
229
+ return {
230
+ status: 'spawned',
231
+ sessionId: session.id,
232
+ paneId,
233
+ placement,
234
+ message: `agent ${session.id} spawned in pane ${paneId} (${placement})`,
235
+ };
236
+ }
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
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * First user message for a spec → plan handoff.
3
+ * Bundles the full planning workflow with the spec to plan.
4
+ */
5
+ export declare function planHandoffPrompt(specPath: string, plansDir: string): string;
6
+ /**
7
+ * First user message for a plan → implementation handoff.
8
+ */
9
+ export declare function implementHandoffPrompt(planPath: string): string;
10
+ /**
11
+ * First user message for a handoff to code review of the working tree.
12
+ */
13
+ export declare function reviewHandoffPrompt(): string;