@ashulab/carrier 0.0.0-stage → 0.1.0

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 (193) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -2
  3. package/dist/chat/claude.js +367 -0
  4. package/dist/chat/codex.js +375 -0
  5. package/dist/chat/config.js +61 -0
  6. package/dist/chat/db.js +9 -0
  7. package/dist/chat/fake.js +203 -0
  8. package/dist/chat/folder.js +56 -0
  9. package/dist/chat/git.js +27 -0
  10. package/dist/chat/group.js +28 -0
  11. package/dist/chat/health.js +49 -0
  12. package/dist/chat/host.js +123 -0
  13. package/dist/chat/idle.js +20 -0
  14. package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
  15. package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
  16. package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
  17. package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
  18. package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
  19. package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
  20. package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
  21. package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
  22. package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
  23. package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
  24. package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
  25. package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
  26. package/dist/chat/migrations/meta/_journal.json +48 -0
  27. package/dist/chat/names.js +97 -0
  28. package/dist/chat/plan.js +21 -0
  29. package/dist/chat/projects.js +242 -0
  30. package/dist/chat/runner.js +6 -0
  31. package/dist/chat/schema.js +48 -0
  32. package/dist/chat/server.js +202 -0
  33. package/dist/chat/sessions.js +766 -0
  34. package/dist/chat/sweep.js +64 -0
  35. package/dist/chat/transcript.js +31 -0
  36. package/dist/chat/words.js +20 -0
  37. package/dist/chat/workspace.js +134 -0
  38. package/dist/chat/worktree.js +142 -0
  39. package/dist/config/default-path.js +7 -0
  40. package/dist/config/engine-places.js +9 -0
  41. package/dist/config/json-schema.js +15 -0
  42. package/dist/config/load.js +48 -0
  43. package/dist/config/resolve.js +41 -0
  44. package/dist/config/schema.js +225 -0
  45. package/dist/control/epic.js +219 -0
  46. package/dist/control/events.js +48 -0
  47. package/dist/control/files.js +262 -0
  48. package/dist/control/replies.js +1 -0
  49. package/dist/control/server.js +237 -0
  50. package/dist/control/sessions.js +68 -0
  51. package/dist/doctor/doctor.js +255 -0
  52. package/dist/doctor/fixes.js +35 -0
  53. package/dist/execution/agent-runner.js +1 -0
  54. package/dist/execution/caller-result.js +61 -0
  55. package/dist/execution/events.js +19 -0
  56. package/dist/execution/failure.js +3 -0
  57. package/dist/execution/fake.js +82 -0
  58. package/dist/execution/git-workspaces.js +136 -0
  59. package/dist/execution/import-result.js +23 -0
  60. package/dist/execution/workspaces.js +1 -0
  61. package/dist/format/duration.js +12 -0
  62. package/dist/harnesses/agy.js +131 -0
  63. package/dist/harnesses/claude.js +246 -0
  64. package/dist/harnesses/codex.js +118 -0
  65. package/dist/harnesses/common.js +59 -0
  66. package/dist/harnesses/credentials.js +38 -0
  67. package/dist/harnesses/host-runner.js +225 -0
  68. package/dist/harnesses/local.js +25 -0
  69. package/dist/harnesses/names.js +9 -0
  70. package/dist/harnesses/replay-events.js +57 -0
  71. package/dist/integrations/available-repos.js +29 -0
  72. package/dist/integrations/forge-cwd.js +27 -0
  73. package/dist/integrations/github-graphql.js +216 -0
  74. package/dist/integrations/github.js +231 -0
  75. package/dist/logging/logger.js +53 -0
  76. package/dist/logging/redact.js +21 -0
  77. package/dist/orchestration/blockers.js +25 -0
  78. package/dist/orchestration/changes-slices.js +5 -0
  79. package/dist/orchestration/clock.js +7 -0
  80. package/dist/orchestration/engine.js +1068 -0
  81. package/dist/orchestration/fake.js +152 -0
  82. package/dist/orchestration/final-pr.js +15 -0
  83. package/dist/orchestration/github.js +31 -0
  84. package/dist/orchestration/reserve.js +32 -0
  85. package/dist/orchestration/resume-refused.js +7 -0
  86. package/dist/orchestration/retention.js +145 -0
  87. package/dist/orchestration/retry.js +16 -0
  88. package/dist/prompts/build-prompt.js +13 -0
  89. package/dist/prompts/fix.js +21 -0
  90. package/dist/prompts/intro.js +21 -0
  91. package/dist/prompts/invoke.js +2 -0
  92. package/dist/prompts/persona.js +12 -0
  93. package/dist/prompts/result.js +32 -0
  94. package/dist/prompts/review.js +15 -0
  95. package/dist/prompts/ship.js +8 -0
  96. package/dist/status/snapshot.js +92 -0
  97. package/dist/status/types.js +1 -0
  98. package/dist/storage/caller-results.js +7 -0
  99. package/dist/storage/db.js +8 -0
  100. package/dist/storage/epic-review-threads.js +19 -0
  101. package/dist/storage/epic-slices.js +17 -0
  102. package/dist/storage/events.js +52 -0
  103. package/dist/storage/executions.js +31 -0
  104. package/dist/storage/lock.js +21 -0
  105. package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
  106. package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
  107. package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
  108. package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
  109. package/dist/storage/migrations/0004_long_cerise.sql +18 -0
  110. package/dist/storage/migrations/0005_review_result.sql +2 -0
  111. package/dist/storage/migrations/0006_melted_menace.sql +10 -0
  112. package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
  113. package/dist/storage/migrations/0008_panel_state.sql +4 -0
  114. package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
  115. package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
  116. package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
  117. package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
  118. package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
  119. package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
  120. package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
  121. package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
  122. package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
  123. package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
  124. package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
  125. package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
  126. package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
  127. package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
  128. package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
  129. package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
  130. package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
  131. package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
  132. package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
  133. package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
  134. package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
  135. package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
  136. package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
  137. package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
  138. package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
  139. package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
  140. package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
  141. package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
  142. package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
  143. package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
  144. package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
  145. package/dist/storage/migrations/meta/_journal.json +146 -0
  146. package/dist/storage/schema.js +43 -0
  147. package/dist/terminal/cli/args.js +91 -0
  148. package/dist/terminal/cli/cli-error.js +11 -0
  149. package/dist/terminal/cli/commands/chat-host.js +18 -0
  150. package/dist/terminal/cli/commands/chat.js +321 -0
  151. package/dist/terminal/cli/commands/control.js +46 -0
  152. package/dist/terminal/cli/commands/doctor.js +15 -0
  153. package/dist/terminal/cli/commands/init.js +43 -0
  154. package/dist/terminal/cli/commands/restart.js +92 -0
  155. package/dist/terminal/cli/commands/run.js +20 -0
  156. package/dist/terminal/cli/commands/schema.js +4 -0
  157. package/dist/terminal/cli/commands/start.js +21 -0
  158. package/dist/terminal/cli/commands/status.js +61 -0
  159. package/dist/terminal/cli/commands/watch.js +112 -0
  160. package/dist/terminal/cli/compose.js +163 -0
  161. package/dist/terminal/cli/config-file.js +24 -0
  162. package/dist/terminal/cli/control-client.js +94 -0
  163. package/dist/terminal/cli/detached.js +41 -0
  164. package/dist/terminal/cli/interrupt.js +39 -0
  165. package/dist/terminal/cli/main.js +85 -0
  166. package/dist/terminal/cli/open-url.js +9 -0
  167. package/dist/terminal/dashboard/dashboard.js +204 -0
  168. package/dist/terminal/dashboard/history.js +38 -0
  169. package/dist/terminal/dashboard/interactive.js +60 -0
  170. package/dist/terminal/dashboard/keys.js +22 -0
  171. package/dist/terminal/dashboard/logs.js +85 -0
  172. package/dist/terminal/dashboard/panel.js +62 -0
  173. package/dist/terminal/dashboard/plain.js +32 -0
  174. package/dist/terminal/dashboard/printable.js +2 -0
  175. package/dist/terminal/dashboard/ui-state.js +110 -0
  176. package/dist/terminal/prompts/clack.js +121 -0
  177. package/dist/terminal/prompts/types.js +3 -0
  178. package/dist/terminal/wizard/build-config.js +66 -0
  179. package/dist/terminal/wizard/deps.js +94 -0
  180. package/dist/terminal/wizard/flags.js +1 -0
  181. package/dist/terminal/wizard/forge-registry.js +23 -0
  182. package/dist/terminal/wizard/prior.js +20 -0
  183. package/dist/terminal/wizard/questions/agent.js +61 -0
  184. package/dist/terminal/wizard/questions/identities.js +28 -0
  185. package/dist/terminal/wizard/questions/identity.js +30 -0
  186. package/dist/terminal/wizard/questions/options.js +93 -0
  187. package/dist/terminal/wizard/questions/out.js +16 -0
  188. package/dist/terminal/wizard/questions/persona.js +47 -0
  189. package/dist/terminal/wizard/questions/repos.js +61 -0
  190. package/dist/terminal/wizard/summary.js +31 -0
  191. package/dist/terminal/wizard/testing.js +106 -0
  192. package/dist/terminal/wizard/wizard.js +172 -0
  193. package/package.json +46 -3
