@crouton-kit/crouter 0.3.8 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/dist/cli.js +14 -24
  2. package/dist/commands/agent.d.ts +4 -0
  3. package/dist/commands/agent.js +444 -243
  4. package/dist/commands/debug.d.ts +1 -1
  5. package/dist/commands/debug.js +20 -7
  6. package/dist/commands/human.js +51 -19
  7. package/dist/commands/job.d.ts +9 -0
  8. package/dist/commands/job.js +50 -10
  9. package/dist/commands/mode.d.ts +2 -0
  10. package/dist/commands/mode.js +231 -0
  11. package/dist/commands/pkg.js +5 -0
  12. package/dist/commands/plan.d.ts +1 -1
  13. package/dist/commands/plan.js +24 -11
  14. package/dist/commands/skill.js +20 -4
  15. package/dist/commands/spec.d.ts +1 -1
  16. package/dist/commands/spec.js +24 -11
  17. package/dist/commands/sys.js +5 -0
  18. package/dist/core/__tests__/job.test.js +11 -11
  19. package/dist/core/__tests__/jobs.test.js +33 -1
  20. package/dist/core/__tests__/resolver.test.js +69 -1
  21. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  22. package/dist/core/__tests__/spawn.test.js +138 -0
  23. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  24. package/dist/core/__tests__/subagents.test.js +75 -0
  25. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  26. package/dist/core/__tests__/unknown-path.test.js +52 -0
  27. package/dist/core/bootstrap.d.ts +2 -0
  28. package/dist/core/bootstrap.js +66 -0
  29. package/dist/core/command.d.ts +58 -2
  30. package/dist/core/command.js +62 -14
  31. package/dist/core/frontmatter.d.ts +10 -0
  32. package/dist/core/frontmatter.js +24 -9
  33. package/dist/core/help.d.ts +39 -8
  34. package/dist/core/help.js +64 -32
  35. package/dist/core/jobs.d.ts +8 -2
  36. package/dist/core/jobs.js +109 -6
  37. package/dist/core/resolver.js +51 -1
  38. package/dist/core/spawn.d.ts +140 -23
  39. package/dist/core/spawn.js +392 -73
  40. package/dist/core/subagents.d.ts +18 -0
  41. package/dist/core/subagents.js +163 -0
  42. package/dist/prompts/agent.d.ts +10 -1
  43. package/dist/prompts/agent.js +34 -3
  44. package/dist/types.d.ts +21 -0
  45. package/dist/types.js +3 -0
  46. package/package.json +2 -2
@@ -7,19 +7,241 @@
7
7
  // sessionDirForId, writeSessionMeta, readSessionMeta — all superseded
8
8
  // by the jobs.ts sidecar model (result.json + log.jsonl).
9
9
  //
10
+ // Agent-CLI selection: crtr can be hosted by different coding agents (Claude
11
+ // Code, pi). `detectAgentKind()` inspects the environment crtr inherited from
12
+ // its host and `buildAgentCommand()` emits the matching invocation, so a spawn
13
+ // launches a sibling of whatever agent is driving it.
14
+ //
10
15
  // 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
16
+ // `<agent invocation>; crtr job _fail <job_id>`
17
+ // If the worker calls `crtr job submit` before the agent exits, result.json is
13
18
  // 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
19
+ // If the agent dies without a submit, `_fail` writes status 'failed'. Either way
15
20
  // `job read result` sees a terminal result.json.
16
- import { spawnSync } from 'node:child_process';
21
+ import { spawnSync, spawn } from 'node:child_process';
17
22
  export function isInTmux() {
18
23
  return Boolean(process.env.TMUX);
19
24
  }
20
25
  export function shellQuote(s) {
21
26
  return `'${s.replace(/'/g, "'\\''")}'`;
22
27
  }
