@crouton-kit/crouter 0.2.6 → 0.3.1

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 (79) hide show
  1. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +9 -9
  2. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +19 -19
  3. package/dist/cli.js +42 -37
  4. package/dist/commands/__tests__/human.test.d.ts +1 -0
  5. package/dist/commands/__tests__/human.test.js +214 -0
  6. package/dist/commands/__tests__/skill.test.d.ts +1 -0
  7. package/dist/commands/__tests__/skill.test.js +287 -0
  8. package/dist/commands/debug.d.ts +3 -0
  9. package/dist/commands/debug.js +179 -0
  10. package/dist/commands/flow.d.ts +2 -0
  11. package/dist/commands/flow.js +24 -0
  12. package/dist/commands/human.d.ts +2 -0
  13. package/dist/commands/human.js +480 -0
  14. package/dist/commands/job.d.ts +2 -0
  15. package/dist/commands/job.js +669 -0
  16. package/dist/commands/pkg.d.ts +2 -0
  17. package/dist/commands/pkg.js +1021 -0
  18. package/dist/commands/plan.d.ts +4 -2
  19. package/dist/commands/plan.js +306 -22
  20. package/dist/commands/skill.d.ts +2 -2
  21. package/dist/commands/skill.js +607 -456
  22. package/dist/commands/spec.d.ts +3 -2
  23. package/dist/commands/spec.js +283 -10
  24. package/dist/commands/sys.d.ts +2 -0
  25. package/dist/commands/sys.js +712 -0
  26. package/dist/core/__tests__/argv-parser.test.d.ts +1 -0
  27. package/dist/core/__tests__/argv-parser.test.js +199 -0
  28. package/dist/core/__tests__/flow-leaves.test.d.ts +1 -0
  29. package/dist/core/__tests__/flow-leaves.test.js +248 -0
  30. package/dist/core/__tests__/job.test.d.ts +1 -0
  31. package/dist/core/__tests__/job.test.js +346 -0
  32. package/dist/core/__tests__/pkg.test.d.ts +1 -0
  33. package/dist/core/__tests__/pkg.test.js +218 -0
  34. package/dist/core/__tests__/sys.test.d.ts +1 -0
  35. package/dist/core/__tests__/sys.test.js +208 -0
  36. package/dist/core/artifact.d.ts +29 -18
  37. package/dist/core/artifact.js +78 -221
  38. package/dist/core/auto-update.js +11 -3
  39. package/dist/core/command.d.ts +36 -0
  40. package/dist/core/command.js +287 -0
  41. package/dist/core/errors.d.ts +3 -0
  42. package/dist/core/errors.js +5 -0
  43. package/dist/core/fs-utils.d.ts +1 -0
  44. package/dist/core/fs-utils.js +4 -0
  45. package/dist/core/help.d.ts +98 -0
  46. package/dist/core/help.js +163 -0
  47. package/dist/core/io.d.ts +29 -0
  48. package/dist/core/io.js +83 -0
  49. package/dist/core/jobs.d.ts +87 -0
  50. package/dist/core/jobs.js +353 -0
  51. package/dist/core/pagination.d.ts +33 -0
  52. package/dist/core/pagination.js +89 -0
  53. package/dist/core/self-update.d.ts +21 -0
  54. package/dist/{commands/update.js → core/self-update.js} +28 -63
  55. package/dist/core/spawn.d.ts +47 -65
  56. package/dist/core/spawn.js +78 -228
  57. package/dist/prompts/agent.d.ts +10 -5
  58. package/dist/prompts/agent.js +51 -74
  59. package/dist/prompts/debug.d.ts +8 -0
  60. package/dist/prompts/debug.js +37 -0
  61. package/dist/prompts/review.js +4 -11
  62. package/dist/prompts/skill.d.ts +0 -1
  63. package/dist/prompts/skill.js +95 -149
  64. package/package.json +4 -2
  65. package/dist/commands/agent.d.ts +0 -2
  66. package/dist/commands/agent.js +0 -265
  67. package/dist/commands/config.d.ts +0 -2
  68. package/dist/commands/config.js +0 -146
  69. package/dist/commands/doctor.d.ts +0 -2
  70. package/dist/commands/doctor.js +0 -268
  71. package/dist/commands/marketplace.d.ts +0 -2
  72. package/dist/commands/marketplace.js +0 -365
  73. package/dist/commands/plugin.d.ts +0 -2
  74. package/dist/commands/plugin.js +0 -367
  75. package/dist/commands/update.d.ts +0 -4
  76. package/dist/prompts/plan.d.ts +0 -1
  77. package/dist/prompts/plan.js +0 -175
  78. package/dist/prompts/spec.d.ts +0 -1
  79. package/dist/prompts/spec.js +0 -153