@@ -0,0 +1,94 @@
1
+ import { connect } from 'node:net';
2
+ import { socketPathFits } from '../../control/server.js';
3
+ export class ControlUnreachable extends Error {
4
+ reason;
5
+ /** For 'incompatible': the protocol the hello announced, when it announced one. */
6
+ protocol;
7
+ // No parameter properties: Node strips types at runtime and cannot run that syntax.
8
+ constructor(reason, protocol) {
9
+ super(reason);
10
+ this.reason = reason;
11
+ this.protocol = protocol;
12
+ }
13
+ }
14
+ // Errors that mean no Carrier is listening there; any other one (a permission, say) is a socket that did not answer.
15
+ const nobodyListening = new Set(['ENOENT', 'ECONNREFUSED', 'ENOTSOCK', 'ENAMETOOLONG']);
16
+ /**
17
+ * Connects to a running Carrier's control socket and reads its hello. `timeoutMs` bounds the hello and each request.
18
+ * Rejects with a `ControlUnreachable`; the path is never tried when it does not fit the socket limit.
19
+ */
20
+ export function connectControl(path, { timeoutMs = 5000 } = {}) {
21
+ if (socketPathFits(path).fits === false)
22
+ return Promise.reject(new ControlUnreachable('not-running'));
23
+ return new Promise((resolve, reject) => {
24
+ const socket = connect(path);
25
+ socket.setEncoding('utf8');
26
+ const pending = new Map();
27
+ let hello;
28
+ let nextId = 1;
29
+ let buffer = '';
30
+ const helloTimer = setTimeout(() => fail('no-answer'), timeoutMs);
31
+ const fail = (reason, protocol) => {
32
+ clearTimeout(helloTimer);
33
+ socket.destroy();
34
+ reject(new ControlUnreachable(reason, protocol));
35
+ };
36
+ const settle = (id, finish) => {
37
+ const entry = pending.get(id);
38
+ if (entry === undefined)
39
+ return;
40
+ clearTimeout(entry.timer);
41
+ pending.delete(id);
42
+ finish(entry);
43
+ };
44
+ const onLine = (line) => {
45
+ let message;
46
+ try {
47
+ message = JSON.parse(line);
48
+ }
49
+ catch {
50
+ return hello === undefined ? fail('incompatible') : undefined;
51
+ }
52
+ if (hello === undefined) {
53
+ if (message?.event !== 'hello' || message.protocol !== 1)
54
+ return fail('incompatible', message?.event === 'hello' ? message.protocol : undefined);
55
+ clearTimeout(helloTimer);
56
+ hello = message;
57
+ return resolve({ hello, request, close: () => socket.destroy() });
58
+ }
59
+ // the server answers in order, so a reply without an id (a request it could not read) belongs to the oldest one
60
+ const id = message.id ?? pending.keys().next().value;
61
+ if (id === undefined)
62
+ return;
63
+ settle(id, (entry) => (message.ok === true ? entry.resolve(message.data) : entry.reject(new Error(String(message.error)))));
64
+ };
65
+ socket.on('data', (chunk) => {
66
+ buffer += chunk;
67
+ for (let end = buffer.indexOf('\n'); end >= 0; end = buffer.indexOf('\n')) {
68
+ const line = buffer.slice(0, end);
69
+ buffer = buffer.slice(end + 1);
70
+ onLine(line);
71
+ }
72
+ });
73
+ socket.on('error', (error) => {
74
+ if (hello === undefined)
75
+ fail(nobodyListening.has(error.code ?? '') ? 'not-running' : 'no-answer');
76
+ });
77
+ socket.on('close', () => {
78
+ if (hello === undefined)
79
+ return fail('no-answer');
80
+ for (const id of [...pending.keys()])
81
+ settle(id, (entry) => entry.reject(new ControlUnreachable('no-answer')));
82
+ });
83
+ function request(cmd, fields = {}) {
84
+ if (socket.destroyed)
85
+ return Promise.reject(new ControlUnreachable('no-answer'));
86
+ return new Promise((resolveRequest, rejectRequest) => {
87
+ const id = nextId++;
88
+ const timer = setTimeout(() => settle(id, (entry) => entry.reject(new ControlUnreachable('no-answer'))), timeoutMs);
89
+ pending.set(id, { resolve: resolveRequest, reject: rejectRequest, timer });
90
+ socket.write(`${JSON.stringify({ ...fields, id, cmd })}\n`);
91
+ });
92
+ }
93
+ });
94
+ }
@@ -0,0 +1,41 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { closeSync, mkdirSync, openSync, readFileSync } from 'node:fs';
3
+ import { dirname } from 'node:path';
4
+ import { setTimeout as sleep } from 'node:timers/promises';
5
+ import { CliError } from './cli-error.js';
6
+ import { connectControl } from './control-client.js';
7
+ const POLL_MS = 250;
8
+ const GIVE_UP_MS = 15_000;
9
+ const LOG_TAIL_LINES = 20;
10
+ /** The pid of whoever answers on this socket, if one does. */
11
+ export async function pidAt(socketPath) {
12
+ try {
13
+ const client = await connectControl(socketPath, { timeoutMs: POLL_MS });
14
+ client.close();
15
+ return client.hello.pid;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ /**
22
+ * Starts this program as a detached run (ADR 0010): in its own session, its output in `logPath` (replaced at each start).
23
+ * Returns the pid once `socketPath` answers; throws a CliError with the end of the log when it exits or stays silent.
24
+ * `args` are absolute where they name files: the child does not share this process's working directory.
25
+ */
26
+ export async function startDetached({ label, args, socketPath, logPath }) {
27
+ mkdirSync(dirname(logPath), { recursive: true });
28
+ const logFd = openSync(logPath, 'w');
29
+ let exited = false;
30
+ const child = spawn(process.execPath, [...process.execArgv, process.argv[1], ...args], { detached: true, stdio: ['ignore', logFd, logFd] });
31
+ closeSync(logFd);
32
+ child.on('exit', () => (exited = true));
33
+ child.unref();
34
+ for (const deadline = Date.now() + GIVE_UP_MS; exited === false && Date.now() < deadline; await sleep(POLL_MS)) {
35
+ const pid = await pidAt(socketPath);
36
+ if (pid !== undefined)
37
+ return pid;
38
+ }
39
+ const tail = readFileSync(logPath, 'utf8').trimEnd().split('\n').slice(-LOG_TAIL_LINES);
40
+ throw new CliError(1, exited ? `${label} exited before it was ready:` : `${label} did not answer within ${GIVE_UP_MS / 1000} seconds:`, ...tail, `Log: ${logPath}`);
41
+ }
@@ -0,0 +1,39 @@
1
+ import { createScopedLogger } from '@ashulab/universal-logger';
2
+ const log = createScopedLogger('cli');
3
+ async function stopEngine({ engine, close }, timeoutMs) {
4
+ let code = 0;
5
+ let timer;
6
+ const limit = new Promise((_, reject) => {
7
+ timer = setTimeout(() => reject(new Error(`engine did not stop within ${timeoutMs} ms`)), timeoutMs);
8
+ });
9
+ try {
10
+ await Promise.race([engine.shutdown(), limit]);
11
+ }
12
+ catch (error) {
13
+ log.error('shutdown failed', error);
14
+ code = 1;
15
+ }
16
+ finally {
17
+ clearTimeout(timer);
18
+ close();
19
+ }
20
+ return code;
21
+ }
22
+ /**
23
+ * The one shutdown: stop the engine without waiting for stages, then free the instance lock and exit with 0 (1 when the
24
+ * engine failed to stop or did not within `timeoutMs`: its executions stay `running` and the next start recovers them).
25
+ * Callable from anywhere, not only from a signal handler; calling it again returns the shutdown already under way.
26
+ */
27
+ export function createShutdown(target, { timeoutMs = 20_000, exit = process.exit } = {}) {
28
+ let running;
29
+ const shutdown = () => (running ??= stopEngine(target, timeoutMs).then(exit));
30
+ return Object.defineProperty(shutdown, 'running', { get: () => running !== undefined });
31
+ }
32
+ // Ctrl+C, SIGTERM (kill) and SIGHUP (closed terminal) all run the one shutdown; a second signal while it runs exits at once.
33
+ export function stopOnSigint(target, { exit = process.exit, ...options } = {}) {
34
+ const shutdown = createShutdown(target, { ...options, exit });
35
+ for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
36
+ process.on(signal, () => (shutdown.running ? exit(130) : void shutdown()));
37
+ }
38
+ return shutdown;
39
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // The entry point: read the command line, hand it to the command, turn a CliError into an exit code. Nothing else lives here.
3
+ import { resolve } from 'node:path';
4
+ import { carrierHome } from '../../config/default-path.js';
5
+ import { parseCli, usage } from './args.js';
6
+ import { CliError } from './cli-error.js';
7
+ import { configPathFor, loadConfigFile, noConfig } from './config-file.js';
8
+ // Not console.*: once logging is installed that goes to the log file, and these are for the person at the terminal.
9
+ const fail = (code, ...lines) => {
10
+ process.stderr.write(`${lines.join('\n')}\n`);
11
+ process.exit(code);
12
+ };
13
+ const cli = parseCli(process.argv.slice(2));
14
+ if (!cli.ok)
15
+ fail(2, cli.message, '', usage());
16
+ else if (cli.flags.help) {
17
+ process.stdout.write(`${usage()}\n`);
18
+ process.exit(0);
19
+ }
20
+ else if (cli.flags.version) {
21
+ process.stdout.write(`${(await import('../../control/server.js')).version}\n`); // the value both hellos carry
22
+ process.exit(0);
23
+ }
24
+ else {
25
+ try {
26
+ const configPath = configPathFor(cli.flags.config);
27
+ const folder = carrierHome(); // the one read of CARRIER_HOME; everything below is handed it
28
+ let exit;
29
+ switch (cli.command) {
30
+ case 'schema':
31
+ (await import('./commands/schema.js')).schemaCommand();
32
+ break;
33
+ case 'init':
34
+ exit = await (await import('./commands/init.js')).initCommand(cli.flags, folder);
35
+ break;
36
+ case 'doctor':
37
+ if (configPath === undefined)
38
+ throw noConfig('Usage: carrier doctor --config <path>');
39
+ exit = await (await import('./commands/doctor.js')).doctorCommand(loadConfigFile(configPath), folder, cli.flags.json === true);
40
+ break;
41
+ case 'start':
42
+ if (configPath === undefined)
43
+ throw noConfig('Usage: carrier start --config <path>');
44
+ loadConfigFile(configPath); // refuses a config the detached run would only fail on
45
+ exit = await (await import('./commands/start.js')).startCommand(folder, resolve(configPath));
46
+ break;
47
+ case 'status':
48
+ exit = await (await import('./commands/status.js')).statusCommand(folder);
49
+ break;
50
+ case 'watch':
51
+ exit = await (await import('./commands/watch.js')).watchCommand(folder);
52
+ break;
53
+ case 'stop':
54
+ case 'pause':
55
+ case 'resume':
56
+ case 'check':
57
+ exit = await (await import('./commands/control.js')).controlCommand(cli.command, folder);
58
+ break;
59
+ case 'restart':
60
+ exit = await (await import('./commands/restart.js')).restartCommand(folder, cli.target, { author: cli.flags.author, reviewer: cli.flags.reviewer });
61
+ break;
62
+ case 'chat':
63
+ case 'chat-host':
64
+ // the chat runs from the Carrier folder alone (ADR 0016), not from an engine config
65
+ if (cli.flags.config !== undefined)
66
+ throw new CliError(2, `carrier ${cli.command} takes no --config: it runs from the Carrier folder (CARRIER_HOME, else ~/.carrier)`);
67
+ if (cli.command === 'chat')
68
+ exit = await (await import('./commands/chat.js')).chatCommand(cli.flags);
69
+ else
70
+ await (await import('./commands/chat-host.js')).chatHostCommand(); // runs until stopped
71
+ break;
72
+ default:
73
+ if (configPath === undefined)
74
+ throw noConfig(usage(), '');
75
+ await (await import('./commands/run.js')).runCommand(loadConfigFile(configPath), folder); // runs until Ctrl+C
76
+ }
77
+ if (exit !== undefined)
78
+ process.exit(exit); // otherwise the command keeps the process alive (running), or has nothing left to do
79
+ }
80
+ catch (error) {
81
+ if (error instanceof CliError)
82
+ fail(error.code, ...error.lines);
83
+ throw error;
84
+ }
85
+ }
@@ -0,0 +1,9 @@
1
+ import { spawn } from 'node:child_process';
2
+ // Only GitHub issue and PR pages, whatever the row says: the URL goes to `open`, which would take anything.
3
+ const githubPage = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/(issues|pull)\/\d+$/;
4
+ /** Opens a GitHub issue or PR page in the browser. */
5
+ export function openUrl(url) {
6
+ if (!githubPage.test(url))
7
+ return;
8
+ spawn('open', [url], { stdio: 'ignore', detached: true }).on('error', () => { }).unref(); // macOS; where `open` is missing nothing happens
9
+ }
@@ -0,0 +1,204 @@
1
+ import { formatDuration } from '../../format/duration.js';
2
+ import { printable } from './printable.js';
3
+ import { finishedSections, itemsOf, maxListed, waitingEpic } from './ui-state.js';
4
+ import { detailOf, historyLine } from './history.js';
5
+ const codes = { bold: 1, dim: 2, inverse: 7, red: 31, green: 32, yellow: 33, cyan: 36 };
6
+ // eslint-disable-next-line no-control-regex
7
+ const ansi = /\x1b\[[0-9;]*m/g;
8
+ export const visibleLength = (text) => text.replace(ansi, '').length;
9
+ /** Cuts a styled line to `columns` visible characters (a wrapped line would break the cursor-up count of the redraw). */
10
+ export function fit(line, columns) {
11
+ if (visibleLength(line) <= columns)
12
+ return line;
13
+ let out = '';
14
+ let shown = 0;
15
+ let open = false; // a style is on and not yet reset
16
+ for (const part of line.split(/(\x1b\[[0-9;]*m)/)) {
17
+ if (/^\x1b\[[0-9;]*m$/.test(part)) {
18
+ out += part;
19
+ open = part !== '\x1b[0m';
20
+ }
21
+ else {
22
+ const room = columns - shown;
23
+ out += part.slice(0, Math.max(0, room));
24
+ shown += Math.min(part.length, Math.max(0, room));
25
+ }
26
+ }
27
+ return open ? `${out}\x1b[0m` : out;
28
+ }
29
+ const stages = ['ship', 'review', 'fix'];
30
+ /**
31
+ * The panel for a terminal: header, slots, then one section per kind of epic (running, queued, needing a person, ready
32
+ * for test). Pure: the same snapshot gives the same lines; `color: false` gives them with no ANSI.
33
+ */
34
+ export function renderDashboard(s, { columns = 80, color = true, ui, logLines = [], activity, detail, quit = 'stop', canChoose = false } = {}) {
35
+ const paint = (text, ...styles) => (color && styles.length > 0 ? `\x1b[${styles.map((x) => codes[x]).join(';')}m${text}\x1b[0m` : text);
36
+ const width = Math.min(Math.max(columns, 50), 80) - 2;
37
+ const ago = (at) => (at === null ? 'a while' : `${formatDuration(Math.max(0, s.now - at))} ago`);
38
+ const up = `up ${formatDuration(s.now - s.startedAt)}`;
39
+ const title = '◆ CARRIER ';
40
+ const rule = '─'.repeat(Math.max(3, width - title.length - up.length - 2));
41
+ const lines = [];
42
+ const add = (text = '') => lines.push(text);
43
+ add(` ${paint(title, 'bold', 'cyan')}${paint(rule, 'dim')} ${paint(up, 'dim')}`);
44
+ const wait = s.nextCheckAt === null ? 'starting' : s.nextCheckAt <= s.now ? 'checking now…' : `next check in ${formatDuration(s.nextCheckAt - s.now)}`;
45
+ add(` ${paint(`watching ${s.repos} repo${s.repos === 1 ? '' : 's'} · ${wait}`, 'dim')}`);
46
+ if (s.problem)
47
+ add(` ${paint(`▲ ${printable(s.problem)}`, 'yellow')}`);
48
+ if (s.paused)
49
+ add(` ${paint('▲ PAUSED: new epics wait in the queue (p resumes)', 'yellow')}`);
50
+ add();
51
+ const dots = Array.from({ length: s.slots.total }, (_, i) => (i < s.slots.busy ? paint('●', 'green') : paint('○', 'dim'))).join('');
52
+ add(` Slots ${dots} ${s.slots.busy} of ${s.slots.total} busy`);
53
+ const section = (name, ...styles) => {
54
+ add();
55
+ add(` ${paint(name, 'bold', ...styles)}`);
56
+ };
57
+ const selected = (repo, issue) => ui?.selected === `${repo}#${issue}`;
58
+ // An issue title is written by someone else: printable, and `fit` cuts the line. A missing one leaves the row as it was.
59
+ const titled = (title) => (title ? ` ${printable(title)}` : '');
60
+ const pointer = paint('›', 'bold', 'cyan');
61
+ // The first line of the selected row carries the pointer where the others have the bar or the indent.
62
+ const lead = (repo, issue, plain) => (selected(repo, issue) ? ` ${pointer} ` : plain);
63
+ // Only the keys that do something now: no ↑↓ with one row, no logs for a row with no agent.
64
+ // Packed into as many lines as the width needs: a line cut at the edge loses the keys at its end (q among them).
65
+ const hints = () => {
66
+ const items = itemsOf(s);
67
+ const current = items.find((i) => i.key === ui?.selected);
68
+ const waiting = canChoose && ui?.selected && waitingEpic(s, ui.selected);
69
+ const parts = [items.length > 1 && '↑↓ select', current && 'enter open', current?.executionId && 'l logs', waiting && 'o options · a automatic', 'r check now', s.paused ? 'p resume' : 'p pause', '? help', quit === 'stop' ? 'q quit' : 'q leave'].filter(Boolean);
70
+ return parts.reduce((rows, part) => {
71
+ const last = rows.at(-1);
72
+ if (last !== undefined && visibleLength(`${last} · ${part}`) <= width - 1)
73
+ rows[rows.length - 1] = `${last} · ${part}`;
74
+ else
75
+ rows.push(part);
76
+ return rows;
77
+ }, []);
78
+ };
79
+ const footer = () => {
80
+ if (ui?.toast)
81
+ add(` ${paint(`› ${printable(ui.toast.text)}`, 'cyan')}`);
82
+ add();
83
+ const exit = quit === 'stop' ? 'q quit' : 'q leave';
84
+ const rows = ui === undefined ? [quit === 'stop' ? 'Ctrl+C stops it' : 'Ctrl+C leaves it running'] : ui.mode === 'dashboard' ? hints() : ui.mode === 'choose' ? [`↑↓ role · ←→ option · enter start · esc cancel · ${exit}`] : [`esc back · ${exit}`];
85
+ for (const row of rows)
86
+ add(` ${paint(row, 'dim')}`);
87
+ };
88
+ if (ui?.mode === 'help') {
89
+ section('KEYS');
90
+ for (const [key, what] of [['↑ ↓ j k', 'select an epic'], ['enter', 'open its PR (or issue) in the browser'], ['l', 'agent output of the selected epic'], ...(canChoose ? [['o', 'choose the options of the selected epic that is waiting'], ['a', 'start it on its default options now']] : []), ['r', 'check GitHub now'], ['p', 'pause or resume taking new epics'], ['?', 'this help'], ['esc', 'back'], ['q', quit === 'stop' ? 'stop Carrier (same as Ctrl+C)' : 'leave (Carrier keeps running)']])
91
+ add(` ${paint(key.padEnd(9), 'bold', 'cyan')} ${what}`);
92
+ footer();
93
+ return lines.map((line) => fit(line, width + 2));
94
+ }
95
+ if (ui?.mode === 'logs') {
96
+ const target = [...s.epics, ...s.done, ...(s.finished ?? [])].find((e) => selected(e.repo, e.issue));
97
+ section(`AGENT OUTPUT · ${ui.selected ?? ''}${target?.executionId ? ` · ${target.executionId.slice(0, 8)}` : ''}`, 'cyan');
98
+ if (logLines.length === 0)
99
+ add(paint(' (nothing written yet)', 'dim'));
100
+ for (const l of logLines)
101
+ add(` ${printable(l)}`);
102
+ footer();
103
+ return lines.map((line) => fit(line, width + 2));
104
+ }
105
+ const running = s.epics.filter((e) => e.status === 'running');
106
+ const needsYou = s.epics.filter((e) => e.status === 'needs_human');
107
+ const card = (e, first) => {
108
+ const chosen = selected(e.repo, e.issue);
109
+ const bar = chosen ? paint('▌', 'bold', 'cyan') : paint('┃', 'dim');
110
+ const choice = e.options?.choiceDeadline;
111
+ const chain = choice != null ? paint(`waiting for its choice · starts in ${formatDuration(choice - s.now)}`, 'dim') : e.stage === null ? paint('starting', 'dim') : stages.map((st) => (st === e.stage ? paint(`[${st}]`, 'bold', 'cyan') : paint(st, 'dim'))).join(paint(' ▸ ', 'dim'));
112
+ const round = e.round === null ? '' : ` · round ${Array.from({ length: e.maxReviews }, (_, i) => (i < e.round ? '●' : '○')).join('')}`;
113
+ if (!first)
114
+ add();
115
+ add(chosen ? ` ${pointer}${paint(` ${e.repo} #${e.issue} `, 'bold', 'inverse')}${titled(e.title)}` : ` ${bar} ${paint(`${e.repo} #${e.issue}`, 'bold')}${titled(e.title)}`); // the selected card's title is a highlighted label
116
+ if (chosen && ui?.mode === 'choose' && ui.choice) {
117
+ add(` ${bar} ${paint(`choose the options · ${formatDuration(choice - s.now)} left`, 'dim')}`);
118
+ for (const role of ['author', 'reviewer'])
119
+ add(` ${bar} ${ui.choice.role === role ? pointer : ' '} ${role.padEnd(8)} ${paint(`‹ ${ui.choice[role]} ›`, ui.choice.role === role ? 'bold' : 'dim')}`);
120
+ }
121
+ else
122
+ add(` ${bar} ${chain}${round}`);
123
+ if (choice != null && !(chosen && ui?.mode === 'choose' && ui.choice))
124
+ for (const role of ['author', 'reviewer'])
125
+ add(` ${bar} ${paint(`${role} ${e.options[role]}`, 'dim')}`); // one per line: long option names do not fit two roles in 50 columns
126
+ const history = detail && detailOf(detail, e);
127
+ if (history && history.length > 0)
128
+ add(` ${bar} ${historyLine(history, s.now, paint, width - 4)}`);
129
+ const blockers = e.blockers > 0 ? paint(` · blockers ${e.blockers}`, 'yellow') : '';
130
+ const slices = e.slices && e.slices.total > 0 ? ` · slices ${e.slices.done}/${e.slices.total}` : '';
131
+ add(` ${bar} ${formatDuration(s.now - e.stageSince)} in stage · ${e.lastActivityAt === null ? 'no activity yet' : `active ${ago(e.lastActivityAt)}`}${blockers}${slices}`);
132
+ add(` ${bar} ${paint(e.prUrl ?? e.issueUrl, 'dim')}`);
133
+ if (e.lastError)
134
+ add(` ${bar} ${paint(`error: ${printable(e.lastError)}`, 'red')}`);
135
+ // What the agent is doing: three lines for the selected card, one for the others.
136
+ const now = e.executionId && activity ? activity(e.executionId, chosen ? 3 : 1) : [];
137
+ now.forEach((l, i) => add(` ${bar} ${i === 0 ? paint('now', 'dim') : ' '} ${paint(printable(l), 'dim')}`));
138
+ // The open blockers of a review or fix, by title, listed the way the queue is.
139
+ const titles = e.stage === 'review' || e.stage === 'fix' ? (e.blockerTitles ?? []) : [];
140
+ for (const t of titles.slice(0, 3))
141
+ add(` ${bar} ${paint(`· ${printable(t)}`, 'yellow')}`);
142
+ if (titles.length > 0 && e.blockers > 3)
143
+ add(` ${bar} ${paint(`… and ${e.blockers - 3} more`, 'dim')}`);
144
+ };
145
+ if (running.length > 0) {
146
+ section('RUNNING', 'cyan');
147
+ running.forEach((e, i) => card(e, i === 0));
148
+ }
149
+ if (s.queue.length > 0) {
150
+ section(`QUEUED ${s.queue.length}`);
151
+ for (const q of s.queue.slice(0, maxListed))
152
+ add(`${lead(q.repo, q.issue, ' ')}${q.repo} #${q.issue}${titled(q.title)}`);
153
+ if (s.queue.length > maxListed)
154
+ add(paint(` … and ${s.queue.length - maxListed} more`, 'dim'));
155
+ }
156
+ if (needsYou.length > 0) {
157
+ section('NEEDS YOU ▲', 'yellow');
158
+ for (const e of needsYou) {
159
+ add(`${lead(e.repo, e.issue, ' ')}${paint(`${e.repo} #${e.issue}`, 'bold')}${titled(e.title)}`);
160
+ if (e.needsHumanReason)
161
+ add(` ${printable(e.needsHumanReason)}`);
162
+ const history = detail && detailOf(detail, e);
163
+ if (history && history.length > 0)
164
+ add(` ${historyLine(history, s.now, paint, width - 3)}`);
165
+ add(` ${paint(e.prUrl ?? e.issueUrl, 'dim')}`);
166
+ if (e.lastError)
167
+ add(` ${paint(`error: ${printable(e.lastError)}`, 'red')}`);
168
+ }
169
+ }
170
+ // last: a cut title costs less than a cut PR number or time
171
+ const row = (d) => `${lead(d.repo, d.issue, ' ')}${d.repo} #${d.issue}${d.prNumber === null ? '' : ` · PR #${d.prNumber}`} · ${ago(d.finishedAt)}${titled(d.title)}`;
172
+ if (s.done.length > 0) {
173
+ section('READY FOR TEST ✔', 'green');
174
+ for (const d of s.done) {
175
+ add(row(d));
176
+ const history = detail && detailOf(detail, d);
177
+ if (history && history.length > 0)
178
+ add(` ${historyLine(history, s.now, paint, width - 3)}`);
179
+ }
180
+ }
181
+ // Only while GitHub refused some repo's last check; a healthy Carrier shows nothing extra.
182
+ const refused = (s.watched ?? []).filter((w) => !w.ok);
183
+ if (refused.length > 0) {
184
+ section('REPOS GITHUB REFUSED ▲', 'yellow');
185
+ for (const w of refused)
186
+ add(` ${paint(w.repo, 'bold')} ${paint(printable(w.error ?? 'the last check failed'), 'red')}`);
187
+ }
188
+ if (s.epics.length === 0 && s.queue.length === 0 && s.done.length === 0) {
189
+ add();
190
+ add(` ${paint('Idle.', 'bold')} Mark an epic ${paint('solve:epic:ready', 'cyan')} in a watched repo to start one.`);
191
+ }
192
+ for (const { name, rows, more } of finishedSections(s)) {
193
+ section(name, 'dim');
194
+ for (const f of rows) {
195
+ add(row(f));
196
+ if (f.cancelReason)
197
+ add(` ${printable(f.cancelReason)}`);
198
+ }
199
+ if (more > 0)
200
+ add(paint(` +${more} more`, 'dim'));
201
+ }
202
+ footer();
203
+ return lines.map((line) => fit(line, width + 2));
204
+ }
@@ -0,0 +1,38 @@
1
+ import { formatDuration } from '../../format/duration.js';
2
+ // ponytail: entries of epics that left the panel stay until the provider is dropped; a prune if a long run ever shows it.
3
+ const caches = new WeakMap();
4
+ /** An epic's detail, asked again only when its stage, round, status, blockers or latest execution changed (a finished epic has only the last). */
5
+ export function detailOf(provider, e) {
6
+ const cache = caches.get(provider) ?? new Map();
7
+ caches.set(provider, cache);
8
+ const key = `${e.repo}#${e.issue}`;
9
+ const signature = [e.stage, e.round, e.status, e.blockers, e.executionId].join('|');
10
+ const hit = cache.get(key);
11
+ if (hit?.signature === signature)
12
+ return hit.detail;
13
+ const detail = provider(e.repo, e.issue);
14
+ if (detail === undefined)
15
+ return hit?.detail ?? null;
16
+ cache.set(key, { signature, detail });
17
+ return detail;
18
+ }
19
+ const marks = { completed: ['✓', 'green'], changes_requested: ['↻ changes', 'yellow'], needs_human: ['▲ needs you', 'yellow'] };
20
+ /** "ship ✓ 14m · review ↻ changes round 1 4m · [fix] 2m": oldest first, the running stage in brackets; the oldest are dropped to fit `width`. */
21
+ export function historyLine(sessions, now, paint, width) {
22
+ const parts = [...sessions].reverse().map((x) => {
23
+ const took = formatDuration((x.finishedAt ?? now) - x.startedAt);
24
+ const round = x.round === null ? '' : ` round ${x.round}`;
25
+ if (x.status === 'running')
26
+ return { shown: `[${x.stage}]${round} ${took}`, styled: paint(`[${x.stage}]`, 'bold', 'cyan') + `${round} ${took}` };
27
+ const [mark, color] = x.failure ? [`✗ ${x.failure.kind}`, 'yellow'] : x.result ? marks[x.result.outcome] : ['', 'dim'];
28
+ return { shown: `${x.stage}${mark ? ` ${mark}` : ''}${round} ${took}`, styled: `${x.stage}${mark ? ` ${paint(mark, color)}` : ''}${round} ${took}` };
29
+ });
30
+ const separator = ' · ';
31
+ const length = (list) => list.reduce((n, p) => n + p.shown.length, 0) + separator.length * (list.length - 1);
32
+ let dropped = false;
33
+ while (parts.length > 1 && length(parts) + (dropped ? 2 : 0) > width) {
34
+ parts.shift();
35
+ dropped = true;
36
+ }
37
+ return `${dropped ? '… ' : ''}${parts.map((p) => p.styled).join(paint(separator, 'dim'))}`;
38
+ }
@@ -0,0 +1,60 @@
1
+ import { createScopedLogger } from '@ashulab/universal-logger';
2
+ import { startKeys } from './keys.js';
3
+ import { startPanel } from './panel.js';
4
+ import { renderDashboard } from './dashboard.js';
5
+ import { initialUi, itemsOf, onKey, settle, toastFor } from './ui-state.js';
6
+ const logRows = 18;
7
+ const log = createScopedLogger('panel');
8
+ /** The engine's expected refusals of `chooseOptions`, in the panel's words. */
9
+ const refusalWords = {
10
+ 'not waiting': 'Already started on its defaults',
11
+ 'unknown option': 'That option is not in the configuration',
12
+ 'unknown epic': 'That epic is gone',
13
+ 'github unavailable': 'GitHub is not answering: it starts on its own when it does',
14
+ };
15
+ /**
16
+ * The dashboard with keys: select an epic, open it, read its agent's output, check GitHub now, pause intake, choose the options of a waiting epic, quit.
17
+ * Every key redraws at once instead of waiting for the next second. Returns the stop function.
18
+ */
19
+ export function startInteractivePanel({ snapshot, out, stdin, controls, now = Date.now, intervalMs, quit }) {
20
+ let ui = initialUi;
21
+ const canChoose = controls.chooseOptions !== undefined;
22
+ const panel = startPanel(snapshot, out, intervalMs, (s, columns) => {
23
+ ui = settle(ui, s, now());
24
+ const executionId = ui.mode === 'logs' ? itemsOf(s).find((i) => i.key === ui.selected)?.executionId : undefined;
25
+ return renderDashboard(s, { columns, ui, logLines: executionId ? controls.logs(executionId, logRows) : [], activity: controls.logs, detail: controls.detail, quit, canChoose });
26
+ });
27
+ const perform = (action) => {
28
+ if (action?.type === 'open')
29
+ controls.open(action.url);
30
+ else if (action?.type === 'poll')
31
+ void controls.poll();
32
+ else if (action?.type === 'pause')
33
+ void controls.setPaused(action.paused);
34
+ else if (action?.type === 'choose') {
35
+ // The engine says no when the deadline won the race, and a rejection nobody catches ends the process: tell the person instead.
36
+ Promise.resolve(controls.chooseOptions?.(action.repo, action.issue, { author: action.author, reviewer: action.reviewer })).catch((error) => {
37
+ const reason = error instanceof Error ? error.message : String(error);
38
+ // A refusal is the engine answering; anything else is a failure.
39
+ if (Object.hasOwn(refusalWords, reason))
40
+ log.info('the engine refused the chosen options', { reason, repo: action.repo, epic: action.issue });
41
+ else
42
+ log.error('could not start the epic on the chosen options', error, { repo: action.repo, epic: action.issue });
43
+ ui = { ...ui, toast: { text: refusalWords[reason] ?? `Could not start it: ${reason}`, until: now() + toastFor } };
44
+ panel.redraw();
45
+ });
46
+ }
47
+ else if (action?.type === 'quit')
48
+ controls.quit();
49
+ };
50
+ const stopKeys = startKeys(stdin, (key) => {
51
+ const next = onKey(ui, key, snapshot(), now(), canChoose);
52
+ ui = next.ui;
53
+ perform(next.action);
54
+ panel.redraw();
55
+ });
56
+ return () => {
57
+ stopKeys();
58
+ panel();
59
+ };
60
+ }
@@ -0,0 +1,22 @@
1
+ import { emitKeypressEvents } from 'node:readline';
2
+ /**
3
+ * Reads single keys from the terminal. Raw mode means Ctrl+C is a key like any other (it no longer raises SIGINT),
4
+ * so the caller has to treat it as a quit. Nothing happens without a terminal. Returns the stop function.
5
+ */
6
+ export function startKeys(stdin, onKey) {
7
+ if (!stdin.isTTY || stdin.setRawMode === undefined)
8
+ return () => { };
9
+ emitKeypressEvents(stdin);
10
+ stdin.setRawMode(true);
11
+ stdin.resume();
12
+ const handler = (sequence, key) => onKey({ name: key?.name, ctrl: key?.ctrl, sequence });
13
+ stdin.on('keypress', handler);
14
+ const restore = () => stdin.setRawMode?.(false); // also when the process dies on its own
15
+ process.once('exit', restore);
16
+ return () => {
17
+ stdin.off('keypress', handler);
18
+ process.removeListener('exit', restore);
19
+ restore();
20
+ stdin.pause();
21
+ };
22
+ }