28
+ /**
29
+ * Foreground `comm` names that mark a tmux pane as hosting an interactive
30
+ * agent. claude reports `claude`; pi sets process.title to `pi`. Used by
31
+ * findWindowWithSpace so panes of EITHER agent count as agent panes and a
32
+ * mixed window still qualifies for placement.
33
+ */
34
+ const AGENT_COMMS = new Set(['claude', 'pi']);
35
+ /**
36
+ * Detect which coding-agent CLI is hosting the current crtr process so spawns
37
+ * launch a matching sibling. pi exports `PI_CODING_AGENT=true` into its tool
38
+ * subprocess environment; Claude Code exports `CLAUDECODE` /
39
+ * `CLAUDE_CODE_SESSION_ID`. Defaults to claude when no signal is present
40
+ * (preserves prior behavior).
41
+ */
42
+ export function detectAgentKind() {
43
+ if (process.env.PI_CODING_AGENT === 'true')
44
+ return 'pi';
45
+ return 'claude';
46
+ }
47
+ /** Bare Claude-Code model aliases that subagent frontmatter uses. */
48
+ const CLAUDE_MODEL_ALIASES = new Set(['sonnet', 'opus', 'haiku']);
49
+ /**
50
+ * Normalize a `--model` value for the target agent CLI.
51
+ *
52
+ * Subagent frontmatter uses Claude Code's bare aliases (`sonnet`, `opus`,
53
+ * `haiku`, optionally with a `:thinking` suffix). The `claude` CLI resolves
54
+ * those natively, but `pi` maps a bare alias to its default provider —
55
+ * `amazon-bedrock` — which most users have not authenticated, so the spawn
56
+ * dies with "No API key found for amazon-bedrock". These aliases name Anthropic
57
+ * models, so under pi we pin them to the `anthropic/` provider (preserving any
58
+ * `:thinking` suffix). Values that already carry a `provider/` prefix or are
59
+ * concrete model ids are passed through untouched.
60
+ */
61
+ export function normalizeModelForKind(model, kind) {
62
+ if (kind !== 'pi')
63
+ return model;
64
+ if (model.includes('/'))
65
+ return model;
66
+ const [base, ...rest] = model.split(':');
67
+ if (!CLAUDE_MODEL_ALIASES.has(base.toLowerCase()))
68
+ return model;
69
+ const suffix = rest.length > 0 ? `:${rest.join(':')}` : '';
70
+ return `anthropic/${base.toLowerCase()}${suffix}`;
71
+ }
72
+ /**
73
+ * Build the agent-CLI invocation (no job wrapper) for the given kind.
74
+ *
75
+ * claude: `claude [-n <name>] [--resume <id> --fork-session] \
76
+ * --dangerously-skip-permissions <prompt>`
77
+ * pi: `pi [-n <name>] [--fork <id>] <prompt>`
78
+ *
79
+ * pi has no permission popups, so it needs no skip-permissions flag.
80
+ */
81
+ export function buildAgentCommand(opts, kind = detectAgentKind()) {
82
+ if (kind === 'pi') {
83
+ const parts = ['pi'];
84
+ if (opts.name !== undefined && opts.name !== '') {
85
+ parts.push('-n', shellQuote(opts.name));
86
+ }
87
+ if (opts.fork !== undefined) {
88
+ parts.push('--fork', shellQuote(opts.fork.sessionId));
89
+ }
90
+ if (opts.model !== undefined && opts.model !== '') {
91
+ parts.push('--model', shellQuote(normalizeModelForKind(opts.model, 'pi')));
92
+ }
93
+ if (opts.tools !== undefined && opts.tools.length > 0) {
94
+ parts.push('--tools', shellQuote(opts.tools.join(',')));
95
+ }
96
+ if (opts.systemPrompt !== undefined && opts.systemPrompt.trim() !== '') {
97
+ parts.push('--append-system-prompt', shellQuote(opts.systemPrompt));
98
+ }
99
+ parts.push(shellQuote(opts.prompt));
100
+ return parts.join(' ');
101
+ }
102
+ const parts = ['claude'];
103
+ if (opts.name !== undefined && opts.name !== '') {
104
+ parts.push('-n', shellQuote(opts.name));
105
+ }
106
+ if (opts.fork !== undefined) {
107
+ parts.push('--resume', shellQuote(opts.fork.sessionId), '--fork-session');
108
+ }
109
+ if (opts.model !== undefined && opts.model !== '') {
110
+ parts.push('--model', shellQuote(opts.model));
111
+ }
112
+ if (opts.systemPrompt !== undefined && opts.systemPrompt.trim() !== '') {
113
+ parts.push('--append-system-prompt', shellQuote(opts.systemPrompt));
114
+ }
115
+ parts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
116
+ return parts.join(' ');
117
+ }
118
+ /**
119
+ * Argv for a non-interactive print-mode run.
120
+ *
121
+ * claude: `claude [-n <name>] [--resume <id> --fork-session] -p \
122
+ * --dangerously-skip-permissions <prompt>`
123
+ * pi: `pi [-n <name>] [--fork <id>] -p <prompt>`
124
+ *
125
+ * Returned as a cmd + args array so callers can spawn without a shell.
126
+ */
127
+ export function buildAgentPrintArgv(opts, kind = detectAgentKind()) {
128
+ if (kind === 'pi') {
129
+ const args = [];
130
+ if (opts.name !== undefined && opts.name !== '')
131
+ args.push('-n', opts.name);
132
+ if (opts.fork !== undefined)
133
+ args.push('--fork', opts.fork.sessionId);
134
+ if (opts.model !== undefined && opts.model !== '')
135
+ args.push('--model', normalizeModelForKind(opts.model, 'pi'));
136
+ if (opts.tools !== undefined && opts.tools.length > 0)
137
+ args.push('--tools', opts.tools.join(','));
138
+ if (opts.systemPrompt !== undefined && opts.systemPrompt.trim() !== '') {
139
+ args.push('--append-system-prompt', opts.systemPrompt);
140
+ }
141
+ args.push('-p', opts.prompt);
142
+ return { cmd: 'pi', args };
143
+ }
144
+ const args = [];
145
+ if (opts.name !== undefined && opts.name !== '')
146
+ args.push('-n', opts.name);
147
+ if (opts.fork !== undefined)
148
+ args.push('--resume', opts.fork.sessionId, '--fork-session');
149
+ if (opts.model !== undefined && opts.model !== '')
150
+ args.push('--model', opts.model);
151
+ if (opts.systemPrompt !== undefined && opts.systemPrompt.trim() !== '') {
152
+ args.push('--append-system-prompt', opts.systemPrompt);
153
+ }
154
+ args.push('-p', '--dangerously-skip-permissions', opts.prompt);
155
+ return { cmd: 'claude', args };
156
+ }
157
+ /** Same as buildAgentPrintArgv but rendered as a single shell-quoted string. */
158
+ export function buildAgentPrintCommand(opts, kind = detectAgentKind()) {
159
+ const { cmd, args } = buildAgentPrintArgv(opts, kind);
160
+ return [cmd, ...args.map(shellQuote)].join(' ');
161
+ }
162
+ /**
163
+ * Run the agent headlessly and resolve once it exits. A blocking caller awaits
164
+ * this. stdout is captured as the result; a non-zero exit yields status
165
+ * 'failed' with the combined output.
166
+ */
167
+ export function runAgentHeadless(opts) {
168
+ const { cmd, args } = buildAgentPrintArgv({
169
+ prompt: opts.prompt,
170
+ name: opts.name,
171
+ systemPrompt: opts.systemPrompt,
172
+ model: opts.model,
173
+ tools: opts.tools,
174
+ });
175
+ return new Promise((resolve) => {
176
+ let out = '';
177
+ let err = '';
178
+ let child;
179
+ try {
180
+ // stdin 'ignore' so the agent never blocks waiting for stdin EOF (the
181
+ // prompt is passed as an argv arg, not piped).
182
+ child = spawn(cmd, args, { cwd: opts.cwd, env: process.env, stdio: ['ignore', 'pipe', 'pipe'] });
183
+ }
184
+ catch (e) {
185
+ resolve({ status: 'failed', output: `failed to launch ${cmd}: ${String(e)}`, exitCode: null });
186
+ return;
187
+ }
188
+ child.stdout?.on('data', (d) => { out += d.toString(); });
189
+ child.stderr?.on('data', (d) => { err += d.toString(); });
190
+ child.on('error', (e) => {
191
+ resolve({ status: 'failed', output: `failed to launch ${cmd}: ${String(e)}`, exitCode: null });
192
+ });
193
+ child.on('close', (code) => {
194
+ if (code === 0) {
195
+ resolve({ status: 'done', output: out.trim() !== '' ? out : '(agent produced no output)', exitCode: 0 });
196
+ }
197
+ else {
198
+ const combined = `${out}${err}`.trim();
199
+ resolve({
200
+ status: 'failed',
201
+ output: combined !== '' ? combined : `agent exited with code ${code ?? 'null'}`,
202
+ exitCode: code,
203
+ });
204
+ }
205
+ });
206
+ });
207
+ }
208
+ /**
209
+ * Launch a headless agent detached (background). Its print output is captured
210
+ * and delivered to the job via `crtr job submit`; a non-zero exit marks the job
211
+ * failed. Returns immediately with the wrapper pid (recorded for crash
212
+ * detection). No tmux required.
213
+ */
214
+ export function spawnHeadlessDetached(opts) {
215
+ const agentCmd = buildAgentPrintCommand({
216
+ prompt: opts.prompt,
217
+ name: opts.name,
218
+ systemPrompt: opts.systemPrompt,
219
+ model: opts.model,
220
+ tools: opts.tools,
221
+ });
222
+ const jid = shellQuote(opts.jobId);
223
+ // On failure, forward the captured stdout+stderr as the result body so the
224
+ // real cause (e.g. an auth error) is visible via `crtr job read result`/`logs`
225
+ // instead of being discarded behind a generic exit-code reason.
226
+ const wrapper = `out="$(${agentCmd} 2>&1)"; ec=$?; ` +
227
+ `if [ "$ec" -eq 0 ]; then ` +
228
+ `if [ -z "$out" ]; then out='(agent produced no output)'; fi; ` +
229
+ `printf '%s' "$out" | crtr job submit ${jid}; ` +
230
+ `else printf '%s' "$out" | crtr job submit ${jid} --status failed --reason "agent exited with code $ec"; fi`;
231
+ try {
232
+ const child = spawn('sh', ['-c', wrapper], {
233
+ cwd: opts.cwd,
234
+ env: process.env,
235
+ detached: true,
236
+ stdio: 'ignore',
237
+ });
238
+ child.unref();
239
+ return { status: 'spawned', pid: child.pid, message: `headless agent started (pid ${child.pid ?? 'unknown'})` };
240
+ }
241
+ catch (e) {
242
+ return { status: 'spawn-failed', message: `failed to launch headless agent: ${String(e)}` };
243
+ }
244
+ }
23
245
  export function countPanesInCurrentWindow() {
24
246
  const result = spawnSync('tmux', ['list-panes', '-F', '#{pane_id}'], {
25
247
  encoding: 'utf8',
@@ -51,8 +273,8 @@ function listWindowsInCurrentSession() {
51
273
  *
52
274
  * tmux's `#{pane_current_command}` is unreliable on macOS because the Claude
53
275
  * Code CLI sets `process.title` to its version (e.g. `2.1.143`), which is what
54
- * tmux then reports. Going through the TTY + `ps` gives us the real binary
55
- * name (`claude`) from the kernel.
276
+ * tmux then reports. Going through the TTY + `ps` gives us the real foreground
277
+ * `comm` (`claude`, or `pi` from its process.title) from the kernel.
56
278
  */
57
279
  function paneTtysByWindow() {
58
280
  const result = spawnSync('tmux', ['list-panes', '-s', '-F', '#{window_id} #{pane_tty}'], { encoding: 'utf8' });
@@ -111,27 +333,36 @@ function foregroundCommsByTty() {
111
333
  }
112
334
  /**
113
335
  * Find a window in the current tmux session with fewer than `maxPanesPerWindow`
114
- * panes AND where every existing pane has `claude` as a foreground process.
115
- * Prefers the active window so the spawned pane is visible to the user;
116
- * otherwise falls back to the first other eligible window. Returns the tmux
117
- * window id (e.g. `@5`) to pass via `-t`, or null if no window qualifies.
336
+ * panes AND where every existing pane hosts an agent (claude or pi) as its
337
+ * foreground process. Prefers the active window so the spawned pane is visible
338
+ * to the user; otherwise falls back to the first other eligible window. Returns
339
+ * the tmux window id (e.g. `@5`) to pass via `-t`, or null if no window qualifies.
118
340
  *
119
341
  * Windows holding non-agent panes (dashboards, log tails, idle shells, editors,
120
342
  * REPLs, etc.) are skipped so spawning never disrupts those workflows. A pane
121
- * qualifies as long as `claude` is among its foreground commands — co-resident
122
- * helpers like `caffeinate` don't disqualify it.
343
+ * qualifies as long as an agent comm is among its foreground commands —
344
+ * co-resident helpers like `caffeinate` don't disqualify it.
123
345
  */
124
346
  export function findWindowWithSpace(maxPanesPerWindow) {
125
347
  const windows = listWindowsInCurrentSession();
126
348
  const ttysByWindow = paneTtysByWindow();
127
349
  const fgByTty = foregroundCommsByTty();
128
- const isClaudeOnly = (windowId) => {
350
+ const isAgentOnly = (windowId) => {
129
351
  const ttys = ttysByWindow.get(windowId);
130
352
  if (ttys === undefined || ttys.length === 0)
131
353
  return false;
132
- return ttys.every((tty) => fgByTty.get(tty)?.has('claude') === true);
354
+ return ttys.every((tty) => {
355
+ const comms = fgByTty.get(tty);
356
+ if (comms === undefined)
357
+ return false;
358
+ for (const c of comms) {
359
+ if (AGENT_COMMS.has(c))
360
+ return true;
361
+ }
362
+ return false;
363
+ });
133
364
  };
134
- const eligible = windows.filter((w) => w.paneCount < maxPanesPerWindow && isClaudeOnly(w.windowId));
365
+ const eligible = windows.filter((w) => w.paneCount < maxPanesPerWindow && isAgentOnly(w.windowId));
135
366
  const active = eligible.find((w) => w.isActive);
136
367
  if (active !== undefined)
137
368
  return active.windowId;
@@ -163,22 +394,22 @@ export function scheduleKillCurrentPane(delaySeconds) {
163
394
  /**
164
395
  * Build the wrapper shell command passed to the tmux pane.
165
396
  *
166
- * Pattern: `claude <args>; crtr job _fail <job_id>`
397
+ * Pattern: `<agent invocation>; crtr job _fail <job_id>`
167
398
  *
168
- * If the worker submits via `crtr job submit` before claude exits,
399
+ * If the worker submits via `crtr job submit` before the agent exits,
169
400
  * result.json is already written (`done`); `_fail` sees it and is a no-op.
170
- * If claude crashes/exits without submitting, `_fail` writes status `failed`
401
+ * If the agent crashes/exits without submitting, `_fail` writes status `failed`
171
402
  * so `job read result` can distinguish completion from crash.
172
403
  */
173
- function wrapperCmd(claudeCmd, jobId) {
174
- return `${claudeCmd}; crtr job _fail ${shellQuote(jobId)}`;
404
+ function wrapperCmd(agentCmd, jobId) {
405
+ return `${agentCmd}; crtr job _fail ${shellQuote(jobId)}`;
175
406
  }
176
407
  /**
177
- * Fire-and-forget: launch an interactive `claude` in a new pane (or window),
408
+ * Fire-and-forget: launch an interactive agent in a new pane (or window),
178
409
  * then schedule the originating pane to be killed after `killAfterSeconds`.
179
410
  *
180
411
  * No custom system prompt — the task is delivered as the first user message.
181
- * Returns as soon as the new pane is up; does NOT wait for claude to finish.
412
+ * Returns as soon as the new pane is up; does NOT wait for the agent to finish.
182
413
  */
183
414
  export function spawnAndDetach(opts) {
184
415
  if (!isInTmux()) {
@@ -187,15 +418,9 @@ export function spawnAndDetach(opts) {
187
418
  message: 'handoff requires tmux (TMUX env var not set)',
188
419
  };
189
420
  }
190
- const buildClaudeInner = () => {
191
- const parts = ['claude'];
192
- if (opts.name !== undefined && opts.name !== '') {
193
- parts.push('-n', shellQuote(opts.name));
194
- }
195
- parts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
196
- return parts.join(' ');
197
- };
198
- const inner = opts.command !== undefined ? opts.command : buildClaudeInner();
421
+ const inner = opts.command !== undefined
422
+ ? opts.command
423
+ : buildAgentCommand({ prompt: opts.prompt, name: opts.name });
199
424
  const useFailGuard = opts.failGuard !== false;
200
425
  const fullCmd = useFailGuard ? wrapperCmd(inner, opts.jobId) : inner;
201
426
  const splitArgs = [];
@@ -234,17 +459,126 @@ export function spawnAndDetach(opts) {
234
459
  message: `handed off to pane ${paneId}; this pane will close in ${opts.killAfterSeconds}s`,
235
460
  };
236
461
  }
462
+ // ---------------------------------------------------------------------------
463
+ // Dedicated subagent session
464
+ //
465
+ // Every subagent crtr spawns lands in a tmux session dedicated to the pi/claude
466
+ // session that launched it, instead of splitting the user's working window. The
467
+ // session is keyed on the originating pane ($TMUX_PANE) so it is reused across
468
+ // spawns for the life of that pane. Spawns are interactive (headed) panes but
469
+ // never steal focus — the user jumps to the session with Alt-o.
470
+ //
471
+ // Navigation state is written as tmux user-options so a keybinding can toggle
472
+ // between the two without crtr involvement:
473
+ // - origin session: @crtr_subagent_session = <subagent session name>
474
+ // - subagent session: @crtr_origin_session = <origin session id>
475
+ // @crtr_origin_pane = <origin pane id>
476
+ // ---------------------------------------------------------------------------
477
+ function tmuxQuery(args) {
478
+ const r = spawnSync('tmux', args, { encoding: 'utf8' });
479
+ if (r.status !== 0)
480
+ return null;
481
+ return r.stdout.trim();
482
+ }
483
+ /** Originating pane id + session id of the host (pi/claude) crtr runs under. */
484
+ export function originContext() {
485
+ const pane = process.env.TMUX_PANE;
486
+ if (pane === undefined || pane === '')
487
+ return null;
488
+ const sessionId = tmuxQuery(['display-message', '-p', '-t', pane, '#{session_id}']);
489
+ if (sessionId === null || sessionId === '')
490
+ return null;
491
+ return { pane, sessionId };
492
+ }
493
+ /** Deterministic subagent session name for an originating pane id (e.g. `%5`). */
494
+ export function subagentSessionName(pane) {
495
+ return `crtr-agents-${pane.replace(/[^a-zA-Z0-9]/g, '')}`;
496
+ }
497
+ /** A window in `session` with fewer than `maxPanes` panes (active preferred). */
498
+ function findWindowWithSpaceInSession(session, maxPanes) {
499
+ const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id} #{window_panes} #{window_active}'], { encoding: 'utf8' });
500
+ if (r.status !== 0)
501
+ return null;
502
+ const wins = r.stdout
503
+ .split('\n')
504
+ .filter((l) => l.trim() !== '')
505
+ .map((l) => {
506
+ const [id, count, active] = l.split(' ');
507
+ return { id, count: Number.parseInt(count, 10), active: active === '1' };
508
+ });
509
+ const eligible = wins.filter((w) => w.count < maxPanes);
510
+ const active = eligible.find((w) => w.active);
511
+ if (active !== undefined)
512
+ return active.id;
513
+ return eligible[0]?.id ?? null;
514
+ }
237
515
  /**
238
- * Async sibling spawn. Launches a claude session in a tmux pane, progressively
239
- * filling existing windows up to `maxPanesPerWindow` before creating a new
240
- * window. Returns immediately with the pane id; the parent stays alive.
241
- *
242
- * Placement order:
243
- * 1. Current window, if it has space.
244
- * 2. Any other window in the session with space.
245
- * 3. New window (every existing window at capacity).
516
+ * Ensure the dedicated subagent session exists and launch `fullCmd` in it,
517
+ * filling windows up to `maxPanes` before opening a new window. Records the
518
+ * cross-session navigation options on both sides. Never switches focus the
519
+ * user navigates to the subagent session themselves (Alt-o).
520
+ */
521
+ function placeInSubagentSession(opts) {
522
+ if (!isInTmux()) {
523
+ return { status: 'not-in-tmux', message: 'requires tmux (TMUX env var not set)' };
524
+ }
525
+ const origin = originContext();
526
+ if (origin === null) {
527
+ return { status: 'not-in-tmux', message: 'requires tmux (TMUX_PANE not set)' };
528
+ }
529
+ const session = subagentSessionName(origin.pane);
530
+ const envArgs = opts.jobId !== undefined ? ['-e', `CRTR_JOB_ID=${opts.jobId}`] : [];
531
+ const exists = spawnSync('tmux', ['has-session', '-t', session], { encoding: 'utf8' }).status === 0;
532
+ let paneId;
533
+ let placement;
534
+ if (!exists) {
535
+ const r = spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', opts.cwd, '-P', '-F', '#{pane_id}', ...envArgs, opts.fullCmd], { encoding: 'utf8' });
536
+ if (r.status !== 0) {
537
+ return { status: 'spawn-failed', message: r.stderr.trim() || 'tmux new-session failed' };
538
+ }
539
+ paneId = r.stdout.trim();
540
+ placement = 'new-session';
541
+ }
542
+ else {
543
+ const targetWindow = findWindowWithSpaceInSession(session, opts.maxPanes);
544
+ if (targetWindow === null) {
545
+ const r = spawnSync('tmux', ['new-window', '-t', session, '-c', opts.cwd, '-P', '-F', '#{pane_id}', ...envArgs, opts.fullCmd], { encoding: 'utf8' });
546
+ if (r.status !== 0) {
547
+ return { status: 'spawn-failed', message: r.stderr.trim() || 'tmux new-window failed' };
548
+ }
549
+ paneId = r.stdout.trim();
550
+ placement = 'new-window';
551
+ }
552
+ else {
553
+ const r = spawnSync('tmux', ['split-window', '-h', '-t', targetWindow, '-c', opts.cwd, '-P', '-F', '#{pane_id}', ...envArgs, opts.fullCmd], { encoding: 'utf8' });
554
+ if (r.status !== 0) {
555
+ return { status: 'spawn-failed', message: r.stderr.trim() || 'tmux split-window failed' };
556
+ }
557
+ paneId = r.stdout.trim();
558
+ placement = 'split-window';
559
+ spawnSync('tmux', ['select-layout', '-t', paneId, 'even-horizontal'], { encoding: 'utf8' });
560
+ }
561
+ }
562
+ // Record navigation state for the M-o toggle keybinding.
563
+ spawnSync('tmux', ['set-option', '-t', origin.sessionId, '@crtr_subagent_session', session], { encoding: 'utf8' });
564
+ spawnSync('tmux', ['set-option', '-t', session, '@crtr_origin_session', origin.sessionId], { encoding: 'utf8' });
565
+ spawnSync('tmux', ['set-option', '-t', session, '@crtr_origin_pane', origin.pane], { encoding: 'utf8' });
566
+ return {
567
+ status: 'spawned',
568
+ paneId,
569
+ session,
570
+ placement,
571
+ message: `agent spawned in pane ${paneId} of session ${session} (${placement})`,
572
+ };
573
+ }
574
+ /**
575
+ * Async sibling spawn. Launches an interactive agent (claude or pi, per
576
+ * detectAgentKind) in the dedicated subagent session, progressively filling
577
+ * windows up to `maxPanesPerWindow` before creating a new window. Returns
578
+ * immediately with the pane id; the parent stays alive. Focus is never
579
+ * switched — the user jumps to the subagent session with Alt-o.
246
580
  *
247
- * If `fork` is set, uses `claude --resume <id> --fork-session`.
581
+ * If `fork` is set, forks the host session into a fresh one.
248
582
  */
249
583
  export function spawnAgent(opts) {
250
584
  if (!isInTmux()) {
@@ -253,40 +587,25 @@ export function spawnAgent(opts) {
253
587
  message: 'crtr job requires tmux (TMUX env var not set)',
254
588
  };
255
589
  }
256
- const claudeParts = ['claude'];
257
- if (opts.name !== undefined && opts.name !== '') {
258
- claudeParts.push('-n', shellQuote(opts.name));
259
- }
260
- if (opts.fork !== undefined) {
261
- claudeParts.push('--resume', opts.fork.sessionId, '--fork-session');
262
- }
263
- claudeParts.push('--dangerously-skip-permissions', shellQuote(opts.prompt));
264
- const claudeCmd = claudeParts.join(' ');
265
- const fullCmd = wrapperCmd(claudeCmd, opts.jobId);
266
- const targetWindow = findWindowWithSpace(opts.maxPanesPerWindow);
267
- const placement = targetWindow === null ? 'new-window' : 'split-window';
268
- const tmuxArgs = [placement];
269
- if (placement === 'split-window') {
270
- tmuxArgs.push('-h', '-t', targetWindow);
271
- }
272
- tmuxArgs.push('-P', '-F', '#{pane_id}', '-c', opts.cwd, '-e', `CRTR_JOB_ID=${opts.jobId}`, fullCmd);
273
- const split = spawnSync('tmux', tmuxArgs, { encoding: 'utf8' });
274
- if (split.status !== 0) {
275
- const stderrText = split.stderr.trim();
276
- const msg = stderrText === '' ? `tmux ${placement} failed` : stderrText;
277
- return { status: 'spawn-failed', message: msg };
278
- }
279
- const paneId = split.stdout.trim();
280
- // Re-balance the target window's panes evenly so the new pane doesn't end up
281
- // half the size of its siblings. -t <pane_id> resolves to the window it lives
282
- // in for both placements (split + new-window).
283
- spawnSync('tmux', ['select-layout', '-t', paneId, 'even-horizontal'], {
284
- encoding: 'utf8',
590
+ const agentCmd = buildAgentCommand({
591
+ prompt: opts.prompt,
592
+ name: opts.name,
593
+ fork: opts.fork,
594
+ systemPrompt: opts.systemPrompt,
595
+ model: opts.model,
596
+ tools: opts.tools,
597
+ });
598
+ const fullCmd = wrapperCmd(agentCmd, opts.jobId);
599
+ const placed = placeInSubagentSession({
600
+ fullCmd,
601
+ jobId: opts.jobId,
602
+ cwd: opts.cwd,
603
+ maxPanes: opts.maxPanesPerWindow,
285
604
  });
286
605
  return {
287
- status: 'spawned',
288
- paneId,
289
- placement,
290
- message: `agent spawned in pane ${paneId} (${placement})`,
606
+ status: placed.status,
607
+ paneId: placed.paneId,
608
+ placement: placed.placement === 'split-window' ? 'split-window' : 'new-window',
609
+ message: placed.message,
291
610
  };
292
611
  }
@@ -0,0 +1,18 @@
1
+ import type { Scope, Subagent } from '../types.js';
2
+ /** `<scope-root>/agents` for a given scope, or null when the scope has no root. */
3
+ export declare function scopeAgentsDir(scope: Scope): string | null;
4
+ /** Scope-root agents under `<scope-root>/agents/*.md`. */
5
+ export declare function listScopeRootSubagents(scope: Scope): Subagent[];
6
+ /** All subagents: scope-root agents (project, user) plus enabled plugins. */
7
+ export declare function listSubagents(scopeFilter?: Scope): Subagent[];
8
+ /** Canonical, unambiguous identifier: `<plugin>/<name>`, or bare `<name>` for
9
+ * scope-root agents. */
10
+ export declare function subagentId(a: Subagent): string;
11
+ export interface SubagentResolutionOpts {
12
+ scope?: Scope;
13
+ plugin?: string;
14
+ }
15
+ /** Resolve a subagent by name. Accepts a bare `<name>` or a `<plugin>/<name>`
16
+ * qualifier. Project precedes user precedes builtin; scope-root precedes
17
+ * plugin. Throws notFound / ambiguous as the skill resolver does. */
18
+ export declare function resolveSubagent(rawName: string, opts?: SubagentResolutionOpts): Subagent;