@@ -1,42 +1,44 @@
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).
1
4
  import { spawnSync } from 'node:child_process';
2
5
  import { fileURLToPath } from 'node:url';
3
6
  import { join, dirname } from 'node:path';
4
7
  import { readFileSync } from 'node:fs';
5
- import { err, warn, handleError } from '../core/output.js';
6
- import { projectScopeRoot } from '../core/scope.js';
7
- import { updateState } from '../core/config.js';
8
- import { listAllPlugins, listAllMarketplaces } from '../core/resolver.js';
9
- import { pull, fetch, currentSha, remoteSha } from '../core/git.js';
10
- import { nowIso } from '../core/fs-utils.js';
11
- import { network, general } from '../core/errors.js';
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';
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = dirname(__filename);
14
- const PKG_ROOT = join(__dirname, '..', '..', '..');
15
+ // src/core/self-update.ts up to src/ → up to pkg root
16
+ const PKG_ROOT = join(__dirname, '..', '..');
15
17
  const PACKAGE_JSON_PATH = join(PKG_ROOT, 'package.json');
16
- function currentVersion() {
18
+ export function currentVersion() {
17
19
  const raw = readFileSync(PACKAGE_JSON_PATH, 'utf8');
18
20
  const parsed = JSON.parse(raw);
19
21
  return parsed.version;
20
22
  }
21
- function selfUpdate() {
23
+ export function selfUpdate() {
22
24
  const res = spawnSync('npm', ['i', '-g', '@crouton-kit/crtr@latest'], { stdio: 'inherit' });
23
25
  if (res.status !== 0) {
24
26
  throw general('npm install failed');
25
27
  }
26
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. */
27
31
  export function selfCheck() {
28
32
  const res = spawnSync('npm', ['view', '@crouton-kit/crtr', 'version'], { encoding: 'utf8' });
29
33
  if (res.status !== 0) {
30
- warn('could not check for crtr updates (network unavailable)');
31
- return;
34
+ return null;
32
35
  }
33
36
  const latest = res.stdout.trim();
34
37
  const current = currentVersion();
35
- if (latest !== current) {
36
- err(`crtr: v${latest} available (current ${current}) — run \`crtr update --self\``);
37
- }
38
+ return { current, latest };
38
39
  }
39
- function contentUpdate() {
40
+ /** Pull updates for all installed marketplaces and standalone plugins. */
41
+ export function contentUpdate() {
40
42
  const marketplaces = listAllMarketplaces();
41
43
  for (const mkt of marketplaces) {
42
44
  const res = pull(mkt.root);
@@ -66,19 +68,21 @@ function contentUpdate() {
66
68
  });
67
69
  }
68
70
  }
71
+ /** Check whether any marketplace/plugin has updates available.
72
+ * Returns per-item status without applying anything. */
69
73
  export function contentCheck() {
74
+ const results = [];
70
75
  const marketplaces = listAllMarketplaces();
71
76
  for (const mkt of marketplaces) {
72
77
  const fetchRes = fetch(mkt.root, mkt.ref);
73
78
  if (fetchRes.status !== 0) {
74
- warn(`could not fetch ${mkt.name} (network unavailable)`);
79
+ results.push({ name: mkt.name, kind: 'marketplace', current: null, latest: null, up_to_date: true, unreachable: true });
75
80
  continue;
76
81
  }
77
82
  const head = currentSha(mkt.root);
78
83
  const remote = remoteSha(mkt.root, mkt.ref);
79
- if (head !== null && remote !== null && head !== remote) {
80
- err(`crtr: marketplace ${mkt.name} has updates available run \`crtr update --content\``);
81
- }
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 });
82
86
  }
83
87
  const plugins = listAllPlugins();
84
88
  for (const plugin of plugins) {
@@ -89,52 +93,13 @@ export function contentCheck() {
89
93
  const ref = 'main';
90
94
  const fetchRes = fetch(plugin.root, ref);
91
95
  if (fetchRes.status !== 0) {
92
- warn(`could not fetch ${plugin.name} (network unavailable)`);
96
+ results.push({ name: plugin.name, kind: 'plugin', current: null, latest: null, up_to_date: true, unreachable: true });
93
97
  continue;
94
98
  }
95
99
  const head = currentSha(plugin.root);
96
100
  const remote = remoteSha(plugin.root, ref);
97
- if (head !== null && remote !== null && head !== remote) {
98
- err(`crtr: plugin ${plugin.name} has updates available run \`crtr plugin update ${plugin.name}\``);
99
- }
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 });
100
103
  }
101
- }
102
- export function registerUpdateCommand(program) {
103
- program
104
- .command('update')
105
- .description('update crtr itself and/or installed plugins and marketplaces')
106
- .option('--self', 'update crtr binary via npm')
107
- .option('--content', 'pull updates for all installed plugins and marketplaces')
108
- .option('--check', 'check for updates without applying them')
109
- .action(async (opts) => {
110
- try {
111
- const runSelf = opts.self === true;
112
- const runContent = opts.content === true;
113
- const runBoth = !runSelf && !runContent;
114
- if (opts.check) {
115
- if (runSelf || runBoth)
116
- selfCheck();
117
- if (runContent || runBoth)
118
- contentCheck();
119
- return;
120
- }
121
- if (runSelf || runBoth) {
122
- selfUpdate();
123
- const scopes = ['user'];
124
- if (projectScopeRoot())
125
- scopes.unshift('project');
126
- for (const scope of scopes) {
127
- updateState(scope, (s) => {
128
- s.last_self_check = nowIso();
129
- });
130
- }
131
- }
132
- if (runContent || runBoth) {
133
- contentUpdate();
134
- }
135
- }
136
- catch (e) {
137
- handleError(e);
138
- }
139
- });
104
+ return results;
140
105
  }
