@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
@@ -0,0 +1,83 @@
1
+ // The agent-facing I/O contract. Flags and positional args on input; one JSON
2
+ // object on stdout (JSONL for streams); structured errors; stderr is
3
+ // diagnostics only and never carries the result. The stdout value is the next
4
+ // caller's stdin. See cli-design SKILL.md / reference.md.
5
+ import { CrtrError } from './errors.js';
6
+ import { ExitCode } from '../types.js';
7
+ /** A command-level failure: surfaces as the JSON response on stdout. */
8
+ export class InputError extends CrtrError {
9
+ payload;
10
+ constructor(payload, exitCode = ExitCode.USAGE) {
11
+ super(payload.error, payload.message, exitCode, { ...payload });
12
+ this.name = 'InputError';
13
+ this.payload = payload;
14
+ }
15
+ }
16
+ // ---------------------------------------------------------------------------
17
+ // stdin
18
+ // ---------------------------------------------------------------------------
19
+ /** Read raw stdin to EOF. Returns empty string when stdin is a TTY (no pipe).
20
+ * Called by the argv parser for leaves declaring a `stdin` parameter. */
21
+ export async function readStdinRaw() {
22
+ if (process.stdin.isTTY)
23
+ return '';
24
+ const chunks = [];
25
+ for await (const chunk of process.stdin)
26
+ chunks.push(chunk);
27
+ return Buffer.concat(chunks).toString('utf8');
28
+ }
29
+ // ---------------------------------------------------------------------------
30
+ // stdout — the result, nothing else
31
+ // ---------------------------------------------------------------------------
32
+ /** Single-shot response: one JSON object. The whole response is one value. */
33
+ export function emit(obj) {
34
+ process.stdout.write(JSON.stringify(obj, null, 2) + '\n');
35
+ }
36
+ /** One JSONL record. Call per event in a stream; partial reads stay parseable. */
37
+ export function emitLine(obj) {
38
+ process.stdout.write(JSON.stringify(obj) + '\n');
39
+ }
40
+ // ---------------------------------------------------------------------------
41
+ // stderr — diagnostics the agent MAY capture, never the result
42
+ // ---------------------------------------------------------------------------
43
+ export function diag(message) {
44
+ process.stderr.write(message + '\n');
45
+ }
46
+ // ---------------------------------------------------------------------------
47
+ // errors
48
+ // ---------------------------------------------------------------------------
49
+ function payloadOf(e) {
50
+ if (e instanceof InputError)
51
+ return e.payload;
52
+ const d = (e.details !== undefined ? e.details : {});
53
+ const next = d.next !== undefined
54
+ ? d.next
55
+ : 'Inspect the error and adjust the call. See -h for the schema.';
56
+ return {
57
+ error: e.code,
58
+ message: e.message,
59
+ received: d.received,
60
+ field: d.field,
61
+ next,
62
+ };
63
+ }
64
+ /** Terminal error handler. Command-level failures (bad input, not-found,
65
+ * ambiguous) surface as the JSON response on stdout so the caller parses one
66
+ * contract. Runtime/internal failures go to stderr as `{error:"internal"}` —
67
+ * raw traces never reach the agent. Exits non-zero either way. */
68
+ export function handle(e) {
69
+ if (e instanceof CrtrError) {
70
+ process.stdout.write(JSON.stringify(payloadOf(e), null, 2) + '\n');
71
+ process.exit(e.exitCode);
72
+ }
73
+ const err = e;
74
+ const message = err !== null && err !== undefined && typeof err.message === 'string'
75
+ ? err.message
76
+ : String(e);
77
+ process.stderr.write(JSON.stringify({
78
+ error: 'internal',
79
+ message,
80
+ next: 'This is a crtr bug, not a bad call. Retry; if it persists, report it.',
81
+ }, null, 2) + '\n');
82
+ process.exit(ExitCode.GENERAL);
83
+ }
@@ -0,0 +1,87 @@
1
+ type TerminalStatus = 'done' | 'failed' | 'canceled';
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
+ * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
21
+ * a crashed writer should not further corrupt state; use a guard at the call site.
22
+ */
23
+ export declare function appendEvent(jobId: string, event: {
24
+ level: LogLevel;
25
+ event: string;
26
+ message: string;
27
+ data?: object;
28
+ }): void;
29
+ /**
30
+ * Atomically write result.json and update meta.json status.
31
+ * result.json's appearance is the ONLY completion signal — never inferred from
32
+ * log content.
33
+ */
34
+ export declare function writeResult(jobId: string, result: object, terminalStatus: TerminalStatus): void;
35
+ /**
36
+ * Read result.json. If it doesn't exist and waitMs is given, block via fs.watch
37
+ * until result.json appears or the timeout elapses.
38
+ *
39
+ * Race safety: registers the watcher THEN re-stats. If result.json appeared
40
+ * between the first stat and the watch registration, the re-stat catches it
41
+ * before the watcher has a chance to miss it.
42
+ */
43
+ export declare function readResult(jobId: string, opts?: {
44
+ waitMs?: number;
45
+ }): Promise<{
46
+ status: 'done' | 'failed' | 'canceled' | 'timeout';
47
+ result?: object;
48
+ }>;
49
+ /**
50
+ * Derive job state from meta.json, result.json, and the tail of log.jsonl.
51
+ * If a pid is recorded, is not alive, and no result.json exists → 'failed'.
52
+ */
53
+ export declare function jobStatus(jobId: string): {
54
+ state: JobState;
55
+ age_s: number;
56
+ last_event: {
57
+ event: string;
58
+ ts: string;
59
+ } | null;
60
+ };
61
+ /**
62
+ * List all jobs sorted by created_at ascending. Pagination is applied by the
63
+ * caller, not here.
64
+ */
65
+ export declare function listJobs(): {
66
+ job_id: string;
67
+ kind: string;
68
+ state: JobState;
69
+ created_at: string;
70
+ }[];
71
+ /**
72
+ * Read and filter log events. Ordering preserved. sinceTs/untilTs are ISO8601
73
+ * strings; minLevel filters by severity rank (inclusive).
74
+ */
75
+ export declare function readLog(jobId: string, opts?: {
76
+ sinceTs?: string;
77
+ untilTs?: string;
78
+ minLevel?: LogLevel;
79
+ }): object[];
80
+ /**
81
+ * Best-effort cancel: send SIGTERM to the recorded pid (if any), mark meta
82
+ * canceled. Success means the signal was delivered, not that execution stopped.
83
+ */
84
+ export declare function cancelJob(jobId: string): {
85
+ canceled: boolean;
86
+ };
87
+ export {};
@@ -0,0 +1,353 @@
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.json — written atomically; its APPEARANCE is the only completion signal.
10
+ import { existsSync, mkdirSync, appendFileSync, readFileSync, writeFileSync, renameSync, readdirSync, statSync, } from 'node:fs';
11
+ import { watch } from 'node:fs';
12
+ import { spawnSync } from 'node:child_process';
13
+ import { join } from 'node:path';
14
+ import { homedir } from 'node:os';
15
+ import { randomBytes } from 'node:crypto';
16
+ import { notFound, general } from './errors.js';
17
+ // ---------------------------------------------------------------------------
18
+ // Paths
19
+ // ---------------------------------------------------------------------------
20
+ function jobsRoot() {
21
+ const xdg = process.env['XDG_STATE_HOME'];
22
+ const base = (xdg !== undefined && xdg !== '') ? xdg : join(homedir(), '.local', 'state');
23
+ return join(base, 'crtr', 'jobs');
24
+ }
25
+ function jobDir(jobId) {
26
+ return join(jobsRoot(), jobId);
27
+ }
28
+ function metaPath(jobId) {
29
+ return join(jobDir(jobId), 'meta.json');
30
+ }
31
+ function logPath(jobId) {
32
+ return join(jobDir(jobId), 'log.jsonl');
33
+ }
34
+ function resultPath(jobId) {
35
+ return join(jobDir(jobId), 'result.json');
36
+ }
37
+ // ---------------------------------------------------------------------------
38
+ // Internal helpers
39
+ // ---------------------------------------------------------------------------
40
+ function generateJobId() {
41
+ const ts = Date.now().toString(36);
42
+ const rnd = randomBytes(4).toString('hex');
43
+ return `${ts}-${rnd}`;
44
+ }
45
+ function ensureJobsRoot() {
46
+ mkdirSync(jobsRoot(), { recursive: true });
47
+ }
48
+ function readMeta(jobId) {
49
+ const p = metaPath(jobId);
50
+ if (!existsSync(p)) {
51
+ throw notFound(`job not found: ${jobId}`, { job_id: jobId });
52
+ }
53
+ try {
54
+ return JSON.parse(readFileSync(p, 'utf8'));
55
+ }
56
+ catch {
57
+ throw general(`failed to parse meta.json for job ${jobId}`, { job_id: jobId });
58
+ }
59
+ }
60
+ function writeMeta(jobId, meta) {
61
+ const dir = jobDir(jobId);
62
+ const tmp = join(dir, '.meta.tmp');
63
+ writeFileSync(tmp, JSON.stringify(meta, null, 2), 'utf8');
64
+ renameSync(tmp, metaPath(jobId));
65
+ }
66
+ function pidAlive(pid) {
67
+ try {
68
+ process.kill(pid, 0);
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ const LEVEL_RANK = {
76
+ debug: 0,
77
+ info: 1,
78
+ warn: 2,
79
+ error: 3,
80
+ };
81
+ // ---------------------------------------------------------------------------
82
+ // Exported API
83
+ // ---------------------------------------------------------------------------
84
+ /**
85
+ * Allocate a new job directory and write meta.json atomically.
86
+ * Returns the job_id and the absolute directory path.
87
+ */
88
+ export function createJob(kind, opts) {
89
+ ensureJobsRoot();
90
+ const jobId = generateJobId();
91
+ const dir = jobDir(jobId);
92
+ mkdirSync(dir, { recursive: true });
93
+ const meta = {
94
+ job_id: jobId,
95
+ kind,
96
+ created_at: new Date().toISOString(),
97
+ cwd: opts.cwd,
98
+ status: 'live',
99
+ };
100
+ if (opts.pid !== undefined) {
101
+ meta.pid = opts.pid;
102
+ }
103
+ writeMeta(jobId, meta);
104
+ return { jobId, dir };
105
+ }
106
+ /**
107
+ * Record the tmux pane hosting a detached worker so `cancelJob` can kill it.
108
+ */
109
+ export function recordJobPane(jobId, paneId) {
110
+ const meta = readMeta(jobId);
111
+ meta.pane_id = paneId;
112
+ writeMeta(jobId, meta);
113
+ }
114
+ /**
115
+ * Append one event line to log.jsonl. Does NOT throw if jobId doesn't exist —
116
+ * a crashed writer should not further corrupt state; use a guard at the call site.
117
+ */
118
+ export function appendEvent(jobId, event) {
119
+ const p = logPath(jobId);
120
+ const line = {
121
+ ts: new Date().toISOString(),
122
+ level: event.level,
123
+ event: event.event,
124
+ message: event.message,
125
+ };
126
+ if (event.data !== undefined) {
127
+ line.data = event.data;
128
+ }
129
+ appendFileSync(p, JSON.stringify(line) + '\n', 'utf8');
130
+ }
131
+ /**
132
+ * Atomically write result.json and update meta.json status.
133
+ * result.json's appearance is the ONLY completion signal — never inferred from
134
+ * log content.
135
+ */
136
+ export function writeResult(jobId, result, terminalStatus) {
137
+ const dir = jobDir(jobId);
138
+ if (!existsSync(dir)) {
139
+ throw notFound(`job not found: ${jobId}`, { job_id: jobId });
140
+ }
141
+ const payload = {
142
+ status: terminalStatus,
143
+ result,
144
+ written_at: new Date().toISOString(),
145
+ };
146
+ // Atomic write: tmp + rename within same directory (same fs, rename is atomic).
147
+ const tmp = join(dir, '.result.tmp');
148
+ writeFileSync(tmp, JSON.stringify(payload, null, 2), 'utf8');
149
+ renameSync(tmp, resultPath(jobId));
150
+ // Update meta status.
151
+ const meta = readMeta(jobId);
152
+ meta.status = terminalStatus;
153
+ writeMeta(jobId, meta);
154
+ }
155
+ /**
156
+ * Read result.json. If it doesn't exist and waitMs is given, block via fs.watch
157
+ * until result.json appears or the timeout elapses.
158
+ *
159
+ * Race safety: registers the watcher THEN re-stats. If result.json appeared
160
+ * between the first stat and the watch registration, the re-stat catches it
161
+ * before the watcher has a chance to miss it.
162
+ */
163
+ export function readResult(jobId, opts = {}) {
164
+ const dir = jobDir(jobId);
165
+ if (!existsSync(dir)) {
166
+ throw notFound(`job not found: ${jobId}`, { job_id: jobId });
167
+ }
168
+ function parseResult() {
169
+ const raw = readFileSync(resultPath(jobId), 'utf8');
170
+ const parsed = JSON.parse(raw);
171
+ return { status: parsed.status, result: parsed.result };
172
+ }
173
+ // Fast path: result already present.
174
+ if (existsSync(resultPath(jobId))) {
175
+ const r = parseResult();
176
+ return Promise.resolve({ status: r.status, result: r.result });
177
+ }
178
+ if (opts.waitMs === undefined || opts.waitMs <= 0) {
179
+ return Promise.resolve({ status: 'timeout' });
180
+ }
181
+ return new Promise((resolve) => {
182
+ let settled = false;
183
+ const finish = (status, result) => {
184
+ if (settled)
185
+ return;
186
+ settled = true;
187
+ clearTimeout(timer);
188
+ try {
189
+ watcher.close();
190
+ }
191
+ catch { /* noop */ }
192
+ resolve({ status, result });
193
+ };
194
+ // Register watcher first, then re-stat (race safety).
195
+ const watcher = watch(dir, (_event, name) => {
196
+ if (name === 'result.json' && existsSync(resultPath(jobId))) {
197
+ const r = parseResult();
198
+ finish(r.status, r.result);
199
+ }
200
+ });
201
+ // Re-stat after watcher is registered to close the race window.
202
+ if (existsSync(resultPath(jobId))) {
203
+ const r = parseResult();
204
+ finish(r.status, r.result);
205
+ return;
206
+ }
207
+ const timer = setTimeout(() => {
208
+ finish('timeout');
209
+ }, opts.waitMs);
210
+ });
211
+ }
212
+ /**
213
+ * Derive job state from meta.json, result.json, and the tail of log.jsonl.
214
+ * If a pid is recorded, is not alive, and no result.json exists → 'failed'.
215
+ */
216
+ export function jobStatus(jobId) {
217
+ const meta = readMeta(jobId);
218
+ const age_s = (Date.now() - new Date(meta.created_at).getTime()) / 1000;
219
+ // Derive effective state.
220
+ let state = meta.status;
221
+ if (state === 'live') {
222
+ if (existsSync(resultPath(jobId))) {
223
+ // result.json present but meta not yet updated (rare); trust the file.
224
+ try {
225
+ const r = JSON.parse(readFileSync(resultPath(jobId), 'utf8'));
226
+ state = r.status;
227
+ }
228
+ catch { /* leave as live */ }
229
+ }
230
+ else if (meta.pid !== undefined && !pidAlive(meta.pid)) {
231
+ state = 'failed';
232
+ }
233
+ }
234
+ // Tail of log for last_event.
235
+ let last_event = null;
236
+ const lp = logPath(jobId);
237
+ if (existsSync(lp)) {
238
+ const lines = readFileSync(lp, 'utf8').trimEnd().split('\n');
239
+ for (let i = lines.length - 1; i >= 0; i--) {
240
+ const line = lines[i];
241
+ if (line === undefined || line.trim() === '')
242
+ continue;
243
+ try {
244
+ const ev = JSON.parse(line);
245
+ last_event = { event: ev.event, ts: ev.ts };
246
+ break;
247
+ }
248
+ catch {
249
+ continue;
250
+ }
251
+ }
252
+ }
253
+ return { state, age_s, last_event };
254
+ }
255
+ /**
256
+ * List all jobs sorted by created_at ascending. Pagination is applied by the
257
+ * caller, not here.
258
+ */
259
+ export function listJobs() {
260
+ const root = jobsRoot();
261
+ if (!existsSync(root))
262
+ return [];
263
+ const entries = readdirSync(root);
264
+ const jobs = [];
265
+ for (const entry of entries) {
266
+ const dir = join(root, entry);
267
+ try {
268
+ if (!statSync(dir).isDirectory())
269
+ continue;
270
+ const mp = join(dir, 'meta.json');
271
+ if (!existsSync(mp))
272
+ continue;
273
+ const meta = JSON.parse(readFileSync(mp, 'utf8'));
274
+ // Derive effective state (result.json beats meta.status for live jobs).
275
+ let state = meta.status;
276
+ if (state === 'live' && existsSync(join(dir, 'result.json'))) {
277
+ try {
278
+ const r = JSON.parse(readFileSync(join(dir, 'result.json'), 'utf8'));
279
+ state = r.status;
280
+ }
281
+ catch { /* leave as live */ }
282
+ }
283
+ jobs.push({ job_id: meta.job_id, kind: meta.kind, state, created_at: meta.created_at });
284
+ }
285
+ catch {
286
+ continue;
287
+ }
288
+ }
289
+ jobs.sort((a, b) => a.created_at.localeCompare(b.created_at));
290
+ return jobs;
291
+ }
292
+ /**
293
+ * Read and filter log events. Ordering preserved. sinceTs/untilTs are ISO8601
294
+ * strings; minLevel filters by severity rank (inclusive).
295
+ */
296
+ export function readLog(jobId, opts = {}) {
297
+ const dir = jobDir(jobId);
298
+ if (!existsSync(dir)) {
299
+ throw notFound(`job not found: ${jobId}`, { job_id: jobId });
300
+ }
301
+ const lp = logPath(jobId);
302
+ if (!existsSync(lp))
303
+ return [];
304
+ const raw = readFileSync(lp, 'utf8');
305
+ const results = [];
306
+ const minRank = opts.minLevel !== undefined ? LEVEL_RANK[opts.minLevel] : 0;
307
+ for (const line of raw.split('\n')) {
308
+ if (line.trim() === '')
309
+ continue;
310
+ let ev;
311
+ try {
312
+ ev = JSON.parse(line);
313
+ }
314
+ catch {
315
+ continue;
316
+ }
317
+ if (opts.sinceTs !== undefined && ev.ts < opts.sinceTs)
318
+ continue;
319
+ if (opts.untilTs !== undefined && ev.ts >= opts.untilTs)
320
+ continue;
321
+ if (LEVEL_RANK[ev.level] < minRank)
322
+ continue;
323
+ results.push(ev);
324
+ }
325
+ return results;
326
+ }
327
+ /**
328
+ * Best-effort cancel: send SIGTERM to the recorded pid (if any), mark meta
329
+ * canceled. Success means the signal was delivered, not that execution stopped.
330
+ */
331
+ export function cancelJob(jobId) {
332
+ const meta = readMeta(jobId);
333
+ if (meta.status !== 'live') {
334
+ // Already terminal — nothing to cancel.
335
+ return { canceled: false };
336
+ }
337
+ let signaled = false;
338
+ if (meta.pid !== undefined) {
339
+ try {
340
+ process.kill(meta.pid, 'SIGTERM');
341
+ signaled = true;
342
+ }
343
+ catch { /* pid gone or unpermitted */ }
344
+ }
345
+ if (meta.pane_id !== undefined && meta.pane_id !== '') {
346
+ const k = spawnSync('tmux', ['kill-pane', '-t', meta.pane_id], { encoding: 'utf8' });
347
+ if (k.status === 0)
348
+ signaled = true;
349
+ }
350
+ meta.status = 'canceled';
351
+ writeMeta(jobId, meta);
352
+ return { canceled: signaled };
353
+ }
@@ -0,0 +1,33 @@
1
+ /** Encodes a stable sort key into an opaque base64url cursor token. */
2
+ export declare function encodeCursor(key: string): string;
3
+ /** Decodes an opaque cursor token back to the sort key it encodes.
4
+ * Throws `CrtrError` with code `'invalid_cursor'` on malformed input. */
5
+ export declare function decodeCursor(token: string): string;
6
+ export interface PaginateResult<T> {
7
+ items: T[];
8
+ next_cursor: string | null;
9
+ total: number | null;
10
+ }
11
+ export interface PaginateOpts<T> {
12
+ /** Default page size when `params.limit` is absent. */
13
+ defaultLimit: number;
14
+ /** Hard cap; `params.limit` is clamped to [1, maxLimit]. */
15
+ maxLimit: number;
16
+ /** Returns the stable sort key for an item. Must match the sort order of the
17
+ * input array — ascending by this key. */
18
+ keyOf: (item: T) => string;
19
+ /** 'count' → compute and return `total`; 'omit' → return `null`. */
20
+ total: 'count' | 'omit';
21
+ }
22
+ /**
23
+ * Returns one page of items from a pre-sorted list, with a stable opaque
24
+ * cursor for resumption.
25
+ *
26
+ * @param allItemsSortedAscByKey - Full dataset, sorted ascending by `keyOf`.
27
+ * @param params - Agent-supplied `{ limit?, cursor? }` from stdin.
28
+ * @param opts - Caller-supplied configuration (limits, key extractor, total).
29
+ */
30
+ export declare function paginate<T>(allItemsSortedAscByKey: T[], params: {
31
+ limit?: number;
32
+ cursor?: string;
33
+ }, opts: PaginateOpts<T>): PaginateResult<T>;
@@ -0,0 +1,89 @@
1
+ // Pagination helper for agent-facing paginated list leaves.
2
+ //
3
+ // Precondition: `allItemsSortedAscByKey` MUST be sorted ascending by the value
4
+ // `keyOf` returns. Sort order is part of each leaf's contract — this module
5
+ // trusts the caller. Violating the precondition produces silently wrong pages.
6
+ //
7
+ // Cursor design: encodes the stable sort key of the last-emitted item as
8
+ // base64url JSON `{k: <sortKey>}`. Resumption seeks the first item whose key
9
+ // is strictly greater than the cursor key — stable under inserts and deletes
10
+ // between pages (a deleted cursor key simply doesn't stall the scan; the next
11
+ // item after it is returned correctly).
12
+ import { CrtrError } from './errors.js';
13
+ import { ExitCode } from '../types.js';
14
+ // ---------------------------------------------------------------------------
15
+ // Cursor encoding / decoding
16
+ // ---------------------------------------------------------------------------
17
+ /** Encodes a stable sort key into an opaque base64url cursor token. */
18
+ export function encodeCursor(key) {
19
+ const json = JSON.stringify({ k: key });
20
+ return Buffer.from(json, 'utf8').toString('base64url');
21
+ }
22
+ /** Decodes an opaque cursor token back to the sort key it encodes.
23
+ * Throws `CrtrError` with code `'invalid_cursor'` on malformed input. */
24
+ export function decodeCursor(token) {
25
+ let json;
26
+ try {
27
+ json = Buffer.from(token, 'base64url').toString('utf8');
28
+ }
29
+ catch {
30
+ throw new CrtrError('invalid_cursor', 'cursor could not be decoded.', ExitCode.USAGE, {
31
+ received: token,
32
+ next: 'Omit cursor to restart from the beginning.',
33
+ });
34
+ }
35
+ let parsed;
36
+ try {
37
+ parsed = JSON.parse(json);
38
+ }
39
+ catch {
40
+ throw new CrtrError('invalid_cursor', 'cursor payload is not valid JSON.', ExitCode.USAGE, {
41
+ received: token,
42
+ next: 'Omit cursor to restart from the beginning.',
43
+ });
44
+ }
45
+ if (typeof parsed !== 'object' ||
46
+ parsed === null ||
47
+ !('k' in parsed) ||
48
+ typeof parsed.k !== 'string') {
49
+ throw new CrtrError('invalid_cursor', 'cursor payload is missing required key field.', ExitCode.USAGE, {
50
+ received: token,
51
+ next: 'Omit cursor to restart from the beginning.',
52
+ });
53
+ }
54
+ return parsed.k;
55
+ }
56
+ /**
57
+ * Returns one page of items from a pre-sorted list, with a stable opaque
58
+ * cursor for resumption.
59
+ *
60
+ * @param allItemsSortedAscByKey - Full dataset, sorted ascending by `keyOf`.
61
+ * @param params - Agent-supplied `{ limit?, cursor? }` from stdin.
62
+ * @param opts - Caller-supplied configuration (limits, key extractor, total).
63
+ */
64
+ export function paginate(allItemsSortedAscByKey, params, opts) {
65
+ // Resolve effective limit: clamp to [1, maxLimit], default when absent.
66
+ const rawLimit = params.limit !== undefined ? params.limit : opts.defaultLimit;
67
+ const effectiveLimit = Math.min(Math.max(1, rawLimit), opts.maxLimit);
68
+ // Resolve start position from cursor.
69
+ let startIndex = 0;
70
+ if (params.cursor !== undefined) {
71
+ const cursorKey = decodeCursor(params.cursor); // throws CrtrError on bad cursor
72
+ // Find the first item whose key is strictly greater than cursorKey.
73
+ // Linear scan; callers with large datasets should pre-filter upstream.
74
+ startIndex = allItemsSortedAscByKey.findIndex((item) => opts.keyOf(item) > cursorKey);
75
+ if (startIndex === -1) {
76
+ // All items are at or before the cursor key — list is exhausted.
77
+ startIndex = allItemsSortedAscByKey.length;
78
+ }
79
+ }
80
+ const page = allItemsSortedAscByKey.slice(startIndex, startIndex + effectiveLimit);
81
+ // next_cursor: null is the ONLY end-of-list signal.
82
+ let next_cursor = null;
83
+ if (page.length === effectiveLimit && startIndex + effectiveLimit < allItemsSortedAscByKey.length) {
84
+ const lastKey = opts.keyOf(page[page.length - 1]);
85
+ next_cursor = encodeCursor(lastKey);
86
+ }
87
+ const total = opts.total === 'count' ? allItemsSortedAscByKey.length : null;
88
+ return { items: page, next_cursor, total };
89
+ }
@@ -0,0 +1,21 @@
1
+ export declare function currentVersion(): string;
2
+ export declare function selfUpdate(): void;
3
+ /** Check whether a newer crtr version is available on npm.
4
+ * Warns to stderr if network unavailable; returns {current, latest} or null if unreachable. */
5
+ export declare function selfCheck(): {
6
+ current: string;
7
+ latest: string;
8
+ } | null;
9
+ /** Pull updates for all installed marketplaces and standalone plugins. */
10
+ export declare function contentUpdate(): void;
11
+ export interface ContentUpdateEntry {
12
+ name: string;
13
+ kind: 'marketplace' | 'plugin';
14
+ current: string | null;
15
+ latest: string | null;
16
+ up_to_date: boolean;
17
+ unreachable: boolean;
18
+ }
19
+ /** Check whether any marketplace/plugin has updates available.
20
+ * Returns per-item status without applying anything. */
21
+ export declare function contentCheck(): ContentUpdateEntry[];