@crouton-kit/crouter 0.3.11 → 0.3.12

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 (182) hide show
  1. package/bin/crtrd +2 -0
  2. package/dist/builtin-personas/design/base.md +9 -0
  3. package/dist/builtin-personas/design/orchestrator.md +10 -0
  4. package/dist/builtin-personas/developer/base.md +9 -0
  5. package/dist/builtin-personas/developer/orchestrator.md +12 -0
  6. package/dist/builtin-personas/explore/base.md +9 -0
  7. package/dist/builtin-personas/explore/orchestrator.md +9 -0
  8. package/dist/builtin-personas/general/base.md +5 -0
  9. package/dist/builtin-personas/general/orchestrator.md +7 -0
  10. package/dist/builtin-personas/orchestration-kernel.md +71 -0
  11. package/dist/builtin-personas/plan/base.md +7 -0
  12. package/dist/builtin-personas/plan/orchestrator.md +12 -0
  13. package/dist/builtin-personas/review/base.md +7 -0
  14. package/dist/builtin-personas/review/orchestrator.md +9 -0
  15. package/dist/builtin-personas/runtime-base.md +39 -0
  16. package/dist/builtin-personas/spec/base.md +7 -0
  17. package/dist/builtin-personas/spec/orchestrator.md +10 -0
  18. package/dist/builtin-skills/skills/design/SKILL.md +51 -0
  19. package/dist/builtin-skills/skills/development/SKILL.md +109 -0
  20. package/dist/builtin-skills/skills/planning/SKILL.md +59 -0
  21. package/dist/builtin-skills/skills/spec/SKILL.md +83 -0
  22. package/dist/cli.js +14 -6
  23. package/dist/commands/{mode.d.ts → attention.d.ts} +1 -1
  24. package/dist/commands/attention.js +152 -0
  25. package/dist/commands/canvas.d.ts +2 -0
  26. package/dist/commands/canvas.js +35 -0
  27. package/dist/commands/daemon.d.ts +2 -0
  28. package/dist/commands/daemon.js +111 -0
  29. package/dist/commands/dashboard.d.ts +2 -0
  30. package/dist/commands/dashboard.js +65 -0
  31. package/dist/commands/human/prompts.d.ts +5 -0
  32. package/dist/commands/human/prompts.js +269 -0
  33. package/dist/commands/human/queue.d.ts +3 -0
  34. package/dist/commands/human/queue.js +133 -0
  35. package/dist/commands/human/shared.d.ts +43 -0
  36. package/dist/commands/human/shared.js +107 -0
  37. package/dist/commands/human.js +10 -454
  38. package/dist/commands/node.d.ts +2 -0
  39. package/dist/commands/node.js +354 -0
  40. package/dist/commands/pkg/market-inspect.d.ts +1 -0
  41. package/dist/commands/pkg/market-inspect.js +157 -0
  42. package/dist/commands/pkg/market-manage.d.ts +1 -0
  43. package/dist/commands/pkg/market-manage.js +316 -0
  44. package/dist/commands/pkg/market.d.ts +1 -0
  45. package/dist/commands/pkg/market.js +16 -0
  46. package/dist/commands/pkg/plugin-inspect.d.ts +1 -0
  47. package/dist/commands/pkg/plugin-inspect.js +142 -0
  48. package/dist/commands/pkg/plugin-manage.d.ts +1 -0
  49. package/dist/commands/pkg/plugin-manage.js +294 -0
  50. package/dist/commands/pkg/plugin.d.ts +1 -0
  51. package/dist/commands/pkg/plugin.js +16 -0
  52. package/dist/commands/pkg/shared.d.ts +5 -0
  53. package/dist/commands/pkg/shared.js +61 -0
  54. package/dist/commands/pkg.js +3 -1004
  55. package/dist/commands/push.d.ts +3 -0
  56. package/dist/commands/push.js +159 -0
  57. package/dist/commands/revive.d.ts +2 -0
  58. package/dist/commands/revive.js +64 -0
  59. package/dist/commands/skill/author.d.ts +3 -0
  60. package/dist/commands/skill/author.js +147 -0
  61. package/dist/commands/skill/find.d.ts +4 -0
  62. package/dist/commands/skill/find.js +254 -0
  63. package/dist/commands/skill/read.d.ts +1 -0
  64. package/dist/commands/skill/read.js +89 -0
  65. package/dist/commands/skill/shared.d.ts +19 -0
  66. package/dist/commands/skill/shared.js +207 -0
  67. package/dist/commands/skill/state.d.ts +3 -0
  68. package/dist/commands/skill/state.js +69 -0
  69. package/dist/commands/skill.js +6 -691
  70. package/dist/commands/sys/config.d.ts +1 -0
  71. package/dist/commands/sys/config.js +186 -0
  72. package/dist/commands/sys/doctor.d.ts +1 -0
  73. package/dist/commands/sys/doctor.js +369 -0
  74. package/dist/commands/sys/shared.d.ts +3 -0
  75. package/dist/commands/sys/shared.js +24 -0
  76. package/dist/commands/sys/update.d.ts +2 -0
  77. package/dist/commands/sys/update.js +114 -0
  78. package/dist/commands/sys.js +4 -694
  79. package/dist/core/__tests__/argv-parser.test.js +19 -1
  80. package/dist/core/__tests__/canvas-inbox-watcher.test.js +100 -0
  81. package/dist/core/__tests__/canvas.test.js +154 -0
  82. package/dist/core/__tests__/reset.test.js +105 -0
  83. package/dist/core/canvas/attention.d.ts +24 -0
  84. package/dist/core/canvas/attention.js +94 -0
  85. package/dist/core/canvas/canvas.d.ts +40 -0
  86. package/dist/core/canvas/canvas.js +210 -0
  87. package/dist/core/canvas/db.d.ts +7 -0
  88. package/dist/core/canvas/db.js +61 -0
  89. package/dist/core/canvas/index.d.ts +4 -0
  90. package/dist/core/canvas/index.js +6 -0
  91. package/dist/core/canvas/paths.d.ts +16 -0
  92. package/dist/core/canvas/paths.js +62 -0
  93. package/dist/core/canvas/render.d.ts +30 -0
  94. package/dist/core/canvas/render.js +186 -0
  95. package/dist/core/canvas/types.d.ts +87 -0
  96. package/dist/core/canvas/types.js +8 -0
  97. package/dist/core/command.d.ts +5 -0
  98. package/dist/core/command.js +35 -10
  99. package/dist/core/feed/feed.d.ts +43 -0
  100. package/dist/core/feed/feed.js +116 -0
  101. package/dist/core/feed/inbox.d.ts +50 -0
  102. package/dist/core/feed/inbox.js +124 -0
  103. package/dist/core/help.js +5 -3
  104. package/dist/core/io.d.ts +15 -1
  105. package/dist/core/io.js +56 -6
  106. package/dist/core/personas/index.d.ts +12 -0
  107. package/dist/core/personas/index.js +10 -0
  108. package/dist/core/personas/loader.d.ts +44 -0
  109. package/dist/core/personas/loader.js +157 -0
  110. package/dist/core/personas/resolve.d.ts +36 -0
  111. package/dist/core/personas/resolve.js +110 -0
  112. package/dist/core/render.d.ts +11 -0
  113. package/dist/core/render.js +126 -0
  114. package/dist/core/resolver.d.ts +10 -0
  115. package/dist/core/resolver.js +109 -1
  116. package/dist/core/runtime/front-door.d.ts +10 -0
  117. package/dist/core/runtime/front-door.js +97 -0
  118. package/dist/core/runtime/kickoff.d.ts +23 -0
  119. package/dist/core/runtime/kickoff.js +134 -0
  120. package/dist/core/runtime/launch.d.ts +34 -0
  121. package/dist/core/runtime/launch.js +85 -0
  122. package/dist/core/runtime/nodes.d.ts +38 -0
  123. package/dist/core/runtime/nodes.js +95 -0
  124. package/dist/core/runtime/presence.d.ts +38 -0
  125. package/dist/core/runtime/presence.js +152 -0
  126. package/dist/core/runtime/promote.d.ts +30 -0
  127. package/dist/core/runtime/promote.js +105 -0
  128. package/dist/core/runtime/reset.d.ts +13 -0
  129. package/dist/core/runtime/reset.js +97 -0
  130. package/dist/core/runtime/revive.d.ts +26 -0
  131. package/dist/core/runtime/revive.js +89 -0
  132. package/dist/core/runtime/roadmap.d.ts +12 -0
  133. package/dist/core/runtime/roadmap.js +52 -0
  134. package/dist/core/runtime/spawn.d.ts +33 -0
  135. package/dist/core/runtime/spawn.js +118 -0
  136. package/dist/core/runtime/stop-guard.d.ts +18 -0
  137. package/dist/core/runtime/stop-guard.js +33 -0
  138. package/dist/core/runtime/tmux.d.ts +88 -0
  139. package/dist/core/runtime/tmux.js +198 -0
  140. package/dist/core/spawn.d.ts +17 -197
  141. package/dist/core/spawn.js +16 -539
  142. package/dist/daemon/crtrd-cli.js +4 -0
  143. package/dist/daemon/crtrd.d.ts +20 -0
  144. package/dist/daemon/crtrd.js +200 -0
  145. package/dist/daemon/manage.d.ts +17 -0
  146. package/dist/daemon/manage.js +57 -0
  147. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +16 -0
  148. package/dist/pi-extensions/canvas-inbox-watcher.js +229 -0
  149. package/dist/pi-extensions/canvas-nav.d.ts +32 -0
  150. package/dist/pi-extensions/canvas-nav.js +536 -0
  151. package/dist/pi-extensions/canvas-stophook.d.ts +17 -0
  152. package/dist/pi-extensions/canvas-stophook.js +373 -0
  153. package/package.json +6 -5
  154. package/dist/commands/agent.d.ts +0 -6
  155. package/dist/commands/agent.js +0 -585
  156. package/dist/commands/debug.d.ts +0 -3
  157. package/dist/commands/debug.js +0 -192
  158. package/dist/commands/job.d.ts +0 -11
  159. package/dist/commands/job.js +0 -384
  160. package/dist/commands/mode.js +0 -231
  161. package/dist/commands/plan.d.ts +0 -4
  162. package/dist/commands/plan.js +0 -322
  163. package/dist/commands/spec.d.ts +0 -3
  164. package/dist/commands/spec.js +0 -299
  165. package/dist/core/__tests__/flow-leaves.test.js +0 -248
  166. package/dist/core/__tests__/job.test.js +0 -310
  167. package/dist/core/__tests__/jobs.test.js +0 -98
  168. package/dist/core/__tests__/spawn.test.js +0 -138
  169. package/dist/core/__tests__/subagents.test.d.ts +0 -1
  170. package/dist/core/__tests__/subagents.test.js +0 -75
  171. package/dist/core/jobs.d.ts +0 -107
  172. package/dist/core/jobs.js +0 -565
  173. package/dist/core/subagents.d.ts +0 -18
  174. package/dist/core/subagents.js +0 -163
  175. package/dist/prompts/agent.d.ts +0 -27
  176. package/dist/prompts/agent.js +0 -184
  177. package/dist/prompts/debug.d.ts +0 -8
  178. package/dist/prompts/debug.js +0 -44
  179. /package/dist/core/__tests__/{flow-leaves.test.d.ts → canvas-inbox-watcher.test.d.ts} +0 -0
  180. /package/dist/core/__tests__/{job.test.d.ts → canvas.test.d.ts} +0 -0
  181. /package/dist/core/__tests__/{jobs.test.d.ts → reset.test.d.ts} +0 -0
  182. /package/dist/{core/__tests__/spawn.test.d.ts → daemon/crtrd-cli.d.ts} +0 -0