@@ -1,95 +1,77 @@
1
- export interface SidePaneOptions {
2
- /** Full first user message task + checklist + submit instructions all in one. */
1
+ export interface SpawnAgentOptions {
2
+ /** First user message for the new claude session. */
3
3
  prompt: string;
4
4
  cwd: string;
5
- timeoutMs: number;
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 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
+ export interface SpawnAgentResult {
15
+ status: 'spawned' | 'spawn-failed' | 'not-in-tmux';
16
+ /** tmux pane id of the spawned pane. */
14
17
  paneId?: string;
15
- sessionDir: string;
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
- /** Full first user message for the new claude session. No custom system prompt. */
24
- prompt: string;
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 crtr session id; the parent stays alive.
73
+ * with the pane id; the parent stays alive.
81
74
  *
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.
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>;
@@ -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
- 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() {
17
+ export function isInTmux() {
13
18
  return Boolean(process.env.TMUX);
14
19
  }
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);
20
+ export function shellQuote(s) {
21
+ return `'${s.replace(/'/g, "'\\''")}'`;
31
22
  }
32
- function paneAlive(paneId) {
33
- const result = spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
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 false;
38
- return result.stdout.split('\n').some((line) => line.trim() === paneId);
28
+ return 0;
29
+ return result.stdout.split('\n').filter((line) => line.trim() !== '').length;
39
30
  }
40
- function killPane(paneId) {
41
- spawnSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' });
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
- function shellQuote(s) {
44
- return `'${s.replace(/'/g, "'\\''")}'`;
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 claudeCmd = [
63
- 'claude',
64
- '--dangerously-skip-permissions',
65
- shellQuote(opts.prompt),
66
- ].join(' ');
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
- splitArgs.push(claudeCmd);
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. 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
- }
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 crtr session id; the parent stays alive.
122
+ * with the pane id; the parent stays alive.
192
123
  *
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.
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 agent requires tmux (TMUX env var not set)',
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', `CRTR_SESSION=${session.id}`, '-e', `CRTR_PIPE=${session.dir}`, claudeCmd);
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 ${session.id} spawned in pane ${paneId} (${placement})`,
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
- }