@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,85 @@
1
+ import { closeSync, openSync, readSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ // The agent's output is one JSON event per line, in a different shape for each harness. What is worth reading in each:
4
+ // what it said, and what tool it ran. Everything else (init, usage, step bookkeeping) is skipped.
5
+ const brief = (value, max = 60) => {
6
+ const text = typeof value === 'string' ? value : JSON.stringify(value) ?? '';
7
+ return text.length > max ? `${text.slice(0, max - 1)}…` : text;
8
+ };
9
+ // Claude's own notices: what it published and which of its background jobs ended. Starts, progress and usage are noise.
10
+ function summarizeNotice(e) {
11
+ if (e.subtype === 'code_change_published')
12
+ return `▲ PR #${e.identifier} ${e.action}`;
13
+ if (e.subtype === 'vcs_state_changed' && e.kind === 'push')
14
+ return '▲ pushed';
15
+ if (e.subtype !== 'task_notification')
16
+ return null;
17
+ const summary = String(e.summary ?? '');
18
+ if (e.status === 'failed')
19
+ return `✗ ${brief(summary, 80)}`;
20
+ // a sub-agent (one `ship` slice) reports its result as JSON; a finished shell command is already in the log as the line that started it
21
+ const slice = /"slice":\s*"([^"]+)",\s*"outcome":\s*"([^"]+)"/.exec(summary);
22
+ return slice ? `✓ slice ${slice[1]} ${slice[2]}` : null;
23
+ }
24
+ export function summarizeLogLine(line) {
25
+ if (line.trim() === '')
26
+ return null;
27
+ let e;
28
+ try {
29
+ e = JSON.parse(line);
30
+ }
31
+ catch {
32
+ return line.trim(); // not an event: an error message, say
33
+ }
34
+ if (e === null || typeof e !== 'object')
35
+ return line.trim();
36
+ // Claude Code (stream-json)
37
+ if (e.type === 'assistant') {
38
+ const parts = Array.isArray(e.message?.content) ? e.message.content : [];
39
+ const out = parts.map((c) => (c?.type === 'text' ? String(c.text).trim() : c?.type === 'tool_use' ? `▸ ${c.name} ${brief(c.input)}` : '')).filter(Boolean);
40
+ return out.length > 0 ? out.join(' ') : null;
41
+ }
42
+ if (e.type === 'result')
43
+ return `result: ${brief(e.result ?? e.subtype ?? e.status ?? '', 100)}`;
44
+ if (e.type === 'system')
45
+ return summarizeNotice(e);
46
+ // Codex (--json)
47
+ if (e.item?.type === 'agent_message')
48
+ return String(e.item.text).trim();
49
+ if (e.item?.type === 'command_execution' && e.type === 'item.started')
50
+ return `$ ${brief(e.item.command, 90)}`;
51
+ // Antigravity (stream-json)
52
+ if (e.event === 'step_update') {
53
+ const su = e.step_update ?? {};
54
+ if (su.tool_name && su.state === 'ACTIVE')
55
+ return `▸ ${su.tool_name} ${brief(su.tool_info ?? '')}`;
56
+ return su.state === 'DONE' && su.step_type === 'agent_response' && typeof su.text_delta === 'string' && su.text_delta.trim() ? su.text_delta.trim() : null;
57
+ }
58
+ if (e.event === 'result')
59
+ return `result: ${brief(e.result?.response ?? e.result?.status ?? '', 100)}`;
60
+ return null;
61
+ }
62
+ /** The last `count` meaningful lines of an execution's output, read from the end of the file (it can be megabytes). */
63
+ export function tailExecution(logs, executionId, count, fileSizeLimit = 256 * 1024) {
64
+ const path = join(logs, 'executions', executionId, 'stdout.log');
65
+ let fd;
66
+ try {
67
+ fd = openSync(path, 'r');
68
+ }
69
+ catch {
70
+ return []; // nothing written yet
71
+ }
72
+ try {
73
+ const size = statSync(path).size;
74
+ const start = Math.max(0, size - fileSizeLimit);
75
+ const buffer = Buffer.alloc(size - start);
76
+ readSync(fd, buffer, 0, buffer.length, start);
77
+ const lines = buffer.toString('utf8').split('\n');
78
+ if (start > 0)
79
+ lines.shift(); // cut in the middle of a line
80
+ return lines.flatMap((l) => summarizeLogLine(l) ?? []).slice(-count);
81
+ }
82
+ finally {
83
+ closeSync(fd);
84
+ }
85
+ }
@@ -0,0 +1,62 @@
1
+ import { renderDashboard } from './dashboard.js';
2
+ import { renderPanel } from './plain.js';
3
+ // What a person reads as "the state changed": durations tick every second and do not count.
4
+ const stateOf = (e) => [e.status, e.stage, e.round, e.blockers, e.needsHumanReason, e.lastError].join('|');
5
+ /**
6
+ * Shows the snapshot on `out` every `intervalMs`: redrawn in place with ANSI on a terminal, one plain line
7
+ * per state change otherwise. No input handling: Ctrl+C is the only way to stop the process.
8
+ * Returns the stop function.
9
+ */
10
+ export function startPanel(snapshot, out = process.stdout, intervalMs = 1000,
11
+ /** What a terminal is shown; the dashboard unless the caller draws its own (with a selection, say). */
12
+ render = (s, columns) => renderDashboard(s, { columns })) {
13
+ let drawn = 0;
14
+ const seen = new Map();
15
+ let queueSeen = '';
16
+ let finishedSeen = '';
17
+ // The panel owns the screen, so the cursor is hidden while it runs and always given back, also when the process dies on its own.
18
+ const hideCursor = '\x1b[?25l';
19
+ const showCursor = '\x1b[?25h';
20
+ const restoreOnExit = () => out.write(showCursor);
21
+ if (out.isTTY)
22
+ process.once('exit', restoreOnExit);
23
+ const redraw = (s) => {
24
+ // Lines wider than the terminal would wrap and break the cursor-up count; the dashboard cuts them itself.
25
+ const lines = render(s, out.columns);
26
+ out.write(`${drawn > 0 ? `\x1b[${drawn}A` : hideCursor}${lines.map((l) => `\r\x1b[2K${l}\n`).join('')}\x1b[J`);
27
+ drawn = lines.length;
28
+ };
29
+ const emitChanges = (s) => {
30
+ const at = new Date(s.now).toISOString();
31
+ const queue = `${s.slots.busy}/${s.slots.total}|${s.queue.map((q) => `${q.repo}#${q.issue}`).join(',')}`;
32
+ if (queue !== queueSeen) {
33
+ queueSeen = queue;
34
+ out.write(`${at} slots ${s.slots.busy}/${s.slots.total} queue ${s.queue.length}${s.queue.length > 0 ? `: ${s.queue.map((q) => `${q.repo}#${q.issue}`).join(', ')}` : ''}\n`);
35
+ }
36
+ for (const e of s.epics) {
37
+ const key = `${e.repo}#${e.issue}`;
38
+ if (seen.get(key) === stateOf(e))
39
+ continue;
40
+ seen.set(key, stateOf(e));
41
+ out.write(`${at} ${renderPanel({ ...s, epics: [e], finished: [] }).slice(1).map((l) => l.trim()).join(' | ')}\n`);
42
+ }
43
+ // The finished rows when the set changes: their "ago" ticks and does not count.
44
+ const finished = (s.finished ?? []).map((f) => `${f.repo}#${f.issue}`).join(',');
45
+ if (finished !== finishedSeen) {
46
+ finishedSeen = finished;
47
+ for (const l of renderPanel({ ...s, epics: [] }).slice(1))
48
+ out.write(`${at} ${l}\n`);
49
+ }
50
+ };
51
+ const tick = () => (out.isTTY ? redraw : emitChanges)(snapshot());
52
+ tick();
53
+ const timer = setInterval(tick, intervalMs);
54
+ const stop = () => {
55
+ clearInterval(timer);
56
+ if (out.isTTY) {
57
+ process.removeListener('exit', restoreOnExit);
58
+ out.write(showCursor);
59
+ }
60
+ };
61
+ return Object.assign(stop, { redraw: tick });
62
+ }
@@ -0,0 +1,32 @@
1
+ import { formatDuration } from '../../format/duration.js';
2
+ import { printable } from './printable.js';
3
+ import { finishedSections } from './ui-state.js';
4
+ // The one-line-per-change form for a pipe (a terminal gets the dashboard). Pure: no ANSI.
5
+ export function renderPanel(s) {
6
+ const { now, slots, queue, epics } = s;
7
+ const lines = [`carrier slots ${slots.busy}/${slots.total} queue ${queue.length}${queue.length > 0 ? `: ${queue.map((q) => `${q.repo}#${q.issue}`).join(', ')}` : ''}`];
8
+ for (const e of epics) {
9
+ const parts = [
10
+ `${e.repo}#${e.issue}`,
11
+ e.status === 'needs_human' ? 'NEEDS HUMAN' : e.options?.choiceDeadline != null ? `waiting for its choice (starts in ${formatDuration(e.options.choiceDeadline - now)})` : (e.stage ?? 'starting'),
12
+ ...(e.round === null ? [] : [`round ${e.round}/${e.maxReviews}`]),
13
+ ...(e.slices && e.slices.total > 0 ? [`slices ${e.slices.done}/${e.slices.total}`] : []),
14
+ `blockers ${e.blockers}`,
15
+ `in stage ${formatDuration(now - e.stageSince)}`,
16
+ e.lastActivityAt === null ? 'no activity' : `activity ${formatDuration(now - e.lastActivityAt)} ago`,
17
+ ];
18
+ lines.push(parts.join(' '), ` issue ${e.issueUrl}${e.prUrl ? ` PR ${e.prUrl}` : ''}`);
19
+ if (e.needsHumanReason)
20
+ lines.push(` reason: ${printable(e.needsHumanReason)}`);
21
+ if (e.lastError)
22
+ lines.push(` error: ${printable(e.lastError)}`);
23
+ }
24
+ for (const { status, rows, more } of finishedSections(s)) {
25
+ const kind = status === 'merged' ? 'done' : 'cancelled';
26
+ for (const f of rows)
27
+ lines.push(`${kind} ${f.repo}#${f.issue}${f.prNumber === null ? '' : ` PR #${f.prNumber}`} ${formatDuration(Math.max(0, now - f.finishedAt))} ago${f.title ? ` ${printable(f.title)}` : ''}${f.cancelReason ? ` reason: ${printable(f.cancelReason)}` : ''}`);
28
+ if (more > 0)
29
+ lines.push(`${kind} +${more} more`);
30
+ }
31
+ return lines;
32
+ }
@@ -0,0 +1,2 @@
1
+ /** The reason and the error come from the agent's own output: no control character (ESC included) may reach the terminal. */
2
+ export const printable = (text) => text.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
@@ -0,0 +1,110 @@
1
+ /** How many queued epics the dashboard lists before "… and N more"; selection only reaches what is listed. */
2
+ export const maxListed = 5;
3
+ /** How many finished epics of each kind the panel lists before "+N more"; selection only reaches what is listed. */
4
+ export const maxFinished = 10;
5
+ /** The DONE and CANCELLED sections that have something to show, each cut to `maxFinished` rows with the count of the rest. */
6
+ export function finishedSections(s) {
7
+ const all = s.finished ?? [];
8
+ return [['DONE', 'merged'], ['CANCELLED', 'cancelled']]
9
+ .map(([name, status]) => ({ name, status, all: all.filter((f) => f.status === status) }))
10
+ .filter((x) => x.all.length > 0)
11
+ .map(({ name, status, all }) => ({ name, status, rows: all.slice(0, maxFinished), more: Math.max(0, all.length - maxFinished) }));
12
+ }
13
+ const issueUrl = (repo, issue) => `https://github.com/${repo}/issues/${issue}`;
14
+ export function itemsOf(s) {
15
+ const running = s.epics.filter((e) => e.status === 'running');
16
+ const needsYou = s.epics.filter((e) => e.status === 'needs_human');
17
+ return [
18
+ ...running.map((e) => ({ key: `${e.repo}#${e.issue}`, url: e.prUrl ?? e.issueUrl, executionId: e.executionId })),
19
+ ...s.queue.slice(0, maxListed).map((q) => ({ key: `${q.repo}#${q.issue}`, url: issueUrl(q.repo, q.issue), executionId: null })),
20
+ ...needsYou.map((e) => ({ key: `${e.repo}#${e.issue}`, url: e.prUrl ?? e.issueUrl, executionId: e.executionId })),
21
+ ...[...s.done, ...finishedSections(s).flatMap((x) => x.rows)].map((d) => ({ key: `${d.repo}#${d.issue}`, url: d.prNumber === null ? issueUrl(d.repo, d.issue) : `https://github.com/${d.repo}/pull/${d.prNumber}`, executionId: d.executionId })),
22
+ ];
23
+ }
24
+ /** An epic that is waiting for its option choice, found by the key of a row. */
25
+ export const waitingEpic = (s, key) => s.epics.find((e) => `${e.repo}#${e.issue}` === key && e.status === 'running' && e.options !== undefined && e.options.choiceDeadline !== null);
26
+ export const initialUi = { selected: null, mode: 'dashboard', toast: null };
27
+ export const toastFor = 3000;
28
+ /** Pure: what a key does to the screen state, and what it asks the program to do. */
29
+ export function onKey(ui, key, s, now, canChoose = false) {
30
+ const items = itemsOf(s);
31
+ const say = (text) => ({ ...ui, toast: { text, until: now + toastFor } });
32
+ const raw = key.sequence === '?' ? '?' : key.name;
33
+ const name = raw === 'j' ? 'down' : raw === 'k' ? 'up' : raw; // vim keys move too
34
+ if ((key.ctrl && name === 'c') || name === 'q')
35
+ return { ui, action: { type: 'quit' } };
36
+ if (ui.mode === 'choose')
37
+ return chooseKey(ui, name, s, now);
38
+ if (name === '?')
39
+ return { ui: { ...ui, mode: ui.mode === 'help' ? 'dashboard' : 'help' } };
40
+ if (ui.mode !== 'dashboard') {
41
+ // Help and logs are one keypress away from the dashboard and take no other keys.
42
+ return name === 'escape' || (ui.mode === 'logs' && name === 'l') ? { ui: { ...ui, mode: 'dashboard' } } : { ui };
43
+ }
44
+ const at = items.findIndex((i) => i.key === ui.selected);
45
+ const current = at === -1 ? undefined : items[at];
46
+ switch (name) {
47
+ case 'down':
48
+ return { ui: { ...ui, selected: items.length === 0 ? null : items[Math.min(at + 1, items.length - 1)].key } };
49
+ case 'up':
50
+ return { ui: { ...ui, selected: items.length === 0 ? null : items[at <= 0 ? 0 : at - 1].key } };
51
+ case 'return':
52
+ return current ? { ui: say(`Opening ${current.url.replace('https://github.com/', '')}`), action: { type: 'open', url: current.url } } : { ui: say('Select an epic first (↑/↓)') };
53
+ case 'l':
54
+ if (!current)
55
+ return { ui: say('Select an epic first (↑/↓)') };
56
+ return current.executionId ? { ui: { ...ui, mode: 'logs' } } : { ui: say('That one has no agent output yet') };
57
+ case 'r':
58
+ return { ui: say('Checking GitHub now…'), action: { type: 'poll' } };
59
+ case 'o': {
60
+ const waiting = canChoose ? waitingEpic(s, ui.selected) : undefined;
61
+ if (!waiting?.options || waiting.options.names.length < 2)
62
+ return { ui };
63
+ return { ui: { ...ui, mode: 'choose', choice: { role: 'author', author: waiting.options.author ?? waiting.options.names[0], reviewer: waiting.options.reviewer ?? waiting.options.names[0] } } };
64
+ }
65
+ case 'a': {
66
+ const waiting = canChoose ? waitingEpic(s, ui.selected) : undefined;
67
+ return waiting ? { ui: say(`Starting ${ui.selected} on its defaults`), action: { type: 'choose', repo: waiting.repo, issue: waiting.issue } } : { ui };
68
+ }
69
+ case 'p':
70
+ return s.paused ? { ui: say('Resumed: taking epics again'), action: { type: 'pause', paused: false } } : { ui: say('Paused: no new epics will start'), action: { type: 'pause', paused: true } };
71
+ default:
72
+ return { ui };
73
+ }
74
+ }
75
+ /** The keys of the choice mode: ↑↓ move between the roles, ←→ change the option of the one they are on, enter sends, esc leaves. */
76
+ function chooseKey(ui, name, s, now) {
77
+ const waiting = waitingEpic(s, ui.selected);
78
+ const choice = ui.choice;
79
+ if (!waiting?.options || !choice || name === 'escape')
80
+ return { ui: { ...ui, mode: 'dashboard', choice: undefined } };
81
+ const { names } = waiting.options;
82
+ switch (name) {
83
+ case 'up':
84
+ case 'down':
85
+ return { ui: { ...ui, choice: { ...choice, role: choice.role === 'author' ? 'reviewer' : 'author' } } };
86
+ case 'left':
87
+ case 'right': {
88
+ const at = names.indexOf(choice[choice.role]);
89
+ const next = names[(at + (name === 'right' ? 1 : names.length - 1)) % names.length];
90
+ return { ui: { ...ui, choice: { ...choice, [choice.role]: next } } };
91
+ }
92
+ case 'return':
93
+ return { ui: { ...ui, mode: 'dashboard', choice: undefined, toast: { text: `Starting ${ui.selected} on ${choice.author} and ${choice.reviewer}`, until: now + toastFor } }, action: { type: 'choose', repo: waiting.repo, issue: waiting.issue, author: choice.author, reviewer: choice.reviewer } };
94
+ default:
95
+ return { ui };
96
+ }
97
+ }
98
+ /** The state to draw with: a selection that no longer exists moves to the first row, a toast past its time is dropped, logs of nothing go back. */
99
+ export function settle(ui, s, now) {
100
+ const items = itemsOf(s);
101
+ const current = items.find((i) => i.key === ui.selected);
102
+ const gone = (ui.mode === 'logs' && !current?.executionId) || (ui.mode === 'choose' && !waitingEpic(s, ui.selected)); // logs of nothing, or an epic that started meanwhile
103
+ return {
104
+ selected: current ? ui.selected : (items[0]?.key ?? null), // something is always selected, so the keys always act on a visible row
105
+ mode: gone ? 'dashboard' : ui.mode,
106
+ ...(ui.mode === 'choose' && !gone && { choice: ui.choice }),
107
+ // The wait ended with the choice still open: the options picked are lost, so say so.
108
+ toast: gone && ui.mode === 'choose' ? { text: 'Time ran out: started on its defaults', until: now + toastFor } : ui.toast && ui.toast.until > now ? ui.toast : null,
109
+ };
110
+ }
@@ -0,0 +1,121 @@
1
+ import { styleText } from 'node:util';
2
+ import { autocomplete, autocompleteMultiselect, cancel, confirm, groupMultiselect, intro, isCancel, log, note, outro, select, spinner, text } from '@clack/prompts';
3
+ import { Cancelled } from './types.js';
4
+ const keep = (value) => {
5
+ if (isCancel(value))
6
+ throw new Cancelled();
7
+ return value;
8
+ };
9
+ const checkLine = ({ name, detail }) => (detail ? `${name} — ${detail}` : name);
10
+ const fixLine = (fix) => log.message(styleText('dim', `→ ${fix}`));
11
+ /** One line per check, with its remedy under a failure. */
12
+ export function printCheck(check) {
13
+ if (check.status === 'ok')
14
+ log.success(checkLine(check));
15
+ else if (check.status === 'warn')
16
+ log.warn(checkLine(check));
17
+ else
18
+ log.error(styleText('red', checkLine(check)));
19
+ if (check.fix)
20
+ fixLine(check.fix);
21
+ }
22
+ // Past this many options a plain list is a chore to scroll: type to filter instead.
23
+ const longList = 10;
24
+ /**
25
+ * `compact`: what passes leaves no trace but a final count, only failures are printed (init: nobody needs eight lines
26
+ * that say everything is fine). Not compact: one line per check (the `doctor` command, which is asked for them).
27
+ */
28
+ export function makeClackUi({ compact = false } = {}) {
29
+ // A check that takes a while (a token from Forge, a call to GitHub) shows a spinner until its result arrives.
30
+ let spin;
31
+ return {
32
+ interactive: true,
33
+ intro: (title) => intro(title),
34
+ outro: (message) => outro(message),
35
+ note: (message, title) => note(message, title),
36
+ warn: (message) => log.warn(message),
37
+ checkStart: (name) => {
38
+ // Compact: one spinner for the whole run whose text changes, so the screen does not jump once per check.
39
+ if (spin === undefined) {
40
+ spin = spinner();
41
+ spin.start(name);
42
+ }
43
+ else
44
+ spin.message(name);
45
+ },
46
+ check: (check) => {
47
+ if (spin === undefined)
48
+ return check.status === 'ok' && compact ? undefined : printCheck(check);
49
+ if (check.status === 'fail')
50
+ spin.error(checkLine(check));
51
+ else if (compact)
52
+ return; // the spinner goes on to the next check
53
+ else
54
+ spin.stop(checkLine(check));
55
+ spin = undefined;
56
+ if (check.fix)
57
+ fixLine(check.fix);
58
+ },
59
+ checksEnd: (total, failed) => {
60
+ if (!compact || spin === undefined)
61
+ return;
62
+ spin.stop(failed === 0 ? `${total} checks passed` : `${total - failed} of ${total} checks passed`);
63
+ spin = undefined;
64
+ },
65
+ text: async ({ validate, ...options }) => keep(await text({ ...options, validate: validate && ((value) => validate(value ?? options.defaultValue ?? '')) })) || options.defaultValue || '',
66
+ select: async (options) => {
67
+ if (options.options.length > longList)
68
+ return keep(await autocomplete({ message: options.message, options: options.options, initialValue: options.initialValue, placeholder: 'Type to filter…', maxItems: 8 }));
69
+ return keep(await select(options));
70
+ },
71
+ confirm: async (message) => keep(await confirm({ message })),
72
+ multiselect: async ({ message, options, initialValues }) => {
73
+ const groups = Object.values(options);
74
+ if (groups.length === 1)
75
+ return keep(await autocompleteMultiselect({ message, options: groups[0], initialValues, required: true, placeholder: 'Type to filter…', maxItems: 8 }));
76
+ return keep(await groupMultiselect({ message, options, initialValues, required: true, maxItems: 12 }));
77
+ },
78
+ };
79
+ }
80
+ /** For `--yes` and for pipes: any question that was not answered by a flag is an error that names it. */
81
+ export const noPromptUi = {
82
+ ...makeClackUi(),
83
+ interactive: false,
84
+ checkStart: () => { },
85
+ check: (check) => (check.status === 'ok' ? undefined : printCheck(check)), // like the compact one: failures only
86
+ checksEnd: (total, failed) => {
87
+ if (failed === 0)
88
+ log.success(`${total} checks passed`);
89
+ },
90
+ text: async ({ message, defaultValue }) => {
91
+ if (defaultValue !== undefined)
92
+ return defaultValue;
93
+ throw new Error(`"${message}" needs an answer and there is no terminal to ask: pass it as a flag (carrier init --help)`);
94
+ },
95
+ select: async ({ message, initialValue }) => {
96
+ if (initialValue !== undefined)
97
+ return initialValue;
98
+ throw new Error(`"${message}" needs an answer and there is no terminal to ask: pass it as a flag (carrier init --help)`);
99
+ },
100
+ confirm: async () => false,
101
+ multiselect: async ({ message, initialValues }) => {
102
+ if (initialValues?.length)
103
+ return initialValues; // the repo of the current folder
104
+ throw new Error(`"${message}" needs an answer and there is no terminal to ask: pass it as a flag (carrier init --help)`);
105
+ },
106
+ };
107
+ /** "Starting…" while the engine comes up, then the line Carrier arrives with. Only on a terminal. */
108
+ export function announceStart({ framed }) {
109
+ if (!framed)
110
+ intro('Carrier');
111
+ const spin = spinner();
112
+ spin.start('Starting…');
113
+ return {
114
+ arrived: () => {
115
+ spin.stop('Carrier has arrived.');
116
+ outro(styleText('dim', 'Ctrl+C stops it'));
117
+ },
118
+ failed: () => spin.error('Carrier could not start'),
119
+ };
120
+ }
121
+ export const cancelled = () => cancel('✖ Cancelled');
@@ -0,0 +1,3 @@
1
+ /** The user said no (Ctrl+C, Esc, or declined the final question): not an error, the caller exits quietly. */
2
+ export class Cancelled extends Error {
3
+ }
@@ -0,0 +1,66 @@
1
+ import { localKinds } from '../../harnesses/local.js';
2
+ import { harnessNames } from '../../harnesses/names.js';
3
+ /** `forge:<agent>` or `gh`, the form used by flags and by the prompts' values. */
4
+ export function parseIdentity(text) {
5
+ if (text === 'gh')
6
+ return { kind: 'gh' };
7
+ const agent = /^forge:(.+)$/.exec(text)?.[1];
8
+ if (agent !== undefined)
9
+ return { kind: 'forge', agent };
10
+ throw new Error(`invalid identity "${text}", expected "forge:<agent>" or "gh"`);
11
+ }
12
+ export const identityText = (identity) => (identity.kind === 'gh' ? 'gh' : `forge:${identity.agent}`);
13
+ /** `<harness>:<model>`, the harness being one of `harnessNames`. */
14
+ export function parseHarnessModel(text) {
15
+ const match = new RegExp(`^(${harnessNames.join('|')}):(.+)$`).exec(text);
16
+ if (match === null)
17
+ throw new Error(`invalid agent "${text}", expected "<harness>:<model>" with harness one of ${harnessNames.join(", ")}`);
18
+ return { harness: match[1], model: match[2] };
19
+ }
20
+ export function parseProvider(text) {
21
+ const local = localKinds.find((kind) => kind === text);
22
+ if (local === undefined)
23
+ throw new Error(`invalid provider "${text}", expected ${localKinds.join(' or ')}`);
24
+ return local;
25
+ }
26
+ // A forge profile is named after its agent; the user's own login is just "gh".
27
+ const profileName = (identity) => (identity.kind === 'gh' ? 'gh' : identity.agent);
28
+ /** The user config (what goes in the file): only what has no default. The author's profile also acts as orchestrator. */
29
+ export function buildConfig({ repos, author, reviewer, leadReviewer, providers, failover, identities }) {
30
+ const [a, r] = [profileName(author.identity), profileName(reviewer.identity)];
31
+ const l = leadReviewer ? profileName(leadReviewer.identity) : undefined;
32
+ return {
33
+ repos: repos.map((name) => ({ name })),
34
+ ...(identities === 'shared' && { identities }), // the default, separate, is not written
35
+ profiles: {
36
+ [a]: author.identity,
37
+ [r]: reviewer.identity,
38
+ ...(l && leadReviewer ? { [l]: leadReviewer.identity } : {}),
39
+ },
40
+ orchestratorProfile: a,
41
+ providers,
42
+ ...(failover && { failover }),
43
+ agents: {
44
+ author: {
45
+ profile: a,
46
+ provider: author.provider,
47
+ model: author.model,
48
+ ...(author.persona && { persona: author.persona }),
49
+ },
50
+ reviewer: {
51
+ profile: r,
52
+ provider: reviewer.provider,
53
+ model: reviewer.model,
54
+ ...(reviewer.persona && { persona: reviewer.persona }),
55
+ },
56
+ ...(leadReviewer && l ? {
57
+ leadReviewer: {
58
+ profile: l,
59
+ provider: leadReviewer.provider,
60
+ model: leadReviewer.model,
61
+ ...(leadReviewer.persona && { persona: leadReviewer.persona }),
62
+ },
63
+ } : {}),
64
+ },
65
+ };
66
+ }
@@ -0,0 +1,94 @@
1
+ import { execFileSync, spawnSync } from 'node:child_process';
2
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
3
+ import { homedir } from 'node:os';
4
+ import { dirname, join } from 'node:path';
5
+ import { enginePlacesOf } from '../../config/engine-places.js';
6
+ import { repoNamePattern } from '../../config/schema.js';
7
+ import { defaults } from '../../config/load.js';
8
+ import { cliLoggedIn, runDoctor } from '../../doctor/doctor.js';
9
+ import { ghReposArgs, parseRepos } from '../../integrations/available-repos.js';
10
+ import { forgeCwd } from '../../integrations/forge-cwd.js';
11
+ import { GhGitHub } from '../../integrations/github.js';
12
+ import { listLocalModels } from '../../harnesses/local.js';
13
+ import { readForgeAgents } from './forge-registry.js';
14
+ export const realDeps = {
15
+ which: (command) => spawnSync('which', [command], { stdio: 'ignore' }).status === 0,
16
+ forgeAgents: () => readForgeAgents(),
17
+ ghLogin: () => {
18
+ try {
19
+ return execFileSync('gh', ['api', 'user', '--jq', '.login'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() || undefined;
20
+ }
21
+ catch {
22
+ return undefined; // not logged in or no gh: the doctor reports it, here it only means no hint
23
+ }
24
+ },
25
+ listRepos: (page) => {
26
+ // no config yet at init: the default bounds the call
27
+ const { repos, hasMore } = parseRepos(execFileSync('gh', ghReposArgs(page), { encoding: 'utf8', timeout: defaults.limits.githubCallTimeoutSeconds * 1000, stdio: ['ignore', 'pipe', 'pipe'] }));
28
+ return { names: repos.map((repo) => repo.name), hasMore };
29
+ },
30
+ originRepo: () => {
31
+ const url = spawnSync('git', ['remote', 'get-url', 'origin'], { encoding: 'utf8' }).stdout.trim();
32
+ const repo = /github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url)?.[1];
33
+ return repo !== undefined && repoNamePattern.test(repo) ? repo : undefined;
34
+ },
35
+ codexModel: () => {
36
+ try {
37
+ return /^model\s*=\s*"([^"]+)"/m.exec(readFileSync(join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'config.toml'), 'utf8'))?.[1];
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ },
43
+ codexModels: () => {
44
+ try {
45
+ const cache = JSON.parse(readFileSync(join(process.env.CODEX_HOME ?? join(homedir(), '.codex'), 'models_cache.json'), 'utf8'));
46
+ return (cache.models ?? []).flatMap((m) => (m.visibility === 'list' && m.slug ? [{ value: m.slug, label: m.slug, hint: m.description }] : []));
47
+ }
48
+ catch {
49
+ return []; // no cache yet: the model is typed
50
+ }
51
+ },
52
+ agyModels: () => {
53
+ try {
54
+ // "Fetching available models..." then `id<TAB>label` per line.
55
+ const out = execFileSync('agy', ['models'], { encoding: 'utf8', timeout: 20_000, stdio: ['ignore', 'pipe', 'ignore'] });
56
+ return out.split('\n').flatMap((line) => {
57
+ const [id, label] = line.split('\t');
58
+ return id && label ? [{ value: id, label: id, hint: label }] : [];
59
+ });
60
+ }
61
+ catch {
62
+ return []; // not installed, not logged in or offline: the model is typed
63
+ }
64
+ },
65
+ localModels: (local) => listLocalModels(local),
66
+ configFolders: (harness) => {
67
+ const home = homedir();
68
+ const isFolder = (path) => {
69
+ try {
70
+ return statSync(path).isDirectory();
71
+ }
72
+ catch {
73
+ return false; // a dangling link
74
+ }
75
+ };
76
+ return readdirSync(home)
77
+ .filter((name) => (harness === 'claude' ? name === '.claude' || name.startsWith('.claude-') : name === '.codex') && isFolder(join(home, name)))
78
+ .sort()
79
+ .map((name) => join(home, name));
80
+ },
81
+ loggedIn: cliLoggedIn,
82
+ exists: existsSync,
83
+ write: (path, content) => {
84
+ mkdirSync(dirname(path), { recursive: true });
85
+ writeFileSync(path, content);
86
+ },
87
+ doctor: runDoctor,
88
+ readConfig: (path) => JSON.parse(readFileSync(path, 'utf8')),
89
+ forgeAdd: () => spawnSync('agent-forge', ['add'], { stdio: 'inherit' }).status === 0,
90
+ readyEpics: async (config, folder) => {
91
+ const github = new GhGitHub({ profile: config.profiles[config.orchestratorProfile], cwd: forgeCwd(enginePlacesOf(folder)), timeoutMs: config.limits.githubCallTimeoutSeconds * 1000 });
92
+ return Object.fromEntries(await Promise.all(config.repos.map(async (repo) => [repo.name, await github.listReadyEpics(repo.name).then((epics) => epics.length, () => undefined)])));
93
+ },
94
+ };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,23 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
4
+ /** Same lookup order as Agent Forge: $AGENT_FORGE_CONFIG, the global registry, then ./agents.json. */
5
+ export function forgeRegistryPath(env = process.env, cwd = process.cwd()) {
6
+ if (env.AGENT_FORGE_CONFIG)
7
+ return resolve(env.AGENT_FORGE_CONFIG);
8
+ const global = join(env.XDG_CONFIG_HOME || join(homedir(), '.config'), 'agent-forge', 'agents.json');
9
+ if (existsSync(global))
10
+ return global;
11
+ const local = resolve(cwd, 'agents.json');
12
+ return existsSync(local) ? local : undefined;
13
+ }
14
+ /** The agents Forge knows about (names only, never keys). Empty when there is no registry; throws when it is unreadable. */
15
+ export function readForgeAgents(path = forgeRegistryPath()) {
16
+ if (path === undefined || !existsSync(path))
17
+ return [];
18
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
19
+ const agents = parsed.agents;
20
+ if (!Array.isArray(agents))
21
+ return [];
22
+ return agents.flatMap((agent) => typeof agent?.name === 'string' ? [{ name: agent.name, ...(typeof agent.label === 'string' ? { label: agent.label } : {}) }] : []);
23
+ }
@@ -0,0 +1,20 @@
1
+ import { defaults, loadConfig } from '../../config/load.js';
2
+ import { tilde } from './summary.js';
3
+ export function priorFrom(raw) {
4
+ const loaded = loadConfig(defaults, raw);
5
+ if (!loaded.ok)
6
+ return undefined;
7
+ const { config } = loaded;
8
+ const role = (r) => ({ identity: config.profiles[r.profile], provider: r.provider, model: r.model, ...(r.persona && { persona: r.persona }) });
9
+ // The loader makes a folder absolute; written back with `~` it stays portable.
10
+ const providers = Object.fromEntries(Object.entries(config.providers).map(([name, p]) => [name, p.configDir ? { ...p, configDir: tilde(p.configDir) } : p]));
11
+ return {
12
+ repos: config.repos.map((r) => r.name),
13
+ identities: config.identities,
14
+ providers,
15
+ ...(config.failover && { failover: config.failover }),
16
+ author: role(config.agents.author),
17
+ reviewer: role(config.agents.reviewer),
18
+ ...(config.agents.leadReviewer ? { leadReviewer: role(config.agents.leadReviewer) } : {}),
19
+ };
20
+ }