@@ -1,107 +0,0 @@
1
- type TerminalStatus = 'done' | 'failed' | 'canceled' | 'closed';
2
- type JobState = 'live' | TerminalStatus;
3
- type LogLevel = 'debug' | 'info' | 'warn' | 'error';
4
- /**
5
- * Allocate a new job directory and write meta.json atomically.
6
- * Returns the job_id and the absolute directory path.
7
- */
8
- export declare function createJob(kind: string, opts: {
9
- cwd: string;
10
- pid?: number;
11
- }): {
12
- jobId: string;
13
- dir: string;
14
- };
15
- /**
16
- * Record the tmux pane hosting a detached worker so `cancelJob` can kill it.
17
- */
18
- export declare function recordJobPane(jobId: string, paneId: string): void;
19
- /**
20
- * Record the pid of a detached worker (e.g. a headless background agent) so
21
- * jobStatus can mark the job failed if the process dies without a result.
22
- */
23
- export declare function recordJobPid(jobId: string, pid: number): void;
24
- /**
25
- * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
26
- * a crashed writer should not further corrupt state; use a guard at the call site.
27
- */
28
- export declare function appendEvent(jobId: string, event: {
29
- level: LogLevel;
30
- event: string;
31
- message: string;
32
- data?: object;
33
- }): void;
34
- /**
35
- * Atomically write result.json (structured object) and update meta.json status.
36
- * Used by programmatic callers (human, sys) that produce object results.
37
- * The result file's appearance is the completion signal — never inferred from log content.
38
- */
39
- export declare function writeResult(jobId: string, result: object, terminalStatus: TerminalStatus): void;
40
- /**
41
- * Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
42
- * Used by `crtr job submit` for agent-driven markdown results.
43
- */
44
- export declare function writeMarkdownResult(jobId: string, body: string, terminalStatus: TerminalStatus, reason?: string): void;
45
- /**
46
- * Read whichever result file exists (result.md or result.json). If neither
47
- * exists and waitMs is given, block via fs.watch until one appears or the
48
- * timeout elapses.
49
- *
50
- * Race safety: registers the watcher THEN re-stats. If a result file appeared
51
- * between the first stat and the watch registration, the re-stat catches it
52
- * before the watcher has a chance to miss it.
53
- *
54
- * Returns shape:
55
- * - JSON path: { status, result: object }
56
- * - Markdown path: { status, result_md: string, reason?: string }
57
- * - Timeout: { status: 'timeout' }
58
- * - Closed: pane vanished with no result → status 'closed'
59
- */
60
- export interface ReadResultResponse {
61
- status: 'done' | 'failed' | 'canceled' | 'closed' | 'timeout';
62
- result?: object;
63
- result_md?: string;
64
- reason?: string;
65
- }
66
- export declare function readResult(jobId: string, opts?: {
67
- waitMs?: number;
68
- }): Promise<ReadResultResponse>;
69
- /**
70
- * Derive job state from meta.json, the result file, and the tail of log.jsonl.
71
- * If a pid is recorded, is not alive, and no result file exists → 'failed'.
72
- */
73
- export declare function jobStatus(jobId: string): {
74
- state: JobState;
75
- age_s: number;
76
- last_event: {
77
- event: string;
78
- ts: string;
79
- } | null;
80
- };
81
- /**
82
- * List all jobs sorted by created_at ascending. Pagination is applied by the
83
- * caller, not here.
84
- */
85
- export declare function listJobs(): {
86
- job_id: string;
87
- kind: string;
88
- state: JobState;
89
- created_at: string;
90
- }[];
91
- /**
92
- * Read and filter log events. Ordering preserved. sinceTs/untilTs are ISO8601
93
- * strings; minLevel filters by severity rank (inclusive).
94
- */
95
- export declare function readLog(jobId: string, opts?: {
96
- sinceTs?: string;
97
- untilTs?: string;
98
- minLevel?: LogLevel;
99
- }): object[];
100
- /**
101
- * Best-effort cancel: send SIGTERM to the recorded pid (if any), mark meta
102
- * canceled. Success means the signal was delivered, not that execution stopped.
103
- */
104
- export declare function cancelJob(jobId: string): {
105
- canceled: boolean;
106
- };
107
- export {};
package/dist/core/jobs.js DELETED
@@ -1,565 +0,0 @@
1
- // Job / long-running-operation infrastructure.
2
- //
3
- // Files are the single source of truth. No in-memory registry. An agent picks
4
- // up a job by id across processes. Crashes recover by reading files.
5
- //
6
- // Layout: ${XDG_STATE_HOME or ~/.local/state}/crtr/jobs/<job_id>/
7
- // meta.json — written atomically on create; updated atomically on terminal transition.
8
- // log.jsonl — append-only event log.
9
- // result.md — agent submissions (markdown body + YAML frontmatter). Written atomically.
10
- // result.json — programmatic submissions (structured object). Written atomically.
11
- // Either result file's APPEARANCE is the completion signal. Exactly one is written per job.
12
- //
13
- // A worker is not required to submit. Besides an explicit submit, a job becomes
14
- // terminal when (a) the wrapper shell's `crtr job _fail` runs on a clean exit,
15
- // or (b) the hosting tmux pane is closed — which sends SIGHUP so (a) never runs.
16
- // Case (b) is reaped here: when a live job's recorded pane is gone and no result
17
- // exists, we write a `closed` result (terminal, but distinct from `failed`) so
18
- // the job stops being a zombie without claiming an outcome we can't know.
19
- import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, } from 'node:fs';
20
- import { watch } from 'node:fs';
21
- import { spawnSync } from 'node:child_process';
22
- import { join } from 'node:path';
23
- import { homedir } from 'node:os';
24
- import { randomBytes } from 'node:crypto';
25
- import { notFound, general } from './errors.js';
26
- // ---------------------------------------------------------------------------
27
- // Paths
28
- // ---------------------------------------------------------------------------
29
- function jobsRoot() {
30
- const xdg = process.env['XDG_STATE_HOME'];
31
- const base = (xdg !== undefined && xdg !== '') ? xdg : join(homedir(), '.local', 'state');
32
- return join(base, 'crtr', 'jobs');
33
- }
34
- function jobDir(jobId) {
35
- return join(jobsRoot(), jobId);
36
- }
37
- function metaPath(jobId) {
38
- return join(jobDir(jobId), 'meta.json');
39
- }
40
- function logPath(jobId) {
41
- return join(jobDir(jobId), 'log.jsonl');
42
- }
43
- function resultJsonPath(jobId) {
44
- return join(jobDir(jobId), 'result.json');
45
- }
46
- function resultMdPath(jobId) {
47
- return join(jobDir(jobId), 'result.md');
48
- }
49
- /** Path of whichever result file currently exists, or null if neither does. */
50
- function existingResultPath(jobId) {
51
- const md = resultMdPath(jobId);
52
- if (existsSync(md))
53
- return md;
54
- const js = resultJsonPath(jobId);
55
- if (existsSync(js))
56
- return js;
57
- return null;
58
- }
59
- // ---------------------------------------------------------------------------
60
- // Internal helpers
61
- // ---------------------------------------------------------------------------
62
- function generateJobId() {
63
- const ts = Date.now().toString(36);
64
- const rnd = randomBytes(4).toString('hex');
65
- return `${ts}-${rnd}`;
66
- }
67
- function ensureJobsRoot() {
68
- mkdirSync(jobsRoot(), { recursive: true });
69
- }
70
- function readMeta(jobId) {
71
- const p = metaPath(jobId);
72
- if (!existsSync(p)) {
73
- throw notFound(`job not found: ${jobId}`, { job_id: jobId });
74
- }
75
- try {
76
- return JSON.parse(readFileSync(p, 'utf8'));
77
- }
78
- catch {
79
- throw general(`failed to parse meta.json for job ${jobId}`, { job_id: jobId });
80
- }
81
- }
82
- function writeMeta(jobId, meta) {
83
- const dir = jobDir(jobId);
84
- const tmp = join(dir, '.meta.tmp');
85
- writeFileSync(tmp, JSON.stringify(meta, null, 2), 'utf8');
86
- renameSync(tmp, metaPath(jobId));
87
- }
88
- function pidAlive(pid) {
89
- try {
90
- process.kill(pid, 0);
91
- return true;
92
- }
93
- catch {
94
- return false;
95
- }
96
- }
97
- /**
98
- * Set of every tmux pane id across all sessions on the running server. Empty
99
- * when no server is running (→ every recorded pane is treated as gone).
100
- *
101
- * This bridges tmux's pane lifecycle to the job registry. A worker whose pane
102
- * is closed/killed receives SIGHUP, so the wrapper shell's `crtr job _fail`
103
- * never runs and the job would otherwise stay `live` forever (a zombie). We
104
- * detect the vanished pane and reap the job instead.
105
- */
106
- function allTmuxPaneIds() {
107
- const set = new Set();
108
- const r = spawnSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8' });
109
- if (r.status !== 0 || typeof r.stdout !== 'string')
110
- return set;
111
- for (const line of r.stdout.split('\n')) {
112
- const t = line.trim();
113
- if (t !== '')
114
- set.add(t);
115
- }
116
- return set;
117
- }
118
- /**
119
- * Reap a job whose hosting tmux pane has disappeared. Acts only when the job is
120
- * still `live`, has a recorded pane, and has produced no result file. Writes a
121
- * terminal `closed` result so the job stops being a zombie and every reader
122
- * (status, list, result --wait) agrees. `closed` is distinct from `failed`: we
123
- * don't know the outcome, only that the pane is gone. Returns true if it reaped.
124
- *
125
- * `panes` lets a caller reuse a single tmux query across many jobs (listJobs).
126
- */
127
- function reapIfPaneDead(meta, panes) {
128
- if (meta.status !== 'live')
129
- return false;
130
- if (meta.pane_id === undefined || meta.pane_id === '')
131
- return false;
132
- if (existingResultPath(meta.job_id) !== null)
133
- return false;
134
- const set = panes ?? allTmuxPaneIds();
135
- if (set.has(meta.pane_id))
136
- return false;
137
- try {
138
- writeMarkdownResult(meta.job_id, '', 'closed', 'worker pane closed before submitting a result');
139
- }
140
- catch {
141
- return false;
142
- }
143
- return true;
144
- }
145
- /** Poll cadence (ms) for detecting a closed worker pane during result --wait. */
146
- const PANE_POLL_MS = 2000;
147
- const LEVEL_RANK = {
148
- debug: 0,
149
- info: 1,
150
- warn: 2,
151
- error: 3,
152
- };
153
- // ---------------------------------------------------------------------------
154
- // Exported API
155
- // ---------------------------------------------------------------------------
156
- /**
157
- * Allocate a new job directory and write meta.json atomically.
158
- * Returns the job_id and the absolute directory path.
159
- */
160
- export function createJob(kind, opts) {
161
- ensureJobsRoot();
162
- const jobId = generateJobId();
163
- const dir = jobDir(jobId);
164
- mkdirSync(dir, { recursive: true });
165
- const meta = {
166
- job_id: jobId,
167
- kind,
168
- created_at: new Date().toISOString(),
169
- cwd: opts.cwd,
170
- status: 'live',
171
- };
172
- if (opts.pid !== undefined) {
173
- meta.pid = opts.pid;
174
- }
175
- writeMeta(jobId, meta);
176
- return { jobId, dir };
177
- }
178
- /**
179
- * Record the tmux pane hosting a detached worker so `cancelJob` can kill it.
180
- */
181
- export function recordJobPane(jobId, paneId) {
182
- const meta = readMeta(jobId);
183
- meta.pane_id = paneId;
184
- writeMeta(jobId, meta);
185
- }
186
- /**
187
- * Record the pid of a detached worker (e.g. a headless background agent) so
188
- * jobStatus can mark the job failed if the process dies without a result.
189
- */
190
- export function recordJobPid(jobId, pid) {
191
- const meta = readMeta(jobId);
192
- meta.pid = pid;
193
- writeMeta(jobId, meta);
194
- }
195
- /**
196
- * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
197
- * a crashed writer should not further corrupt state; use a guard at the call site.
198
- */
199
- export function appendEvent(jobId, event) {
200
- const p = logPath(jobId);
201
- const line = {
202
- ts: new Date().toISOString(),
203
- level: event.level,
204
- event: event.event,
205
- message: event.message,
206
- };
207
- if (event.data !== undefined) {
208
- line.data = event.data;
209
- }
210
- appendFileSync(p, JSON.stringify(line) + '\n', 'utf8');
211
- }
212
- /**
213
- * Atomically write result.json (structured object) and update meta.json status.
214
- * Used by programmatic callers (human, sys) that produce object results.
215
- * The result file's appearance is the completion signal — never inferred from log content.
216
- */
217
- export function writeResult(jobId, result, terminalStatus) {
218
- const dir = jobDir(jobId);
219
- if (!existsSync(dir)) {
220
- throw notFound(`job not found: ${jobId}`, { job_id: jobId });
221
- }
222
- const payload = {
223
- status: terminalStatus,
224
- result,
225
- written_at: new Date().toISOString(),
226
- };
227
- const tmp = join(dir, '.result.tmp');
228
- writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
229
- renameSync(tmp, resultJsonPath(jobId));
230
- const meta = readMeta(jobId);
231
- meta.status = terminalStatus;
232
- writeMeta(jobId, meta);
233
- }
234
- /**
235
- * Atomically write result.md (YAML frontmatter + markdown body) and update meta.json status.
236
- * Used by `crtr job submit` for agent-driven markdown results.
237
- */
238
- export function writeMarkdownResult(jobId, body, terminalStatus, reason) {
239
- const dir = jobDir(jobId);
240
- if (!existsSync(dir)) {
241
- throw notFound(`job not found: ${jobId}`, { job_id: jobId });
242
- }
243
- const fm = {
244
- status: terminalStatus,
245
- written_at: new Date().toISOString(),
246
- };
247
- if (reason !== undefined && reason !== '') {
248
- fm.reason = reason;
249
- }
250
- const content = `${renderFrontmatter(fm)}${body}`;
251
- const tmp = join(dir, '.result.tmp');
252
- writeFileSync(tmp, content, 'utf8');
253
- renameSync(tmp, resultMdPath(jobId));
254
- const meta = readMeta(jobId);
255
- meta.status = terminalStatus;
256
- writeMeta(jobId, meta);
257
- }
258
- /**
259
- * Render a small fixed-shape frontmatter block. We control writer and reader,
260
- * so a 3-key hand-rolled emitter is plenty — no YAML dep, no escaping surprises.
261
- * Values are plain strings; we double-quote `reason` to survive newlines/colons.
262
- */
263
- function renderFrontmatter(fm) {
264
- const lines = ['---', `status: ${fm.status}`, `written_at: ${fm.written_at}`];
265
- if (fm.reason !== undefined) {
266
- const escaped = fm.reason.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n');
267
- lines.push(`reason: "${escaped}"`);
268
- }
269
- lines.push('---', '');
270
- return lines.join('\n');
271
- }
272
- /**
273
- * Parse the small fixed-shape frontmatter we emit. Tolerant of trailing
274
- * whitespace; returns `{ frontmatter, body }`. Throws if the document does not
275
- * start with `---\n` or no closing `---` is found.
276
- */
277
- function parseMarkdownResult(raw) {
278
- if (!raw.startsWith('---\n') && !raw.startsWith('---\r\n')) {
279
- throw new Error('result.md missing opening --- delimiter');
280
- }
281
- const afterOpen = raw.indexOf('\n') + 1;
282
- const closeIdx = raw.indexOf('\n---', afterOpen);
283
- if (closeIdx === -1) {
284
- throw new Error('result.md missing closing --- delimiter');
285
- }
286
- const fmBlock = raw.slice(afterOpen, closeIdx);
287
- // Body starts after the closing `---` line.
288
- const afterCloseLine = raw.indexOf('\n', closeIdx + 1);
289
- const body = afterCloseLine === -1 ? '' : raw.slice(afterCloseLine + 1);
290
- const fm = {};
291
- for (const line of fmBlock.split('\n')) {
292
- const m = line.match(/^([a-z_]+):\s*(.*)$/);
293
- if (m === null)
294
- continue;
295
- const key = m[1];
296
- if (m[2] === undefined)
297
- continue;
298
- let val = m[2];
299
- if (val.startsWith('"') && val.endsWith('"') && val.length >= 2) {
300
- val = val.slice(1, -1).replace(/\\n/g, '\n').replace(/\\"/g, '"').replace(/\\\\/g, '\\');
301
- }
302
- if (key === 'status') {
303
- fm.status = val;
304
- }
305
- else if (key === 'written_at') {
306
- fm.written_at = val;
307
- }
308
- else if (key === 'reason') {
309
- fm.reason = val;
310
- }
311
- }
312
- if (fm.status === undefined || fm.written_at === undefined) {
313
- throw new Error('result.md frontmatter missing status or written_at');
314
- }
315
- return { frontmatter: fm, body };
316
- }
317
- export function readResult(jobId, opts = {}) {
318
- const dir = jobDir(jobId);
319
- if (!existsSync(dir)) {
320
- throw notFound(`job not found: ${jobId}`, { job_id: jobId });
321
- }
322
- function parseAt(path) {
323
- const raw = readFileSync(path, 'utf8');
324
- if (path.endsWith('.md')) {
325
- const { frontmatter, body } = parseMarkdownResult(raw);
326
- const out = { status: frontmatter.status, result_md: body };
327
- if (frontmatter.reason !== undefined) {
328
- out.reason = frontmatter.reason;
329
- }
330
- return out;
331
- }
332
- const parsed = JSON.parse(raw);
333
- return { status: parsed.status, result: parsed.result };
334
- }
335
- const existing = existingResultPath(jobId);
336
- if (existing !== null) {
337
- return Promise.resolve(parseAt(existing));
338
- }
339
- if (opts.waitMs === undefined || opts.waitMs <= 0) {
340
- return Promise.resolve({ status: 'timeout' });
341
- }
342
- return new Promise((resolve) => {
343
- let settled = false;
344
- let timer;
345
- let poll;
346
- const finish = (response) => {
347
- if (settled)
348
- return;
349
- settled = true;
350
- if (timer !== undefined)
351
- clearTimeout(timer);
352
- if (poll !== undefined)
353
- clearInterval(poll);
354
- try {
355
- watcher.close();
356
- }
357
- catch { /* noop */ }
358
- resolve(response);
359
- };
360
- const watcher = watch(dir, (_event, name) => {
361
- if (name !== 'result.md' && name !== 'result.json')
362
- return;
363
- const path = existingResultPath(jobId);
364
- if (path !== null) {
365
- finish(parseAt(path));
366
- }
367
- });
368
- const path = existingResultPath(jobId);
369
- if (path !== null) {
370
- finish(parseAt(path));
371
- return;
372
- }
373
- // fs.watch only fires on result files. A pane that closes without a submit
374
- // produces no such event, so poll to reap it instead of hanging until the
375
- // full timeout budget elapses.
376
- poll = setInterval(() => {
377
- const found = existingResultPath(jobId);
378
- if (found !== null) {
379
- finish(parseAt(found));
380
- return;
381
- }
382
- try {
383
- if (reapIfPaneDead(readMeta(jobId))) {
384
- const reaped = existingResultPath(jobId);
385
- if (reaped !== null)
386
- finish(parseAt(reaped));
387
- }
388
- }
389
- catch { /* noop */ }
390
- }, PANE_POLL_MS);
391
- // A non-finite budget (Infinity) means block until a result appears or the
392
- // worker pane dies — used by `human review`, where the human may take an
393
- // unbounded amount of time. The poll above still reaps a dead pane, so this
394
- // never hangs forever on a closed pane.
395
- if (Number.isFinite(opts.waitMs)) {
396
- timer = setTimeout(() => {
397
- finish({ status: 'timeout' });
398
- }, opts.waitMs);
399
- }
400
- });
401
- }
402
- /**
403
- * Derive job state from meta.json, the result file, and the tail of log.jsonl.
404
- * If a pid is recorded, is not alive, and no result file exists → 'failed'.
405
- */
406
- export function jobStatus(jobId) {
407
- let meta = readMeta(jobId);
408
- if (reapIfPaneDead(meta)) {
409
- meta = readMeta(jobId);
410
- }
411
- const age_s = (Date.now() - new Date(meta.created_at).getTime()) / 1000;
412
- let state = meta.status;
413
- if (state === 'live') {
414
- const existing = existingResultPath(jobId);
415
- if (existing !== null) {
416
- // Result file present but meta not yet updated (rare); trust the file.
417
- try {
418
- if (existing.endsWith('.md')) {
419
- const { frontmatter } = parseMarkdownResult(readFileSync(existing, 'utf8'));
420
- state = frontmatter.status;
421
- }
422
- else {
423
- const r = JSON.parse(readFileSync(existing, 'utf8'));
424
- state = r.status;
425
- }
426
- }
427
- catch { /* leave as live */ }
428
- }
429
- else if (meta.pid !== undefined && !pidAlive(meta.pid)) {
430
- state = 'failed';
431
- }
432
- }
433
- // Tail of log for last_event.
434
- let last_event = null;
435
- const lp = logPath(jobId);
436
- if (existsSync(lp)) {
437
- const lines = readFileSync(lp, 'utf8').trimEnd().split('\n');
438
- for (let i = lines.length - 1; i >= 0; i--) {
439
- const line = lines[i];
440
- if (line === undefined || line.trim() === '')
441
- continue;
442
- try {
443
- const ev = JSON.parse(line);
444
- last_event = { event: ev.event, ts: ev.ts };
445
- break;
446
- }
447
- catch {
448
- continue;
449
- }
450
- }
451
- }
452
- return { state, age_s, last_event };
453
- }
454
- /**
455
- * List all jobs sorted by created_at ascending. Pagination is applied by the
456
- * caller, not here.
457
- */
458
- export function listJobs() {
459
- const root = jobsRoot();
460
- if (!existsSync(root))
461
- return [];
462
- const entries = readdirSync(root);
463
- const jobs = [];
464
- // One tmux query, reused to reap every job whose pane has vanished.
465
- const panes = allTmuxPaneIds();
466
- for (const entry of entries) {
467
- const dir = join(root, entry);
468
- try {
469
- if (!statSync(dir).isDirectory())
470
- continue;
471
- const mp = join(dir, 'meta.json');
472
- if (!existsSync(mp))
473
- continue;
474
- let meta = JSON.parse(readFileSync(mp, 'utf8'));
475
- if (reapIfPaneDead(meta, panes)) {
476
- meta = JSON.parse(readFileSync(mp, 'utf8'));
477
- }
478
- // Derive effective state (result file beats meta.status for live jobs).
479
- let state = meta.status;
480
- if (state === 'live') {
481
- const mdP = join(dir, 'result.md');
482
- const jsP = join(dir, 'result.json');
483
- try {
484
- if (existsSync(mdP)) {
485
- const { frontmatter } = parseMarkdownResult(readFileSync(mdP, 'utf8'));
486
- state = frontmatter.status;
487
- }
488
- else if (existsSync(jsP)) {
489
- const r = JSON.parse(readFileSync(jsP, 'utf8'));
490
- state = r.status;
491
- }
492
- }
493
- catch { /* leave as live */ }
494
- }
495
- jobs.push({ job_id: meta.job_id, kind: meta.kind, state, created_at: meta.created_at });
496
- }
497
- catch {
498
- continue;
499
- }
500
- }
501
- jobs.sort((a, b) => a.created_at.localeCompare(b.created_at));
502
- return jobs;
503
- }
504
- /**
505
- * Read and filter log events. Ordering preserved. sinceTs/untilTs are ISO8601
506
- * strings; minLevel filters by severity rank (inclusive).
507
- */
508
- export function readLog(jobId, opts = {}) {
509
- const dir = jobDir(jobId);
510
- if (!existsSync(dir)) {
511
- throw notFound(`job not found: ${jobId}`, { job_id: jobId });
512
- }
513
- const lp = logPath(jobId);
514
- if (!existsSync(lp))
515
- return [];
516
- const raw = readFileSync(lp, 'utf8');
517
- const results = [];
518
- const minRank = opts.minLevel !== undefined ? LEVEL_RANK[opts.minLevel] : 0;
519
- for (const line of raw.split('\n')) {
520
- if (line.trim() === '')
521
- continue;
522
- let ev;
523
- try {
524
- ev = JSON.parse(line);
525
- }
526
- catch {
527
- continue;
528
- }
529
- if (opts.sinceTs !== undefined && ev.ts < opts.sinceTs)
530
- continue;
531
- if (opts.untilTs !== undefined && ev.ts >= opts.untilTs)
532
- continue;
533
- if (LEVEL_RANK[ev.level] < minRank)
534
- continue;
535
- results.push(ev);
536
- }
537
- return results;
538
- }
539
- /**
540
- * Best-effort cancel: send SIGTERM to the recorded pid (if any), mark meta
541
- * canceled. Success means the signal was delivered, not that execution stopped.
542
- */
543
- export function cancelJob(jobId) {
544
- const meta = readMeta(jobId);
545
- if (meta.status !== 'live') {
546
- // Already terminal — nothing to cancel.
547
- return { canceled: false };
548
- }
549
- let signaled = false;
550
- if (meta.pid !== undefined) {
551
- try {
552
- process.kill(meta.pid, 'SIGTERM');
553
- signaled = true;
554
- }
555
- catch { /* pid gone or unpermitted */ }
556
- }
557
- if (meta.pane_id !== undefined && meta.pane_id !== '') {
558
- const k = spawnSync('tmux', ['kill-pane', '-t', meta.pane_id], { encoding: 'utf8' });
559
- if (k.status === 0)
560
- signaled = true;
561
- }
562
- meta.status = 'canceled';
563
- writeMeta(jobId, meta);
564
- return { canceled: signaled };
565
- }
@@ -1,18 +0,0 @@
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;